diff --git a/content/docs/introduction.mdx b/content/docs/introduction.mdx index fd9674f..0755c56 100644 --- a/content/docs/introduction.mdx +++ b/content/docs/introduction.mdx @@ -23,6 +23,7 @@ Shadcn Hooks is a carefully curated collection of modern React hooks designed to - [`useControllableValue`](/docs/hooks/use-controllable-value) - Manage a controllable value - [`useCounter`](/docs/hooks/use-counter) - Create and manage counter state with increment, decrement, and reset - [`useDebounce`](/docs/hooks/use-debounce) - A hook to debounce a value +- [`useLocalStorageState`](/docs/hooks/use-local-storage-state) - Persist state in localStorage with SSR-safe synchronization - [`useResetState`](/docs/hooks/use-reset-state) - Reset a state to its initial state - [`useThrottle`](/docs/hooks/use-throttle) - A hook to throttle a value - [`useToggle`](/docs/hooks/use-toggle) - Simple boolean toggle functionality diff --git a/skills/shadcn-hooks/SKILL.md b/skills/shadcn-hooks/SKILL.md index 0cc49e6..100cf8a 100644 --- a/skills/shadcn-hooks/SKILL.md +++ b/skills/shadcn-hooks/SKILL.md @@ -57,6 +57,7 @@ IMPORTANT: Each function entry includes a short `Description` and a detailed `Re | [`useControllableValue`](references/useControllableValue.md) | Supports both controlled and uncontrolled component patterns | AUTO | | [`useCounter`](references/useCounter.md) | Counter with `inc`, `dec`, `set`, `reset` helpers | AUTO | | [`useDebounce`](references/useDebounce.md) | Debounced reactive value | AUTO | +| [`useLocalStorageState`](references/useLocalStorageState.md) | SSR-safe localStorage state with synchronization | AUTO | | [`useResetState`](references/useResetState.md) | State with a `reset` function to restore the initial value | AUTO | | [`useThrottle`](references/useThrottle.md) | Throttled reactive value | AUTO | | [`useToggle`](references/useToggle.md) | Toggle between two values with utility actions | AUTO | diff --git a/skills/shadcn-hooks/references/useLocalStorageState.md b/skills/shadcn-hooks/references/useLocalStorageState.md new file mode 100644 index 0000000..eae4562 --- /dev/null +++ b/skills/shadcn-hooks/references/useLocalStorageState.md @@ -0,0 +1,64 @@ +# useLocalStorageState + +Persist state in `localStorage` with SSR-safe snapshots and automatic same-tab / cross-tab synchronization. + +## Usage + +```tsx +import { useLocalStorageState } from '@/hooks/use-local-storage-state' + +function Component() { + const [theme, setTheme, removeTheme] = useLocalStorageState('theme', 'light') + + return ( +
+

Theme: {theme}

+ + + +
+ ) +} +``` + +## Type Declarations + +```ts +import type { Dispatch, SetStateAction } from 'react' + +export interface UseLocalStorageStateOptions { + serializer?: (value: T) => string + deserializer?: (value: string) => T + onError?: (error: unknown) => void +} + +export type UseLocalStorageStateReturn = [ + T, + Dispatch>, + () => void, +] + +export function useLocalStorageState( + key: string, + initialValue: T | (() => T), + options?: UseLocalStorageStateOptions, +): UseLocalStorageStateReturn +``` + +## Parameters + +| Parameter | Type | Default | Description | +| -------------- | -------------------------------- | ------- | --------------------------------------------------------- | +| `key` | `string` | - | The `localStorage` key | +| `initialValue` | `T \| (() => T)` | - | Fallback value during SSR or when storage value is absent | +| `options` | `UseLocalStorageStateOptions` | `{}` | Serializer, deserializer, and optional error callback | + +## Returns + +| Type | Description | +| -------------------------------- | ---------------------------------------------------- | +| `[value, setValue, removeValue]` | Current value, React-style updater, and clear method | diff --git a/src/registry/hooks/meta.json b/src/registry/hooks/meta.json index dd5ccea..d922cf8 100644 --- a/src/registry/hooks/meta.json +++ b/src/registry/hooks/meta.json @@ -5,6 +5,7 @@ "use-controllable-value", "use-counter", "use-debounce", + "use-local-storage-state", "use-reset-state", "use-throttle", "use-toggle", diff --git a/src/registry/hooks/use-hash/demo/demo-01.tsx b/src/registry/hooks/use-hash/demo/demo-01.tsx new file mode 100644 index 0000000..6de60ec --- /dev/null +++ b/src/registry/hooks/use-hash/demo/demo-01.tsx @@ -0,0 +1,75 @@ +'use client' +import { useState } from 'react' +import { Button } from '~/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '~/components/ui/card' +import { Input } from '~/components/ui/input' +import { useHash } from '..' + +const PRESET_HASHES = ['intro', 'api', 'faq'] + +export function Demo01() { + const hash = useHash() + const [inputValue, setInputValue] = useState('demo-hash') + + const setHash = (value: string) => { + const nextHash = value ? `#${value}` : '' + window.location.assign( + `${window.location.pathname}${window.location.search}${nextHash}`, + ) + } + + return ( + + + useHash Demo + + Update the URL hash and watch the hook value change in real time. + + + + + setInputValue(event.target.value)} + placeholder='Type hash without #' + /> + +
+ + +
+ +
+ {PRESET_HASHES.map((item) => ( + + ))} +
+ +

