Skip to content

Commit eb86d6e

Browse files
committed
refactor(hooks): isolate localStorage side-effects from state updater
1 parent 98ae406 commit eb86d6e

2 files changed

Lines changed: 127 additions & 59 deletions

File tree

hooks/useRecentSearches.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import React from 'react';
12
import { describe, it, expect, beforeEach, vi } from 'vitest';
23
import { renderHook, act } from '@testing-library/react';
34
import { useRecentSearches, MAX_SEARCHES, STORAGE_KEY } from './useRecentSearches';
@@ -76,6 +77,7 @@ describe('useRecentSearches', () => {
7677
});
7778

7879
expect(result.current.searches).toEqual([]);
80+
expect(removeItemSpy).toHaveBeenCalledTimes(1);
7981
expect(removeItemSpy).toHaveBeenCalledWith(STORAGE_KEY);
8082
});
8183

@@ -102,4 +104,45 @@ describe('useRecentSearches', () => {
102104
const { result: result2 } = renderHook(() => useRecentSearches());
103105
expect(result2.current.searches[0]).toBe('octocat');
104106
});
107+
108+
it('is safe under Strict Mode double invocation', () => {
109+
const setItemSpy = vi.spyOn(window.localStorage, 'setItem');
110+
const removeItemSpy = vi.spyOn(window.localStorage, 'removeItem');
111+
const { result } = renderHook(() => useRecentSearches(), {
112+
wrapper: React.StrictMode,
113+
});
114+
115+
// Hydration sets the state from storage (starts empty)
116+
expect(result.current.searches).toEqual([]);
117+
expect(setItemSpy).not.toHaveBeenCalled();
118+
expect(removeItemSpy).not.toHaveBeenCalled();
119+
120+
act(() => {
121+
result.current.addSearch('torvalds');
122+
});
123+
124+
expect(result.current.searches).toEqual(['torvalds']);
125+
expect(setItemSpy).toHaveBeenCalledTimes(1);
126+
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, JSON.stringify(['torvalds']));
127+
expect(removeItemSpy).not.toHaveBeenCalled();
128+
});
129+
130+
it('performs localStorage writes reactively outside state updater logic', () => {
131+
const setItemSpy = vi.spyOn(window.localStorage, 'setItem');
132+
const removeItemSpy = vi.spyOn(window.localStorage, 'removeItem');
133+
const { result } = renderHook(() => useRecentSearches());
134+
135+
// Initially loading from storage should not trigger any writes or removals
136+
expect(setItemSpy).not.toHaveBeenCalled();
137+
expect(removeItemSpy).not.toHaveBeenCalled();
138+
139+
act(() => {
140+
result.current.addSearch('gaearon');
141+
});
142+
143+
// Verify localStorage.setItem is synchronized correctly
144+
expect(setItemSpy).toHaveBeenCalledTimes(1);
145+
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, JSON.stringify(['gaearon']));
146+
expect(removeItemSpy).not.toHaveBeenCalled();
147+
});
105148
});

hooks/useRecentSearches.ts

Lines changed: 84 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,117 @@
11
'use client';
22

3-
import { useState } from 'react';
3+
import { useState, useEffect, useRef } from 'react';
44

55
export const STORAGE_KEY = 'recentSearches';
66
export const MAX_SEARCHES = 5;
77

8-
type State = {
9-
searches: string[];
10-
mounted: boolean;
11-
};
8+
type State = { searches: string[]; mounted: boolean };
9+
10+
function loadFromStorage(): string[] {
11+
let saved: string[] = [];
12+
try {
13+
const stored = localStorage.getItem(STORAGE_KEY);
14+
if (stored) saved = JSON.parse(stored) as string[];
15+
} catch {
16+
// ignore malformed storage
17+
}
18+
return saved;
19+
}
1220

