Skip to content

Commit 4fc9b6c

Browse files
feat: add encoder and decoder props in useLocalStorage (#106)
* feat: add encoder and decoder props in useLocalStorage
1 parent 03f2a5f commit 4fc9b6c

File tree

4 files changed

+290
-16
lines changed

4 files changed

+290
-16
lines changed

.changeset/yellow-nails-worry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'classic-react-hooks': minor
3+
---
4+
5+
feat: add `encoder` and `decoder` props in useLocalStorage for encoding/decoding value in localStorage

apps/doc/hooks/use-local-storage.md

Lines changed: 153 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,20 @@ A React hook that provides a seamless way to persist and synchronize state with
88

99
## Features
1010

11-
- **`useState` Compatible API:** Drop-in replacement with identical API including functional updates
11+
- **_useState_ Compatible API:** Drop-in replacement with identical API including functional updates
1212
- **SSR Compatible:** Default values prevent hydration mismatches
1313
- **Auto Synchronization:** Seamless bidirectional sync between React state, `localStorage` and across different browser tabs
1414
- **Error handling:** Graceful fallbacks when localStorage operations fail
15+
- **Custom Encoding/Decoding:** Optional encoder and decoder for data transformation (encryption, compression, etc.)
16+
- **Dynamic Key Migration:** Automatically migrates data when key changes without data loss
1517

1618
::: danger Important Notes
1719

1820
- **Automatic Serialization:** Data is automatically serialized to JSON when storing.
1921
- **Synchronous Updates:** State updates are synchronous and immediately persisted.
2022
- **Fallback value:** Always provide default values for SSR fallback.
23+
- **Encoder/Decoder:** Applied after JSON serialization and before JSON parsing respectively.
24+
- **Key Migration:** When key changes, old key is removed and data is migrated to new key automatically.
2125
:::
2226

2327
## Problem It Solves
@@ -76,7 +80,7 @@ It's designed to be a drop-in replacement for `useState`, maintaining the famili
7680
```tsx
7781
// ✅ Automatic synchronization
7882
function UserSettings() {
79-
const [theme, setTheme] = useLocalStorage({ key: 'theme', defaultValue: 'light' })
83+
const [theme, setTheme] = useLocalStorage({ key: 'theme', initialValue: 'light' })
8084

8185
return (
8286
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
@@ -114,7 +118,7 @@ function BrokenComponent() {
114118
```tsx
115119
// ✅ Perfect useState compatibility
116120
function Component() {
117-
const [count, setCount] = useLocalStorage({ key: 'count', defaultValue: 0 })
121+
const [count, setCount] = useLocalStorage({ key: 'count', initialValue: 0 })
118122

119123
// All familiar useState patterns work perfectly
120124
setCount(5) // Direct value
@@ -144,14 +148,14 @@ function ProblematicComponent() {
144148

145149
---
146150

147-
**Solution:-** The hook's `defaultValue` prop ensures consistent initial renders and smooth hydration by providing predictable fallback values.
151+
**Solution:-** The hook's `initialValue` prop ensures consistent initial renders and smooth hydration by providing predictable fallback values.
148152

149153
```tsx
150154
// ✅ SSR-compatible with smooth hydration
151155
function SSRFriendlyComponent() {
152156
const [theme, setTheme] = useLocalStorage({
153157
key: 'theme',
154-
defaultValue: 'light', // Used during SSR and as fallback
158+
initialValue: 'light', // Used during SSR and as fallback
155159
})
156160

157161
// Server and client both start with 'light'
@@ -197,7 +201,7 @@ interface UserSettings {
197201
function SafeComponent() {
198202
const [settings, setSettings] = useLocalStorage<UserSettings>({
199203
key: 'user-settings',
200-
defaultValue: { theme: 'light', language: 'en', notifications: true },
204+
initialValue: { theme: 'light', language: 'en', notifications: true },
201205
})
202206

203207
// TypeScript ensures settings has the correct shape
@@ -210,12 +214,56 @@ function SafeComponent() {
210214

211215
:::
212216

217+
::: details Lack of Data Security and Transformation
218+
219+
---
220+
221+
**Problem:-** Sensitive data stored in localStorage is visible in plain text, and there's no built-in way to transform or compress data before storage.
222+
223+
```tsx
224+
// ❌ Sensitive data exposed in plain text
225+
function InsecureComponent() {
226+
const [apiKey, setApiKey] = useLocalStorage({ key: 'api-key', initialValue: '' })
227+
228+
// API key stored as plain text in localStorage - anyone can read it!
229+
// No way to compress large data structures
230+
}
231+
```
232+
233+
---
234+
235+
**Solution:-** The hook supports optional `encoder` and `decoder` functions for custom data transformation like encryption, compression, or Base64 encoding.
236+
237+
```tsx
238+
// ✅ Encrypted storage with custom encoder/decoder
239+
function SecureComponent() {
240+
const [apiKey, setApiKey] = useLocalStorage({
241+
key: 'api-key',
242+
initialValue: '',
243+
encoder: (value) => btoa(value), // Base64 encode
244+
decoder: (value) => atob(value), // Base64 decode
245+
})
246+
247+
// Or use real encryption
248+
const [sensitiveData, setSensitiveData] = useLocalStorage({
249+
key: 'sensitive',
250+
initialValue: {},
251+
encoder: (value) => encryptData(value), // Your encryption function
252+
decoder: (value) => decryptData(value), // Your decryption function
253+
})
254+
}
255+
```
256+
257+
:::
258+
213259
## Parameters
214260

215-
| Parameter | Type | Required | Default Value | Description |
216-
| ------------ | :----: | :------: | :-----------: | ----------------------------------------- |
217-
| key | string || - | Unique key for localStorage item |
218-
| defaultValue | any || undefined | Initial value when no stored value exists |
261+
| Parameter | Type | Required | Default Value | Description |
262+
| ------------ | :-----------------------: | :------: | :-----------: | ------------------------------------------------------------ |
263+
| key | string || - | Unique key for localStorage item |
264+
| initialValue | State \| (() => State) || undefined | Initial value when no stored value exists |
265+
| encoder | (value: string) => string || undefined | Optional function to encode stringified value before storing |
266+
| decoder | (value: string) => string || undefined | Optional function to decode stored value before parsing |
219267

220268
## Return Value(s)
221269

@@ -233,6 +281,8 @@ Returns a tuple `[state, setState]` similar to `useState`:
233281
- Shopping cart persistence
234282
- User settings and preferences
235283
- Feature flags for application
284+
- Encrypted/encoded sensitive data storage
285+
- Compressed data for large objects
236286

237287
## Usage Examples
238288

@@ -242,8 +292,8 @@ Returns a tuple `[state, setState]` similar to `useState`:
242292
import { useLocalStorage } from 'classic-react-hooks'
243293

244294
function UserPreferences() {
245-
const [theme, setTheme] = useLocalStorage({ key: 'theme', defaultValue: 'light' })
246-
const [language, setLanguage] = useLocalStorage({ key: 'language', defaultValue: 'en' })
295+
const [theme, setTheme] = useLocalStorage({ key: 'theme', initialValue: 'light' })
296+
const [language, setLanguage] = useLocalStorage({ key: 'language', initialValue: 'en' })
247297

248298
return (
249299
<div>
@@ -279,7 +329,7 @@ interface UserProfile {
279329
function ProfileForm() {
280330
const [profile, setProfile] = useLocalStorage<UserProfile>({
281331
key: 'user-profile',
282-
defaultValue: {
332+
initialValue: {
283333
name: '',
284334
email: '',
285335
preferences: {
@@ -327,3 +377,93 @@ function ProfileForm() {
327377
```
328378

329379
:::
380+
381+
### Encoded/Encrypted Storage
382+
383+
::: details
384+
385+
```ts
386+
// Base64 encoding example
387+
function Base64Example() {
388+
const [token, setToken] = useLocalStorage({
389+
key: 'auth-token',
390+
initialValue: '',
391+
encoder: (value) => btoa(value), // Encode to Base64
392+
decoder: (value) => atob(value), // Decode from Base64
393+
})
394+
395+
return <input type='text' value={token} onChange={(e) => setToken(e.target.value)} placeholder='Enter token' />
396+
}
397+
398+
// Custom encryption example (pseudo-code)
399+
function EncryptedStorage() {
400+
const encrypt = (value: string) => {
401+
// Your encryption logic (e.g., AES)
402+
return CryptoJS.AES.encrypt(value, 'secret-key').toString()
403+
}
404+
405+
const decrypt = (value: string) => {
406+
// Your decryption logic
407+
const bytes = CryptoJS.AES.decrypt(value, 'secret-key')
408+
return bytes.toString(CryptoJS.enc.Utf8)
409+
}
410+
411+
const [sensitiveData, setSensitiveData] = useLocalStorage({
412+
key: 'sensitive-info',
413+
initialValue: { apiKey: '', secret: '' },
414+
encoder: encrypt,
415+
decoder: decrypt,
416+
})
417+
418+
return (
419+
<div>
420+
<input
421+
type='password'
422+
value={sensitiveData.apiKey}
423+
onChange={(e) => setSensitiveData((prev) => ({ ...prev, apiKey: e.target.value }))}
424+
placeholder='API Key'
425+
/>
426+
</div>
427+
)
428+
}
429+
430+
// Compression example using pako library
431+
function CompressedStorage() {
432+
const compress = (value: string) => {
433+
return pako.deflate(value, { to: 'string' })
434+
}
435+
436+
const decompress = (value: string) => {
437+
return pako.inflate(value, { to: 'string' })
438+
}
439+
440+
const [largeData, setLargeData] = useLocalStorage({
441+
key: 'large-dataset',
442+
initialValue: [],
443+
encoder: compress,
444+
decoder: decompress,
445+
})
446+
447+
// Useful for storing large arrays or objects
448+
}
449+
```
450+
451+
:::
452+
453+
## Data Flow
454+
455+
The encoding and decoding process follows this flow:
456+
457+
**Storing data:**
458+
459+
```
460+
StateJSON.stringify() → encoder() → localStorage
461+
```
462+
463+
**Retrieving data:**
464+
465+
```
466+
localStoragedecoder() → JSON.parse() → State
467+
```
468+
469+
Note: The encoder operates on the JSON-stringified value, and the decoder operates before JSON parsing.

src/lib/use-local-storage/index.test.tsx

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,4 +713,128 @@ describe('use-local-storage', () => {
713713
})
714714
})
715715
})
716+
717+
describe('Encoder/Decoder Tests', () => {
718+
it('should encode value before storing', () => {
719+
const encoder = vi.fn((val: string) => btoa(val))
720+
const { result } = renderHook(() => useLocalStorage({ key: 'encode-test', initialValue: 'hello', encoder }))
721+
722+
act(() => {
723+
result.current[1]('world')
724+
})
725+
726+
expect(encoder).toHaveBeenCalledWith('"world"')
727+
expect(mockStorage.setItem).toHaveBeenCalledWith('encode-test', btoa('"world"'))
728+
})
729+
730+
it('should decode value when retrieving', () => {
731+
const decoder = vi.fn((val: string) => atob(val))
732+
const encoded = btoa('"stored"')
733+
mockStorage._setStore({ 'decode-test': encoded })
734+
735+
const { result } = renderHook(() => useLocalStorage({ key: 'decode-test', initialValue: 'default', decoder }))
736+
737+
expect(decoder).toHaveBeenCalledWith(encoded)
738+
expect(result.current[0]).toBe('stored')
739+
})
740+
741+
it('should handle both encoder and decoder', () => {
742+
const encoder = (val: string) => btoa(val)
743+
const decoder = (val: string) => atob(val)
744+
745+
const { result } = renderHook(() =>
746+
useLocalStorage({ key: 'both-test', initialValue: { data: 'test' }, encoder, decoder })
747+
)
748+
749+
act(() => {
750+
result.current[1]({ data: 'updated' })
751+
})
752+
753+
const stored = mockStorage._getStore()['both-test']!
754+
expect(stored).toBe(btoa(JSON.stringify({ data: 'updated' })))
755+
756+
mockStorage._setStore({ 'both-test': stored })
757+
act(() => {
758+
mockEvents._triggerCustomEvent('both-test')
759+
})
760+
761+
expect(result.current[0]).toEqual({ data: 'updated' })
762+
})
763+
764+
it('should handle encoder errors gracefully', () => {
765+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
766+
const encoder = () => {
767+
throw new Error('Encode failed')
768+
}
769+
770+
const { result } = renderHook(() => useLocalStorage({ key: 'encode-error', initialValue: 'init', encoder }))
771+
772+
act(() => {
773+
result.current[1]('new')
774+
})
775+
776+
expect(consoleSpy).toHaveBeenCalled()
777+
consoleSpy.mockRestore()
778+
})
779+
780+
it('should handle decoder errors gracefully', () => {
781+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
782+
const decoder = () => {
783+
throw new Error('Decode failed')
784+
}
785+
mockStorage._setStore({ 'decode-error': 'invalid' })
786+
787+
const { result } = renderHook(() =>
788+
useLocalStorage({ key: 'decode-error', initialValue: 'fallback', decoder })
789+
)
790+
791+
expect(result.current[0]).toBe('fallback')
792+
expect(consoleSpy).toHaveBeenCalled()
793+
consoleSpy.mockRestore()
794+
})
795+
796+
it('should work without encoder/decoder (backward compatibility)', () => {
797+
const { result } = renderHook(() => useLocalStorage({ key: 'no-codec', initialValue: 'test' }))
798+
799+
act(() => {
800+
result.current[1]('updated')
801+
})
802+
803+
expect(mockStorage.setItem).toHaveBeenCalledWith('no-codec', '"updated"')
804+
expect(result.current[0]).toBe('updated')
805+
})
806+
807+
it('should handle complex objects with encoder/decoder', () => {
808+
const encoder = (val: string) => btoa(val)
809+
const decoder = (val: string) => atob(val)
810+
const obj = { nested: { data: [1, 2, 3] } }
811+
812+
const { result } = renderHook(() =>
813+
useLocalStorage({ key: 'complex-codec', initialValue: obj, encoder, decoder })
814+
)
815+
816+
act(() => {
817+
result.current[1]({ nested: { data: [4, 5, 6] } })
818+
})
819+
820+
expect(result.current[0]).toEqual({ nested: { data: [4, 5, 6] } })
821+
})
822+
823+
it('should apply encoder on key change migration', () => {
824+
const encoder = vi.fn((val: string) => btoa(val))
825+
let key = 'key-1'
826+
827+
const { result, rerender } = renderHook(() => useLocalStorage({ key, initialValue: 'value', encoder }))
828+
829+
act(() => {
830+
result.current[1]('migrated')
831+
})
832+
833+
key = 'key-2'
834+
rerender()
835+
836+
expect(encoder).toHaveBeenCalledWith('"migrated"')
837+
expect(mockStorage.setItem).toHaveBeenCalledWith('key-2', btoa('"migrated"'))
838+
})
839+
})
716840
})

0 commit comments

Comments
 (0)