From 9a1d6fa9b13b47a66b98682977196094b2ac84b7 Mon Sep 17 00:00:00 2001 From: ashish-simpleCoder Date: Sun, 25 Jan 2026 16:08:37 +0530 Subject: [PATCH 1/2] feat: add encoder and decoder props in useLocalStorage --- .changeset/yellow-nails-worry.md | 5 + apps/doc/hooks/use-local-storage.md | 166 +++++++++++++++++++++-- src/lib/use-local-storage/index.test.tsx | 124 +++++++++++++++++ src/lib/use-local-storage/index.tsx | 11 +- 4 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 .changeset/yellow-nails-worry.md diff --git a/.changeset/yellow-nails-worry.md b/.changeset/yellow-nails-worry.md new file mode 100644 index 0000000..e966bef --- /dev/null +++ b/.changeset/yellow-nails-worry.md @@ -0,0 +1,5 @@ +--- +'classic-react-hooks': minor +--- + +feat: add `encoder` and `decoder` props in useLocalStorage for encoding/decoding value in localStorage diff --git a/apps/doc/hooks/use-local-storage.md b/apps/doc/hooks/use-local-storage.md index 979fb83..76ade1e 100644 --- a/apps/doc/hooks/use-local-storage.md +++ b/apps/doc/hooks/use-local-storage.md @@ -8,16 +8,20 @@ A React hook that provides a seamless way to persist and synchronize state with ## Features -- **`useState` Compatible API:** Drop-in replacement with identical API including functional updates +- **_useState_ Compatible API:** Drop-in replacement with identical API including functional updates - **SSR Compatible:** Default values prevent hydration mismatches - **Auto Synchronization:** Seamless bidirectional sync between React state, `localStorage` and across different browser tabs - **Error handling:** Graceful fallbacks when localStorage operations fail +- **Custom Encoding/Decoding:** Optional encoder and decoder for data transformation (encryption, compression, etc.) +- **Dynamic Key Migration:** Automatically migrates data when key changes without data loss ::: danger Important Notes - **Automatic Serialization:** Data is automatically serialized to JSON when storing. - **Synchronous Updates:** State updates are synchronous and immediately persisted. - **Fallback value:** Always provide default values for SSR fallback. +- **Encoder/Decoder:** Applied after JSON serialization and before JSON parsing respectively. +- **Key Migration:** When key changes, old key is removed and data is migrated to new key automatically. ::: ## Problem It Solves @@ -76,7 +80,7 @@ It's designed to be a drop-in replacement for `useState`, maintaining the famili ```tsx // ✅ Automatic synchronization function UserSettings() { - const [theme, setTheme] = useLocalStorage({ key: 'theme', defaultValue: 'light' }) + const [theme, setTheme] = useLocalStorage({ key: 'theme', initialValue: 'light' }) return ( setToken(e.target.value)} placeholder='Enter token' /> +} + +// Custom encryption example (pseudo-code) +function EncryptedStorage() { + const encrypt = (value: string) => { + // Your encryption logic (e.g., AES) + return CryptoJS.AES.encrypt(value, 'secret-key').toString() + } + + const decrypt = (value: string) => { + // Your decryption logic + const bytes = CryptoJS.AES.decrypt(value, 'secret-key') + return bytes.toString(CryptoJS.enc.Utf8) + } + + const [sensitiveData, setSensitiveData] = useLocalStorage({ + key: 'sensitive-info', + initialValue: { apiKey: '', secret: '' }, + encoder: encrypt, + decoder: decrypt, + }) + + return ( +
+ setSensitiveData((prev) => ({ ...prev, apiKey: e.target.value }))} + placeholder='API Key' + /> +
+ ) +} + +// Compression example using pako library +function CompressedStorage() { + const compress = (value: string) => { + return pako.deflate(value, { to: 'string' }) + } + + const decompress = (value: string) => { + return pako.inflate(value, { to: 'string' }) + } + + const [largeData, setLargeData] = useLocalStorage({ + key: 'large-dataset', + initialValue: [], + encoder: compress, + decoder: decompress, + }) + + // Useful for storing large arrays or objects +} +``` + +::: + +## Data Flow + +The encoding and decoding process follows this flow: + +**Storing data:** + +``` +State → JSON.stringify() → encoder() → localStorage +``` + +**Retrieving data:** + +``` +localStorage → decoder() → JSON.parse() → State +``` + +Note: The encoder operates on the JSON-stringified value, and the decoder operates before JSON parsing. diff --git a/src/lib/use-local-storage/index.test.tsx b/src/lib/use-local-storage/index.test.tsx index a48aa54..11c5e18 100644 --- a/src/lib/use-local-storage/index.test.tsx +++ b/src/lib/use-local-storage/index.test.tsx @@ -713,4 +713,128 @@ describe('use-local-storage', () => { }) }) }) + + describe('Encoder/Decoder Tests', () => { + it('should encode value before storing', () => { + const encoder = vi.fn((val: string) => btoa(val)) + const { result } = renderHook(() => useLocalStorage({ key: 'encode-test', initialValue: 'hello', encoder })) + + act(() => { + result.current[1]('world') + }) + + expect(encoder).toHaveBeenCalledWith('"world"') + expect(mockStorage.setItem).toHaveBeenCalledWith('encode-test', btoa('"world"')) + }) + + it('should decode value when retrieving', () => { + const decoder = vi.fn((val: string) => atob(val)) + const encoded = btoa('"stored"') + mockStorage._setStore({ 'decode-test': encoded }) + + const { result } = renderHook(() => useLocalStorage({ key: 'decode-test', initialValue: 'default', decoder })) + + expect(decoder).toHaveBeenCalledWith(encoded) + expect(result.current[0]).toBe('stored') + }) + + it('should handle both encoder and decoder', () => { + const encoder = (val: string) => btoa(val) + const decoder = (val: string) => atob(val) + + const { result } = renderHook(() => + useLocalStorage({ key: 'both-test', initialValue: { data: 'test' }, encoder, decoder }) + ) + + act(() => { + result.current[1]({ data: 'updated' }) + }) + + const stored = mockStorage._getStore()['both-test'] + expect(stored).toBe(btoa(JSON.stringify({ data: 'updated' }))) + + mockStorage._setStore({ 'both-test': stored }) + act(() => { + mockEvents._triggerCustomEvent('both-test') + }) + + expect(result.current[0]).toEqual({ data: 'updated' }) + }) + + it('should handle encoder errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const encoder = () => { + throw new Error('Encode failed') + } + + const { result } = renderHook(() => useLocalStorage({ key: 'encode-error', initialValue: 'init', encoder })) + + act(() => { + result.current[1]('new') + }) + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should handle decoder errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const decoder = () => { + throw new Error('Decode failed') + } + mockStorage._setStore({ 'decode-error': 'invalid' }) + + const { result } = renderHook(() => + useLocalStorage({ key: 'decode-error', initialValue: 'fallback', decoder }) + ) + + expect(result.current[0]).toBe('fallback') + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('should work without encoder/decoder (backward compatibility)', () => { + const { result } = renderHook(() => useLocalStorage({ key: 'no-codec', initialValue: 'test' })) + + act(() => { + result.current[1]('updated') + }) + + expect(mockStorage.setItem).toHaveBeenCalledWith('no-codec', '"updated"') + expect(result.current[0]).toBe('updated') + }) + + it('should handle complex objects with encoder/decoder', () => { + const encoder = (val: string) => btoa(val) + const decoder = (val: string) => atob(val) + const obj = { nested: { data: [1, 2, 3] } } + + const { result } = renderHook(() => + useLocalStorage({ key: 'complex-codec', initialValue: obj, encoder, decoder }) + ) + + act(() => { + result.current[1]({ nested: { data: [4, 5, 6] } }) + }) + + expect(result.current[0]).toEqual({ nested: { data: [4, 5, 6] } }) + }) + + it('should apply encoder on key change migration', () => { + const encoder = vi.fn((val: string) => btoa(val)) + let key = 'key-1' + + const { result, rerender } = renderHook(() => useLocalStorage({ key, initialValue: 'value', encoder })) + + act(() => { + result.current[1]('migrated') + }) + + key = 'key-2' + rerender() + + expect(encoder).toHaveBeenCalledWith('"migrated"') + expect(mockStorage.setItem).toHaveBeenCalledWith('key-2', btoa('"migrated"')) + }) + }) }) diff --git a/src/lib/use-local-storage/index.tsx b/src/lib/use-local-storage/index.tsx index e8c4fb3..e783c11 100644 --- a/src/lib/use-local-storage/index.tsx +++ b/src/lib/use-local-storage/index.tsx @@ -33,9 +33,13 @@ import React, { useRef, useCallback, useSyncExternalStore } from 'react' export default function useLocalStorage({ key, initialValue, + encoder, + decoder, }: { key: string initialValue?: State | (() => State) + encoder?: (value: string) => string + decoder?: (value: string) => string }) { // Cache the last parse value to avoid unnecessary re-renders const lastValueRef = useRef(null) @@ -47,7 +51,8 @@ export default function useLocalStorage({ // Key has changed, reset cached values if (lastKeyRef.current !== key) { localStorage.removeItem(lastKeyRef.current) - localStorage.setItem(key, JSON.stringify(lastValueRef.current)) + const serializedValue = JSON.stringify(lastValueRef.current) + localStorage.setItem(key, encoder ? encoder(serializedValue) : serializedValue) lastKeyRef.current = key lastValueRef.current = null lastStringRef.current = null @@ -59,7 +64,7 @@ export default function useLocalStorage({ // Only parse if the string value has changed if (lastStringRef.current !== item) { lastStringRef.current = item - lastValueRef.current = JSON.parse(item) + lastValueRef.current = JSON.parse(decoder ? decoder(item) : item) } return lastValueRef.current! } @@ -138,7 +143,7 @@ export default function useLocalStorage({ } const serializedValue = JSON.stringify(newValue === undefined ? null : newValue) - localStorage.setItem(key, serializedValue) + localStorage.setItem(key, encoder ? encoder(serializedValue) : serializedValue) // Update cache lastStringRef.current = serializedValue From 1765784d15020150b26af812bac865c024010068 Mon Sep 17 00:00:00 2001 From: ashish-simpleCoder Date: Sun, 25 Jan 2026 16:12:06 +0530 Subject: [PATCH 2/2] fix: type error --- src/lib/use-local-storage/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/use-local-storage/index.test.tsx b/src/lib/use-local-storage/index.test.tsx index 11c5e18..f1dfa52 100644 --- a/src/lib/use-local-storage/index.test.tsx +++ b/src/lib/use-local-storage/index.test.tsx @@ -750,7 +750,7 @@ describe('use-local-storage', () => { result.current[1]({ data: 'updated' }) }) - const stored = mockStorage._getStore()['both-test'] + const stored = mockStorage._getStore()['both-test']! expect(stored).toBe(btoa(JSON.stringify({ data: 'updated' }))) mockStorage._setStore({ 'both-test': stored })