13-
export function useRecentSearches() {
14-
const [state, setState] = useState<State>(() => {
15-
if (typeof window === 'undefined') {
16-
return {
17-
searches: [],
18-
mounted: false,
19-
};
21+
function writeStorage(searches: string[] | null): void {
22+
try {
23+
if (searches === null) {
24+
localStorage.removeItem(STORAGE_KEY);
25+
return;
2026
}
2127

22-
try {
23-
const stored = localStorage.getItem(STORAGE_KEY);
28+
localStorage.setItem(STORAGE_KEY, JSON.stringify(searches));
29+
} catch {
30+
// ignore storage write failures
31+
}
32+
}
2433

25-
return {
26-
searches: stored ? (JSON.parse(stored) as string[]) : [],
27-
mounted: true,
28-
};
29-
} catch {
30-
return {
31-
searches: [],
32-
mounted: true,
33-
};
34+
/**
35+
* A hook to manage and persist a list of recent searches.
36+
*
37+
* It uses localStorage for persistence and ensures SSR compatibility by starting
38+
* with an empty state on the first render and updating upon hydration.
39+
*
40+
* @returns An object containing the recent searches, a function to add a search, and a function to clear all searches.
41+
*/
42+
export function useRecentSearches() {
43+
// Always start with [] and mounted:false on both server and client so the
44+
// initial render matches (SSR-safe). A single setState in the mount effect
45+
// reads from localStorage and flips mounted:true in one batch — this satisfies
46+
// the react-hooks/set-state-in-effect rule which flags multiple synchronous
47+
// setState calls inside an effect body.
48+
const [state, setState] = useState<State>({ searches: [], mounted: false });
49+
const isHydratedRef = useRef(false);
50+
51+
useEffect(() => {
52+
// Single setState call — reads external system (localStorage) and syncs
53+
// React state in one update, which is exactly what effects are for.
54+
// eslint-disable-next-line react-hooks/set-state-in-effect
55+
setState({ searches: loadFromStorage(), mounted: true });
56+
}, []);
57+
58+
// Synchronize localStorage with React state reactively when searches or mounted state changes.
59+
// This executes outside the state updater callbacks, ensuring they are completely pure and
60+
// safe for concurrent rendering and React Strict Mode.
61+
useEffect(() => {
62+
if (!state.mounted) return;
63+
64+
// Skip the first synchronization effect run after hydration to prevent redundant writes
65+
// or eager key removal before user interaction.
66+
if (!isHydratedRef.current) {
67+
isHydratedRef.current = true;
68+
return;
3469
}
35-
});
3670

71+
if (state.searches.length === 0) {
72+
writeStorage(null);
73+
} else {
74+
writeStorage(state.searches);
75+
}
76+
}, [state.searches, state.mounted]);
77+
78+
/**
79+
* Adds a new search query to the recent searches list.
80+
* If the query already exists, it is moved to the top.
81+
* The list is truncated to the maximum number of searches allowed.
82+
*
83+
* @param query - The search query to add.
84+
*/
3785
const addSearch = (query: string) => {
3886
if (!query.trim()) return;
39-
4087
setState((prev) => {
4188
const deduped = [query, ...prev.searches.filter((s) => s !== query)].slice(0, MAX_SEARCHES);
42-
43-
try {
44-
localStorage.setItem(STORAGE_KEY, JSON.stringify(deduped));
45-
} catch {
46-
// ignore storage write errors
47-
}
48-
49-
return {
50-
...prev,
51-
searches: deduped,
52-
};
53-
});
54-
};
55-
56-
const removeSearch = (query: string) => {
57-
setState((prev) => {
58-
const updated = prev.searches.filter((s) => s !== query);
59-
60-
try {
61-
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
62-
} catch {
63-
// ignore storage write errors
64-
}
65-
66-
return {
67-
...prev,
68-
searches: updated,
69-
};
89+
return { ...prev, searches: deduped };
7090
});
7191
};
7292

93+
/**
94+
* Clears all recent searches from state and localStorage.
95+
*/
7396
const clearSearches = () => {
7497
setState((prev) => ({
7598
...prev,
7699
searches: [],
77100
}));
101+
};
78102

79-
try {
80-
localStorage.removeItem(STORAGE_KEY);
81-
} catch {
82-
// ignore storage clear errors
83-
}
103+
const removeSearch = (query: string): void => {
104+
setState((prev) => {
105+
const filtered = prev.searches.filter((s) => s !== query);
106+
return { ...prev, searches: filtered };
107+
});
84108
};
85109

110+
// Return empty searches until after hydration to prevent SSR/client mismatch.
86111
return {
87112
searches: state.mounted ? state.searches : [],
88113
addSearch,
89-
removeSearch,
90114
clearSearches,
115+
removeSearch,
91116
};
92117
}

0 commit comments

Comments
 (0)