diff --git a/src/app/test-localstorage/page.tsx b/src/app/test-localstorage/page.tsx new file mode 100644 index 0000000..4e6470c --- /dev/null +++ b/src/app/test-localstorage/page.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import { useState } from 'react'; + +interface UserSettings { + theme: 'light' | 'dark'; + notifications: boolean; + language: string; +} + +export default function TestLocalStoragePage() { + // Test 1: Simple string value + const [name, setName, removeName] = useLocalStorage('test-name', 'Guest'); + + // Test 2: Complex object + const [settings, setSettings, removeSettings] = useLocalStorage( + 'test-settings', + { + theme: 'light', + notifications: true, + language: 'en', + } + ); + + // Test 3: Array + const [items, setItems, removeItems] = useLocalStorage('test-items', []); + + // Test 4: Number + const [count, setCount, removeCount] = useLocalStorage('test-count', 0); + + const [newItem, setNewItem] = useState(''); + + return ( +
+

useLocalStorage Hook Test Page

+

+ Open DevTools (F12) → Application → Local Storage to see values update in real-time. + Refresh the page to verify persistence. +

+ + {/* Test 1: String */} +
+

Test 1: String Value

+

Current Name: {name}

+ setName(e.target.value)} + placeholder="Enter your name" + style={{ padding: '0.5rem', marginRight: '0.5rem' }} + /> + +

+ localStorage key: test-name +

+
+ + {/* Test 2: Complex Object */} +
+

Test 2: Complex Object

+
+

Theme: {settings.theme}

+ +
+
+ +
+
+ + +
+ +

+ localStorage key: test-settings +

+
+ + {/* Test 3: Array */} +
+

Test 3: Array Value

+

Items ({items.length}):

+
    + {items.map((item, index) => ( +
  • + {item}{' '} + +
  • + ))} +
