Skip to content

Commit 296e9f6

Browse files
devmgnclaude
andauthored
feat: add useLocalStorage hook with SSR support (#2615)
Add a useSyncExternalStore-based useLocalStorage hook with: - Module-scope cache for reduced localStorage access - Key-scoped listeners for same-tab synchronization - Cross-tab sync via StorageEvent - SSR safety via getServerSnapshot - Error handling for QuotaExceededError and corrupted JSON Also remove outdated type-fest reference from AGENTS.md. Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 05728fb commit 296e9f6

6 files changed

Lines changed: 478 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This file provides guidance to AI coding agents when working with code in this r
88

99
- **Next.js 16** with App Router, React 19, Turbopack, React Compiler (see `next.config.ts`)
1010
- Node.js 24, pnpm 10 (exact versions in package.json)
11-
- **TypeScript** with strict type checking (type-fest for advanced utilities)
11+
- **TypeScript** with strict type checking
1212
- **Tailwind CSS v4** with @tailwindcss/postcss
1313
- **TanStack Query** with queryOptions helper
1414
- **React Hook Form + Zod v4** with @hookform/resolvers

src/app/(sandbox)/dummy/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { SvgIcon } from "../../../components/SvgIcon";
99
import { useDebouncedCallback } from "../../../hooks/useDebouncedCallback";
1010
import { useDisclosure } from "../../../hooks/useDisclosure";
1111
import { useIsComposing } from "../../../hooks/useIsComposing";
12+
import { useLocalStorage } from "../../../hooks/useLocalStorage";
1213
import { useMediaQuery } from "../../../hooks/useMediaQuery";
1314
import { asyncDebounce } from "../../../utils/asyncDebounce";
1415
import { createCustomEvent } from "../../../utils/createCustomEvent";
@@ -22,6 +23,7 @@ export default function Page() {
2223
// noop
2324
}, 1000);
2425
useIsComposing();
26+
useLocalStorage("dummy", "");
2527
useMediaQuery("(min-width: 768px)");
2628
asyncDebounce(() => {
2729
// noop

src/hooks/useLocalStorage/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useLocalStorage } from "./useLocalStorage";
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { Meta, StoryObj } from "@storybook/nextjs-vite";
2+
import { useId, useRef } from "react";
3+
import { expect, userEvent } from "storybook/test";
4+
import { useLocalStorage } from "./useLocalStorage";
5+
import { Button } from "../../components/Button";
6+
import { Input } from "../../components/Input";
7+
8+
function UseLocalStorageDemo() {
9+
const [value, setValue, removeValue] = useLocalStorage(
10+
"storybook-demo",
11+
"initial",
12+
);
13+
const inputRef = useRef<HTMLInputElement>(null);
14+
const valueId = useId();
15+
const inputId = useId();
16+
17+
return (
18+
<div className="flex flex-col gap-4">
19+
<div className="flex gap-2">
20+
<label htmlFor={valueId}>Current Value: </label>
21+
<Input id={valueId} readOnly value={value} />
22+
</div>
23+
<div className="flex gap-2">
24+
<label htmlFor={inputId}>New Value: </label>
25+
<Input ref={inputRef} id={inputId} defaultValue="hello" />
26+
</div>
27+
<div className="flex gap-2">
28+
<Button
29+
onClick={() => {
30+
if (inputRef.current) {
31+
setValue(inputRef.current.value);
32+
}
33+
}}
34+
>
35+
Save
36+
</Button>
37+
<Button onClick={removeValue}>Remove</Button>
38+
</div>
39+
</div>
40+
);
41+
}
42+
43+
const meta = {
44+
parameters: {
45+
layout: "centered",
46+
},
47+
render: () => <UseLocalStorageDemo />,
48+
} satisfies Meta;
49+
50+
export default meta;
51+
type Story = StoryObj;
52+
53+
export const Default: Story = {};
54+
55+
export const InteractionTest: Story = {
56+
play: async ({ canvas }) => {
57+
const [currentValue, newValueInput] = canvas.getAllByRole("textbox");
58+
await expect(currentValue).toHaveValue("initial");
59+
60+
await userEvent.clear(newValueInput);
61+
await userEvent.type(newValueInput, "saved-value");
62+
63+
const [saveButton, removeButton] = canvas.getAllByRole("button");
64+
await userEvent.click(saveButton);
65+
await expect(currentValue).toHaveValue("saved-value");
66+
67+
await userEvent.click(removeButton);
68+
await expect(currentValue).toHaveValue("initial");
69+
},
70+
};
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { useLocalStorage } from "./useLocalStorage";
3+
4+
function createMockStorage() {
5+
const store = new Map<string, string>();
6+
return {
7+
getItem: vi.fn((key: string) => store.get(key) ?? null),
8+
setItem: vi.fn((key: string, value: string) => {
9+
store.set(key, value);
10+
}),
11+
removeItem: vi.fn((key: string) => {
12+
store.delete(key);
13+
}),
14+
};
15+
}
16+
17+
function uniqueKey() {
18+
return `test-key-${Math.random().toString(36).slice(2)}`;
19+
}
20+
21+
describe(useLocalStorage, () => {
22+
let mockStorage: ReturnType<typeof createMockStorage>;
23+
24+
beforeEach(() => {
25+
mockStorage = createMockStorage();
26+
vi.stubGlobal("localStorage", mockStorage);
27+
});
28+
29+
afterEach(() => {
30+
vi.restoreAllMocks();
31+
});
32+
33+
describe("基本動作", () => {
34+
it("初期値を返すこと", () => {
35+
const key = uniqueKey();
36+
const { result } = renderHook(() => useLocalStorage(key, "default"));
37+
expect(result.current[0]).toBe("default");
38+
});
39+
40+
it("localStorageの既存値を読み取ること", () => {
41+
const key = uniqueKey();
42+
mockStorage.setItem(key, JSON.stringify("stored"));
43+
const { result } = renderHook(() => useLocalStorage(key, "default"));
44+
expect(result.current[0]).toBe("stored");
45+
});
46+
47+
it("setValueで値を更新できること", () => {
48+
const key = uniqueKey();
49+
const { result } = renderHook(() => useLocalStorage(key, "default"));
50+
51+
act(() => {
52+
result.current[1]("updated");
53+
});
54+
55+
expect(result.current[0]).toBe("updated");
56+
expect(mockStorage.setItem).toHaveBeenCalledWith(
57+
key,
58+
JSON.stringify("updated"),
59+
);
60+
});
61+
62+
it("関数型更新をサポートすること", () => {
63+
const key = uniqueKey();
64+
const { result } = renderHook(() => useLocalStorage(key, 0));
65+
66+
act(() => {
67+
result.current[1]((prev) => prev + 1);
68+
});
69+
70+
expect(result.current[0]).toBe(1);
71+
72+
act(() => {
73+
result.current[1]((prev) => prev + 10);
74+
});
75+
76+
expect(result.current[0]).toBe(11);
77+
});
78+
79+
it("removeValueで値を削除して初期値に戻ること", () => {
80+
const key = uniqueKey();
81+
const { result } = renderHook(() => useLocalStorage(key, "default"));
82+
83+
act(() => {
84+
result.current[1]("stored");
85+
});
86+
expect(result.current[0]).toBe("stored");
87+
88+
act(() => {
89+
result.current[2]();
90+
});
91+
92+
expect(result.current[0]).toBe("default");
93+
expect(mockStorage.removeItem).toHaveBeenCalledWith(key);
94+
});
95+
});
96+
97+
describe("シリアライズ", () => {
98+
it("オブジェクトを正しく保存・読み取りできること", () => {
99+
const key = uniqueKey();
100+
const obj = { name: "test", count: 42 };
101+
const { result } = renderHook(() =>
102+
useLocalStorage(key, { name: "", count: 0 }),
103+
);
104+
105+
act(() => {
106+
result.current[1](obj);
107+
});
108+
109+
expect(result.current[0]).toStrictEqual(obj);
110+
});
111+
112+
it("配列を正しく保存・読み取りできること", () => {
113+
const key = uniqueKey();
114+
const arr = [1, 2, 3];
115+
const { result } = renderHook(() => useLocalStorage<number[]>(key, []));
116+
117+
act(() => {
118+
result.current[1](arr);
119+
});
120+
121+
expect(result.current[0]).toStrictEqual(arr);
122+
});
123+
124+
it("数値を正しく処理すること", () => {
125+
const key = uniqueKey();
126+
const { result } = renderHook(() => useLocalStorage(key, 0));
127+
128+
act(() => {
129+
result.current[1](42);
130+
});
131+
132+
expect(result.current[0]).toBe(42);
133+
});
134+
135+
it("nullを正しく処理すること", () => {
136+
const key = uniqueKey();
137+
const { result } = renderHook(() =>
138+
useLocalStorage<string | null>(key, null),
139+
);
140+
141+
expect(result.current[0]).toBeNull();
142+
143+
act(() => {
144+
result.current[1]("value");
145+
});
146+
expect(result.current[0]).toBe("value");
147+
148+
act(() => {
149+
result.current[1](null);
150+
});
151+
expect(result.current[0]).toBeNull();
152+
});
153+
});
154+
155+
describe("エラーハンドリング", () => {
156+
it("localStorage利用不可時に初期値を返すこと", () => {
157+
const key = uniqueKey();
158+
mockStorage.getItem.mockImplementation(() => {
159+
throw new Error("localStorage unavailable");
160+
});
161+
162+
const { result } = renderHook(() => useLocalStorage(key, "fallback"));
163+
expect(result.current[0]).toBe("fallback");
164+
});
165+
166+
it("破損JSONの場合に初期値を返すこと", () => {
167+
const key = uniqueKey();
168+
mockStorage.setItem(key, "invalid-json{{{");
169+
170+
const { result } = renderHook(() => useLocalStorage(key, "fallback"));
171+
expect(result.current[0]).toBe("fallback");
172+
});
173+
174+
it("QuotaExceededError時にconsole.warnを出力し状態を変更しないこと", () => {
175+
const key = uniqueKey();
176+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
177+
const { result } = renderHook(() => useLocalStorage(key, "default"));
178+
179+
mockStorage.setItem.mockImplementation(() => {
180+
throw new DOMException("quota exceeded", "QuotaExceededError");
181+
});
182+
183+
act(() => {
184+
result.current[1]("large-value");
185+
});
186+
187+
expect(warnSpy).toHaveBeenCalledOnce();
188+
expect(result.current[0]).toBe("default");
189+
// localStorage に書き込まれていないことを確認
190+
expect(mockStorage.getItem(key)).toBeNull();
191+
});
192+
});
193+
194+
describe("タブ間同期", () => {
195+
it("storageイベントで値が更新されること", () => {
196+
const key = uniqueKey();
197+
const { result } = renderHook(() => useLocalStorage(key, "default"));
198+
199+
act(() => {
200+
window.dispatchEvent(
201+
new StorageEvent("storage", {
202+
key,
203+
newValue: JSON.stringify("from-other-tab"),
204+
}),
205+
);
206+
});
207+
208+
expect(result.current[0]).toBe("from-other-tab");
209+
});
210+
211+
it("storageイベントで削除を検知すること", () => {
212+
const key = uniqueKey();
213+
const { result } = renderHook(() => useLocalStorage(key, "default"));
214+
215+
act(() => {
216+
result.current[1]("stored");
217+
});
218+
expect(result.current[0]).toBe("stored");
219+
220+
act(() => {
221+
mockStorage.removeItem(key);
222+
window.dispatchEvent(
223+
new StorageEvent("storage", {
224+
key,
225+
newValue: null,
226+
}),
227+
);
228+
});
229+
230+
expect(result.current[0]).toBe("default");
231+
});
232+
233+
it("無関係なキーのstorageイベントを無視すること", () => {
234+
const key = uniqueKey();
235+
const { result } = renderHook(() => useLocalStorage(key, "default"));
236+
237+
act(() => {
238+
window.dispatchEvent(
239+
new StorageEvent("storage", {
240+
key: "other-key",
241+
newValue: JSON.stringify("other-value"),
242+
}),
243+
);
244+
});
245+
246+
expect(result.current[0]).toBe("default");
247+
});
248+
});
249+
250+
describe("同一タブ同期", () => {
251+
it("同じキーの複数インスタンスが同期すること", () => {
252+
const key = uniqueKey();
253+
const { result: result1 } = renderHook(() =>
254+
useLocalStorage(key, "default"),
255+
);
256+
const { result: result2 } = renderHook(() =>
257+
useLocalStorage(key, "default"),
258+
);
259+
260+
act(() => {
261+
result1.current[1]("synced");
262+
});
263+
264+
expect(result1.current[0]).toBe("synced");
265+
expect(result2.current[0]).toBe("synced");
266+
});
267+
});
268+
269+
describe("クリーンアップ", () => {
270+
it("アンマウント時にリスナーが解除されること", () => {
271+
const key = uniqueKey();
272+
const addSpy = vi.spyOn(window, "addEventListener");
273+
const removeSpy = vi.spyOn(window, "removeEventListener");
274+
275+
const { unmount } = renderHook(() => useLocalStorage(key, "default"));
276+
277+
expect(addSpy).toHaveBeenCalledWith("storage", expect.any(Function));
278+
279+
unmount();
280+
281+
expect(removeSpy).toHaveBeenCalledWith("storage", expect.any(Function));
282+
});
283+
});
284+
});

0 commit comments

Comments
 (0)