+ Current hash:{' '} + {hash} +

+
+
+ ) +} diff --git a/src/registry/hooks/use-hash/index.mdx b/src/registry/hooks/use-hash/index.mdx index cc9a632..47f1fcf 100644 --- a/src/registry/hooks/use-hash/index.mdx +++ b/src/registry/hooks/use-hash/index.mdx @@ -3,6 +3,10 @@ title: useHash description: A hook to get current hash --- +import { Demo01 } from './demo/demo-01' + + + ## Installation diff --git a/src/registry/hooks/use-local-storage-state/demo/demo-01.tsx b/src/registry/hooks/use-local-storage-state/demo/demo-01.tsx new file mode 100644 index 0000000..5a8421f --- /dev/null +++ b/src/registry/hooks/use-local-storage-state/demo/demo-01.tsx @@ -0,0 +1,42 @@ +'use client' +import { Button } from '~/components/ui/button' +import { Input } from '~/components/ui/input' +import { useLocalStorageState } from '..' + +const DEMO_STORAGE_KEY = 'shadcn-hooks:demo:use-local-storage-state' + +export function Demo01() { + const [name, setName, clearName] = useLocalStorageState( + DEMO_STORAGE_KEY, + '', + ) + + return ( +
+
+ + setName(event.target.value)} + placeholder='Type and refresh the page' + /> +
+ +
+ + +
+ +

+ Current value: {name || '(empty)'} +

