|
1 | 1 | 'use client'; |
2 | 2 |
|
3 | | -import { useState } from 'react'; |
| 3 | +import { useState, useEffect, useRef } from 'react'; |
4 | 4 |
|
5 | 5 | export const STORAGE_KEY = 'recentSearches'; |
6 | 6 | export const MAX_SEARCHES = 5; |
7 | 7 |
|
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 | +} |
12 | 20 |
|
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; |
20 | 26 | } |
21 | 27 |
|
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 | +} |
24 | 33 |
|
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; |
34 | 69 | } |
35 | | - }); |
36 | 70 |
|
| 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 | + */ |
37 | 85 | const addSearch = (query: string) => { |
38 | 86 | if (!query.trim()) return; |
39 | | - |
40 | 87 | setState((prev) => { |
41 | 88 | 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 }; |
70 | 90 | }); |
71 | 91 | }; |
72 | 92 |
|
| 93 | + /** |
| 94 | + * Clears all recent searches from state and localStorage. |
| 95 | + */ |
73 | 96 | const clearSearches = () => { |
74 | 97 | setState((prev) => ({ |
75 | 98 | ...prev, |
76 | 99 | searches: [], |
77 | 100 | })); |
| 101 | + }; |
78 | 102 |
|
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 | + }); |
84 | 108 | }; |
85 | 109 |
|
| 110 | + // Return empty searches until after hydration to prevent SSR/client mismatch. |
86 | 111 | return { |
87 | 112 | searches: state.mounted ? state.searches : [], |
88 | 113 | addSearch, |
89 | | - removeSearch, |
90 | 114 | clearSearches, |
| 115 | + removeSearch, |
91 | 116 | }; |
92 | 117 | } |
0 commit comments