+
+ setNewItem(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter' && newItem.trim()) { + setItems((prev) => [...prev, newItem.trim()]); + setNewItem(''); + } + }} + placeholder="Add new item" + style={{ padding: '0.5rem', marginRight: '0.5rem' }} + /> + + +
+

+ localStorage key: test-items +

+
+ + {/* Test 4: Number */} +
+

Test 4: Number Value

+

Count: {count}

+ + + +

+ localStorage key: test-count +

+
+ + {/* Instructions */} +
+

Verification Checklist:

+
    +
  • ✓ Open DevTools → Application → Local Storage → http://localhost:3000
  • +
  • ✓ Interact with the controls above and watch localStorage update in real-time
  • +
  • ✓ Refresh the page (F5) - all values should persist
  • +
  • ✓ Check Console for hydration errors (there should be none)
  • +
  • ✓ Check Console for any errors (there should be none)
  • +
  • ✓ Verify TypeScript has no errors in your editor
  • +
+
+
+ ); +} diff --git a/src/hooks/useLocalStorage.test.ts b/src/hooks/useLocalStorage.test.ts new file mode 100644 index 0000000..08c6f9b --- /dev/null +++ b/src/hooks/useLocalStorage.test.ts @@ -0,0 +1,235 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { useLocalStorage } from './useLocalStorage'; + +// Mock the error handling utility +vi.mock('../utils/errorHandling', () => ({ + handleError: vi.fn() +})); + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { + store[key] = value.toString(); + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + } + }; +})(); + +describe('useLocalStorage', () => { + beforeEach(() => { + // Setup localStorage mock + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true + }); + localStorageMock.clear(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should return initial value when localStorage is empty', () => { + const { result } = renderHook(() => useLocalStorage('test-key', 'initial-value')); + const [value] = result.current; + + expect(value).toBe('initial-value'); + }); + + it('should return initial value from function when localStorage is empty', () => { + const initializer = vi.fn(() => 'computed-value'); + const { result } = renderHook(() => useLocalStorage('test-key', initializer)); + const [value] = result.current; + + expect(value).toBe('computed-value'); + expect(initializer).toHaveBeenCalled(); + }); + + it('should set value in localStorage', () => { + const { result } = renderHook(() => useLocalStorage('test-key', 'initial')); + + act(() => { + const [, setValue] = result.current; + setValue('new-value'); + }); + + const [value] = result.current; + expect(value).toBe('new-value'); + expect(JSON.parse(localStorage.getItem('test-key')!)).toBe('new-value'); + }); + + it('should handle updater function in setValue', () => { + const { result } = renderHook(() => useLocalStorage('test-key', 10)); + + act(() => { + const [, setValue] = result.current; + setValue((prev) => prev + 5); + }); + + const [value] = result.current; + expect(value).toBe(15); + }); + + it('should read existing value from localStorage', () => { + localStorage.setItem('test-key', JSON.stringify('existing-value')); + + const { result } = renderHook(() => useLocalStorage('test-key', 'initial-value')); + const [value] = result.current; + + expect(value).toBe('existing-value'); + }); + + it('should handle complex objects', () => { + const complexObject = { name: 'John', age: 30, hobbies: ['reading', 'coding'] }; + const { result } = renderHook(() => useLocalStorage('test-key', complexObject)); + + act(() => { + const [, setValue] = result.current; + setValue({ ...complexObject, age: 31 }); + }); + + const [value] = result.current; + expect(value).toEqual({ name: 'John', age: 31, hobbies: ['reading', 'coding'] }); + }); + + it('should remove value from localStorage', () => { + localStorage.setItem('test-key', JSON.stringify('existing-value')); + + const { result } = renderHook(() => useLocalStorage('test-key', 'initial-value')); + + act(() => { + const [, , removeValue] = result.current; + removeValue(); + }); + + const [value] = result.current; + expect(value).toBe('initial-value'); + expect(localStorage.getItem('test-key')).toBeNull(); + }); + + it('should handle invalid JSON in localStorage', () => { + localStorage.setItem('test-key', 'invalid-json{'); + + const { result } = renderHook(() => useLocalStorage('test-key', 'fallback-value')); + const [value] = result.current; + + expect(value).toBe('fallback-value'); + }); + + it('should be SSR-safe (no window)', () => { + const originalWindow = global.window; + // @ts-ignore - Temporarily remove window for SSR test + delete global.window; + + const { result } = renderHook(() => useLocalStorage('test-key', 'ssr-value')); + const [value] = result.current; + + expect(value).toBe('ssr-value'); + + // Restore window + global.window = originalWindow; + }); + + it('should handle localStorage quota exceeded', () => { + const { handleError } = require('../utils/errorHandling'); + + // Mock setItem to throw quota exceeded error + const originalSetItem = localStorage.setItem; + localStorage.setItem = vi.fn(() => { + throw new DOMException('QuotaExceededError'); + }); + + const { result } = renderHook(() => useLocalStorage('test-key', 'initial')); + + act(() => { + const [, setValue] = result.current; + setValue('new-value'); + }); + + expect(handleError).toHaveBeenCalled(); + + // Restore original setItem + localStorage.setItem = originalSetItem; + }); + + it('should serialize and deserialize arrays', () => { + const initialArray = [1, 2, 3, 4, 5]; + const { result } = renderHook(() => useLocalStorage('test-key', initialArray)); + + act(() => { + const [, setValue] = result.current; + setValue([...initialArray, 6]); + }); + + const [value] = result.current; + expect(value).toEqual([1, 2, 3, 4, 5, 6]); + expect(JSON.parse(localStorage.getItem('test-key')!)).toEqual([1, 2, 3, 4, 5, 6]); + }); + + it('should handle boolean values', () => { + const { result } = renderHook(() => useLocalStorage('test-key', false)); + + act(() => { + const [, setValue] = result.current; + setValue(true); + }); + + const [value] = result.current; + expect(value).toBe(true); + expect(JSON.parse(localStorage.getItem('test-key')!)).toBe(true); + }); + + it('should handle number values', () => { + const { result } = renderHook(() => useLocalStorage('test-key', 0)); + + act(() => { + const [, setValue] = result.current; + setValue(42); + }); + + const [value] = result.current; + expect(value).toBe(42); + expect(JSON.parse(localStorage.getItem('test-key')!)).toBe(42); + }); + + it('should handle null values', () => { + const { result } = renderHook(() => useLocalStorage('test-key', null)); + + const [value] = result.current; + expect(value).toBe(null); + }); + + it('should update localStorage when key changes', () => { + const { result, rerender } = renderHook( + ({ key, value }) => useLocalStorage(key, value), + { initialProps: { key: 'key1', value: 'value1' } } + ); + + act(() => { + const [, setValue] = result.current; + setValue('updated-value1'); + }); + + expect(localStorage.getItem('key1')).toBe(JSON.stringify('updated-value1')); + + // Change the key + rerender({ key: 'key2', value: 'value2' }); + + act(() => { + const [, setValue] = result.current; + setValue('updated-value2'); + }); + + expect(localStorage.getItem('key2')).toBe(JSON.stringify('updated-value2')); + }); +}); diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000..ee1029f --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,99 @@ +import { useState, useEffect, useCallback } from 'react'; +import { handleError } from '../utils/errorHandling'; + +/** + * Function type for updating the stored value + * @template T - The type of the stored value + * @param value - Either a new value of type T or an updater function that takes the current value and returns a new value + */ +type SetValue = (value: T | ((val: T) => T)) => void; + +/** + * Custom hook for managing state in localStorage with automatic serialization + * + * @template T - The type of the stored value + * @param {string} key - The localStorage key to use + * @param {T | (() => T)} initialValue - The initial value or a function that returns the initial value + * @returns {[T, SetValue, () => void]} A tuple containing: + * - The current stored value + * - A function to update the stored value + * - A function to remove the value from storage + * + * @example + * ```tsx + * const [theme, setTheme, removeTheme] = useLocalStorage('theme', 'light'); + * setTheme('dark'); // Updates both state and localStorage + * removeTheme(); // Removes from localStorage and resets to initial value + * ``` + * + * @remarks + * - SSR-safe: Returns initial value during server-side rendering + * - Automatically serializes/deserializes JSON + * - Handles localStorage quota exceeded errors + * - Handles invalid JSON gracefully + */ +export function useLocalStorage( + key: string, + initialValue: T | (() => T) +): [T, SetValue, () => void] { + // Get initial value - SSR safe + const getInitialValue = useCallback((): T => { + // Check if we're in a browser environment + if (typeof window === 'undefined') { + return initialValue instanceof Function ? initialValue() : initialValue; + } + + try { + const item = window.localStorage.getItem(key); + if (item) { + return JSON.parse(item) as T; + } + } catch (error) { + handleError(error, `LocalStorage Read (key: ${key})`); + } + + return initialValue instanceof Function ? initialValue() : initialValue; + }, [key, initialValue]); + + const [storedValue, setStoredValue] = useState(getInitialValue); + + // Update localStorage whenever storedValue changes + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem(key, JSON.stringify(storedValue)); + } catch (error) { + handleError(error, `LocalStorage Write (key: ${key})`); + } + }, [key, storedValue]); + + // Set value function that supports both direct values and updater functions + const setValue: SetValue = useCallback((value) => { + try { + setStoredValue((prevValue) => { + const newValue = value instanceof Function ? value(prevValue) : value; + return newValue; + }); + } catch (error) { + handleError(error, `LocalStorage Update (key: ${key})`); + } + }, [key]); + + // Remove value from localStorage and reset to initial value + const removeValue = useCallback(() => { + try { + if (typeof window !== 'undefined') { + window.localStorage.removeItem(key); + } + const resetValue = initialValue instanceof Function ? initialValue() : initialValue; + setStoredValue(resetValue); + } catch (error) { + handleError(error, `LocalStorage Remove (key: ${key})`); + } + }, [key, initialValue]); + + return [storedValue, setValue, removeValue]; +}