+
+ ) +} diff --git a/src/registry/hooks/use-local-storage-state/index.mdx b/src/registry/hooks/use-local-storage-state/index.mdx new file mode 100644 index 0000000..8b5c046 --- /dev/null +++ b/src/registry/hooks/use-local-storage-state/index.mdx @@ -0,0 +1,56 @@ +--- +title: useLocalStorageState +description: A hook to persist state in localStorage with SSR-safe behavior +--- + +import { Demo01 } from './demo/demo-01' + + + +## Installation + + + + + + + Copy and paste the following code into your project. + + + + +## API + +```ts +import type { Dispatch, SetStateAction } from 'react' + +export interface UseLocalStorageStateOptions { + serializer?: (value: T) => string + deserializer?: (value: string) => T + onError?: (error: unknown) => void +} + +export type UseLocalStorageStateReturn = [ + T, + Dispatch>, + () => void, +] + +/** + * A SSR-safe localStorage state hook with same-tab and cross-tab synchronization. + * + * @param key - localStorage key + * @param initialValue - Initial state value, used during SSR and when key does not exist + * @param options - Optional serializer, deserializer, and error callback + * @returns [state, setState, removeState] + */ +export function useLocalStorageState( + key: string, + initialValue: T | (() => T), + options?: UseLocalStorageStateOptions, +): UseLocalStorageStateReturn +``` + +## Credits + +- [useSyncExternalStore](https://react.dev/reference/react/useSyncExternalStore) diff --git a/src/registry/hooks/use-local-storage-state/index.test.ts b/src/registry/hooks/use-local-storage-state/index.test.ts new file mode 100644 index 0000000..2027238 --- /dev/null +++ b/src/registry/hooks/use-local-storage-state/index.test.ts @@ -0,0 +1,180 @@ +import { act, renderHook } from '@testing-library/react' +import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useLocalStorageState } from './index' + +const createKey = (name: string) => + `use-local-storage-state:${name}:${Math.random().toString(36).slice(2)}` + +describe('useLocalStorageState', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + + if (typeof window !== 'undefined') { + window.localStorage.clear() + } + }) + + it('should return initial value when localStorage is empty', () => { + const key = createKey('initial') + const { result } = renderHook(() => + useLocalStorageState(key, 'initial'), + ) + + expect(result.current[0]).toBe('initial') + }) + + it('should return value from localStorage when key exists', () => { + const key = createKey('existing') + window.localStorage.setItem(key, JSON.stringify('stored')) + + const { result } = renderHook(() => + useLocalStorageState(key, 'initial'), + ) + + expect(result.current[0]).toBe('stored') + }) + + it('should set value and persist to localStorage', () => { + const key = createKey('set') + const { result } = renderHook(() => + useLocalStorageState(key, 'initial'), + ) + + act(() => { + result.current[1]('next') + }) + + expect(result.current[0]).toBe('next') + expect(window.localStorage.getItem(key)).toBe(JSON.stringify('next')) + }) + + it('should support function updates', () => { + const key = createKey('updater') + const { result } = renderHook(() => + useLocalStorageState(key, 'initial'), + ) + + act(() => { + result.current[1]((previousState) => `${previousState}-next`) + }) + + expect(result.current[0]).toBe('initial-next') + expect(window.localStorage.getItem(key)).toBe( + JSON.stringify('initial-next'), + ) + }) + + it('should remove value from localStorage and reset to initial value', () => { + const key = createKey('remove') + window.localStorage.setItem(key, JSON.stringify('stored')) + + const { result } = renderHook(() => + useLocalStorageState(key, 'initial'), + ) + + expect(result.current[0]).toBe('stored') + + act(() => { + result.current[2]() + }) + + expect(result.current[0]).toBe('initial') + expect(window.localStorage.getItem(key)).toBeNull() + }) + + it('should sync state between hook instances in the same tab', () => { + const key = createKey('same-tab') + const hookA = renderHook(() => useLocalStorageState(key, 'initial')) + const hookB = renderHook(() => useLocalStorageState(key, 'initial')) + + act(() => { + hookA.result.current[1]('shared') + }) + + expect(hookA.result.current[0]).toBe('shared') + expect(hookB.result.current[0]).toBe('shared') + }) + + it('should update state when a storage event is dispatched', () => { + const key = createKey('storage-event') + const { result } = renderHook(() => + useLocalStorageState(key, 'initial'), + ) + + act(() => { + const rawValue = JSON.stringify('external-update') + window.localStorage.setItem(key, rawValue) + window.dispatchEvent( + new StorageEvent('storage', { + key, + newValue: rawValue, + storageArea: window.localStorage, + }), + ) + }) + + expect(result.current[0]).toBe('external-update') + }) + + it('should fallback to initial value when deserialization fails', () => { + const key = createKey('invalid-json') + const onError = vi.fn<(error: unknown) => void>() + window.localStorage.setItem(key, '{invalid-json}') + + const { result } = renderHook(() => + useLocalStorageState(key, 'fallback', { onError }), + ) + + expect(result.current[0]).toBe('fallback') + expect(onError).toHaveBeenCalled() + }) + + it('should support custom serializer and deserializer', () => { + const key = createKey('custom-serde') + const serializer = vi.fn((value: string) => value.toUpperCase()) + const deserializer = vi.fn((value: string) => value.toLowerCase()) + + window.localStorage.setItem(key, 'PERSISTED') + + const { result } = renderHook(() => + useLocalStorageState(key, 'initial', { + serializer, + deserializer, + }), + ) + + expect(result.current[0]).toBe('persisted') + + act(() => { + result.current[1]('next') + }) + + expect(window.localStorage.getItem(key)).toBe('NEXT') + }) + + it('should render safely when window is unavailable (SSR)', () => { + const key = createKey('ssr') + const originalWindow = window + + vi.stubGlobal('window', undefined) + + try { + const Component = () => { + const [value] = useLocalStorageState(key, 'server-value') + return createElement('span', null, value) + } + + const html = renderToString(createElement(Component)) + expect(html).toContain('server-value') + } finally { + vi.stubGlobal('window', originalWindow) + } + }) +}) diff --git a/src/registry/hooks/use-local-storage-state/index.ts b/src/registry/hooks/use-local-storage-state/index.ts new file mode 100644 index 0000000..cc98c6c --- /dev/null +++ b/src/registry/hooks/use-local-storage-state/index.ts @@ -0,0 +1,228 @@ +import { useCallback, useRef, useSyncExternalStore } from 'react' +import type { Dispatch, SetStateAction } from 'react' + +const LOCAL_STORAGE_STATE_EVENT = 'shadcn-hooks:local-storage-state' +const inMemoryStorage = new Map() + +interface LocalStorageStateEventDetail { + key: string +} + +interface SnapshotCache { + raw: string | null + value: T +} + +export interface UseLocalStorageStateOptions { + serializer?: (value: T) => string + deserializer?: (value: string) => T + onError?: (error: unknown) => void +} + +export type UseLocalStorageStateReturn = [ + T, + Dispatch>, + () => void, +] + +function resolveInitialValue(initialValue: T | (() => T)): T { + return typeof initialValue === 'function' + ? (initialValue as () => T)() + : initialValue +} + +function defaultSerializer(value: T): string { + const serializedValue = JSON.stringify(value) + return serializedValue === undefined ? 'null' : serializedValue +} + +function defaultDeserializer(value: string): T { + return JSON.parse(value) as T +} + +function getLocalStorage(): Storage | null { + if (typeof window === 'undefined') { + return null + } + + try { + return window.localStorage + } catch { + return null + } +} + +function readStorageValue(key: string): string | null { + const localStorage = getLocalStorage() + if (!localStorage) { + return inMemoryStorage.get(key) ?? null + } + + return localStorage.getItem(key) +} + +function writeStorageValue(key: string, value: string): void { + const localStorage = getLocalStorage() + if (!localStorage) { + inMemoryStorage.set(key, value) + return + } + + localStorage.setItem(key, value) +} + +function removeStorageValue(key: string): void { + const localStorage = getLocalStorage() + if (!localStorage) { + inMemoryStorage.delete(key) + return + } + + localStorage.removeItem(key) +} + +function emitLocalStorageStateEvent(key: string): void { + if (typeof window === 'undefined') { + return + } + + window.dispatchEvent( + new CustomEvent(LOCAL_STORAGE_STATE_EVENT, { + detail: { key }, + }), + ) +} + +/** + * A SSR-safe localStorage state hook with same-tab and cross-tab synchronization. + * + * @param key - localStorage key + * @param initialValue - Initial state value, used during SSR and when key does not exist + * @param options - Optional serializer, deserializer, and error callback + * @returns [state, setState, removeState] + */ +export function useLocalStorageState( + key: string, + initialValue: T | (() => T), + options: UseLocalStorageStateOptions = {}, +): UseLocalStorageStateReturn { + const { + serializer = defaultSerializer, + deserializer = defaultDeserializer, + onError, + } = options + + const initialValueRef = useRef(resolveInitialValue(initialValue)) + const cacheRef = useRef | null>(null) + + const getSnapshot = useCallback((): T => { + const rawValue = readStorageValue(key) + + if (rawValue === null) { + const fallbackSnapshot: SnapshotCache = { + raw: null, + value: initialValueRef.current, + } + cacheRef.current = fallbackSnapshot + return fallbackSnapshot.value + } + + const cachedSnapshot = cacheRef.current + if (cachedSnapshot?.raw === rawValue) { + return cachedSnapshot.value + } + + try { + const parsedValue = deserializer(rawValue) + cacheRef.current = { + raw: rawValue, + value: parsedValue, + } + return parsedValue + } catch (error) { + onError?.(error) + const fallbackSnapshot: SnapshotCache = { + raw: rawValue, + value: initialValueRef.current, + } + cacheRef.current = fallbackSnapshot + return fallbackSnapshot.value + } + }, [deserializer, key, onError]) + + const getServerSnapshot = useCallback(() => initialValueRef.current, []) + + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (typeof window === 'undefined') { + return () => {} + } + + const localStorage = getLocalStorage() + + const onStorage = (event: StorageEvent) => { + if (!localStorage || event.storageArea !== localStorage) { + return + } + if (event.key !== key && event.key !== null) { + return + } + onStoreChange() + } + + const onLocalStorageState = (event: Event) => { + const customEvent = event as CustomEvent + if (customEvent.detail?.key !== key) { + return + } + onStoreChange() + } + + window.addEventListener('storage', onStorage) + window.addEventListener(LOCAL_STORAGE_STATE_EVENT, onLocalStorageState) + + return () => { + window.removeEventListener('storage', onStorage) + window.removeEventListener( + LOCAL_STORAGE_STATE_EVENT, + onLocalStorageState, + ) + } + }, + [key], + ) + + const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + + const setState: Dispatch> = useCallback( + (value) => { + const currentValue = getSnapshot() + const nextValue = + typeof value === 'function' + ? (value as (previousState: T) => T)(currentValue) + : value + + try { + const rawValue = serializer(nextValue) + writeStorageValue(key, rawValue) + cacheRef.current = { raw: rawValue, value: nextValue } + emitLocalStorageStateEvent(key) + } catch (error) { + onError?.(error) + } + }, + [getSnapshot, key, onError, serializer], + ) + + const removeState = useCallback(() => { + try { + removeStorageValue(key) + cacheRef.current = { raw: null, value: initialValueRef.current } + emitLocalStorageStateEvent(key) + } catch (error) { + onError?.(error) + } + }, [key, onError]) + + return [state, setState, removeState] +} diff --git a/src/registry/hooks/use-local-storage-state/registry-item.json b/src/registry/hooks/use-local-storage-state/registry-item.json new file mode 100644 index 0000000..18c799e --- /dev/null +++ b/src/registry/hooks/use-local-storage-state/registry-item.json @@ -0,0 +1,3 @@ +{ + "registryDependencies": [] +}