From 5316510babf7606a2f4b78de2b0eb85c930890cf Mon Sep 17 00:00:00 2001 From: Ayush Goyal Date: Wed, 15 Jan 2020 23:14:17 +0530 Subject: [PATCH] feat: add serializer/deserializer option to useLocalStorage --- docs/useLocalStorage.md | 13 +++-- package.json | 4 ++ src/useLocalStorage.ts | 32 ++++++++---- tests/setupTests.ts | 1 + tests/useLocalStorage.test.ts | 95 +++++++++++++++++++++++++++++++++++ yarn.lock | 5 ++ 6 files changed, 137 insertions(+), 13 deletions(-) create mode 100644 tests/setupTests.ts create mode 100644 tests/useLocalStorage.test.ts diff --git a/docs/useLocalStorage.md b/docs/useLocalStorage.md index a414fdad56..00574bf1d9 100644 --- a/docs/useLocalStorage.md +++ b/docs/useLocalStorage.md @@ -2,11 +2,10 @@ React side-effect hook that manages a single `localStorage` key. - ## Usage ```jsx -import {useLocalStorage} from 'react-use'; +import { useLocalStorage } from 'react-use'; const Demo = () => { const [value, setValue] = useLocalStorage('my-key', 'foo'); @@ -21,15 +20,21 @@ const Demo = () => { }; ``` - ## Reference ```js useLocalStorage(key); useLocalStorage(key, initialValue); -useLocalStorage(key, initialValue, raw); +useLocalStorage(key, initialValue, { raw: true }); +useLocalStorage(key, initialValue, { + raw: false, + serializer: (value: T) => string, + deserializer: (value: string) => T, +}); ``` - `key` — `localStorage` key to manage. - `initialValue` — initial value to set, if value in `localStorage` is empty. - `raw` — boolean, if set to `true`, hook will not attempt to JSON serialize stored values. +- `serializer` — custom serializer (defaults to `JSON.stringify`) +- `deserializer` — custom deserializer (defaults to `JSON.parse`) diff --git a/package.json b/package.json index 9ec7417f69..fca3c9c1cc 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "gh-pages": "2.2.0", "husky": "3.1.0", "jest": "24.9.0", + "jest-localstorage-mock": "^2.4.0", "keyboardjs": "2.5.1", "lint-staged": "9.5.0", "markdown-loader": "5.1.0", @@ -159,6 +160,9 @@ "coverageDirectory": "coverage", "testMatch": [ "/tests/**/*.test.(ts|tsx)" + ], + "setupFiles": [ + "./tests/setupTests.ts" ] } } diff --git a/src/useLocalStorage.ts b/src/useLocalStorage.ts index 051685be63..4525e7ee21 100644 --- a/src/useLocalStorage.ts +++ b/src/useLocalStorage.ts @@ -1,22 +1,37 @@ import { useEffect, useState } from 'react'; import { isClient } from './util'; -type Dispatch = (value: A) => void; -type SetStateAction = S | ((prevState: S) => S); +type parserOptions = + | { + raw: true; + } + | { + raw: false; + serializer: (value: T) => string; + deserializer: (value: string) => T; + }; -const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, Dispatch>] => { +const useLocalStorage = ( + key: string, + initialValue?: T, + options?: parserOptions +): [T, React.Dispatch>] => { if (!isClient) { return [initialValue as T, () => {}]; } + // Use provided serializer/deserializer or the default ones + const serializer = options ? (options.raw ? String : options.serializer) : JSON.stringify; + const deserializer = options ? (options.raw ? String : options.deserializer) : JSON.parse; + const [state, setState] = useState(() => { try { const localStorageValue = localStorage.getItem(key); - if (typeof localStorageValue !== 'string') { - localStorage.setItem(key, raw ? String(initialValue) : JSON.stringify(initialValue)); - return initialValue; + if (localStorageValue) { + return deserializer(localStorageValue); } else { - return raw ? localStorageValue : JSON.parse(localStorageValue || 'null'); + initialValue && localStorage.setItem(key, serializer(initialValue)); + return initialValue; } } catch { // If user is in private mode or has storage restriction @@ -28,8 +43,7 @@ const useLocalStorage = (key: string, initialValue?: T, raw?: boolean): [T, D useEffect(() => { try { - const serializedState = raw ? String(state) : JSON.stringify(state); - localStorage.setItem(key, serializedState); + localStorage.setItem(key, serializer(state)); } catch { // If user is in private mode or has storage restriction // localStorage can throw. Also JSON.stringify can throw. diff --git a/tests/setupTests.ts b/tests/setupTests.ts new file mode 100644 index 0000000000..1c787210fd --- /dev/null +++ b/tests/setupTests.ts @@ -0,0 +1 @@ +import 'jest-localstorage-mock'; diff --git a/tests/useLocalStorage.test.ts b/tests/useLocalStorage.test.ts new file mode 100644 index 0000000000..95340c54e3 --- /dev/null +++ b/tests/useLocalStorage.test.ts @@ -0,0 +1,95 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { useLocalStorage } from '../src'; + +const STRINGIFIED_VALUE = '{"a":"b"}'; +const JSONIFIED_VALUE = { a: 'b' }; + +afterEach(() => { + localStorage.clear(); + jest.clearAllMocks(); +}); + +it('should return undefined if no initialValue provided and localStorage empty', () => { + const { result } = renderHook(() => useLocalStorage('some_key')); + + expect(result.current[0]).toBeUndefined(); +}); + +it('should set the value from existing localStorage key', () => { + const key = 'some_key'; + localStorage.setItem(key, STRINGIFIED_VALUE); + + const { result } = renderHook(() => useLocalStorage(key)); + + expect(result.current[0]).toEqual(JSONIFIED_VALUE); +}); + +it('should return initialValue if localStorage empty and set that to localStorage', () => { + const key = 'some_key'; + const value = 'some_value'; + + const { result } = renderHook(() => useLocalStorage(key, value)); + + expect(result.current[0]).toBe(value); + expect(localStorage.__STORE__[key]).toBe(`"${value}"`); +}); + +it('should return the value from localStorage if exists even if initialValue provied', () => { + const key = 'some_key'; + localStorage.setItem(key, STRINGIFIED_VALUE); + + const { result } = renderHook(() => useLocalStorage(key, 'random_value')); + + expect(result.current[0]).toEqual(JSONIFIED_VALUE); +}); + +it('should properly update the localStorage on change', () => { + const key = 'some_key'; + const updatedValue = { b: 'a' }; + const expectedValue = '{"b":"a"}'; + + const { result } = renderHook(() => useLocalStorage(key)); + + act(() => { + result.current[1](updatedValue); + }); + + expect(result.current[0]).toBe(updatedValue); + expect(localStorage.__STORE__[key]).toBe(expectedValue); +}); + +describe('Options with raw true', () => { + it('should set the value from existing localStorage key', () => { + const key = 'some_key'; + localStorage.setItem(key, STRINGIFIED_VALUE); + + const { result } = renderHook(() => useLocalStorage(key, '', { raw: true })); + + expect(result.current[0]).toEqual(STRINGIFIED_VALUE); + }); + + it('should return initialValue if localStorage empty and set that to localStorage', () => { + const key = 'some_key'; + + const { result } = renderHook(() => useLocalStorage(key, STRINGIFIED_VALUE, { raw: true })); + + expect(result.current[0]).toBe(STRINGIFIED_VALUE); + expect(localStorage.__STORE__[key]).toBe(STRINGIFIED_VALUE); + }); +}); + +describe('Options with raw false and provided serializer/deserializer', () => { + const serializer = (_: string) => '321'; + const deserializer = (_: string) => '123'; + + it('should return valid serialized value from existing localStorage key', () => { + const key = 'some_key'; + localStorage.setItem(key, STRINGIFIED_VALUE); + + const { result } = renderHook(() => + useLocalStorage(key, STRINGIFIED_VALUE, { raw: false, serializer, deserializer }) + ); + + expect(result.current[0]).toBe('123'); + }); +}); diff --git a/yarn.lock b/yarn.lock index 2f81e84660..d0152c5572 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8615,6 +8615,11 @@ jest-leak-detector@^24.9.0: jest-get-type "^24.9.0" pretty-format "^24.9.0" +jest-localstorage-mock@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/jest-localstorage-mock/-/jest-localstorage-mock-2.4.0.tgz#c6073810735dd3af74020ea6c3885ec1cc6d0d13" + integrity sha512-/mC1JxnMeuIlAaQBsDMilskC/x/BicsQ/BXQxEOw+5b1aGZkkOAqAF3nu8yq449CpzGtp5jJ5wCmDNxLgA2m6A== + jest-matcher-utils@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-24.9.0.tgz#f5b3661d5e628dffe6dd65251dfdae0e87c3a073"