diff --git a/README.md b/README.md index 350d5ee..a61f677 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,47 @@ No matter how many times the returned function is called, it is performed once p `throttleFunction` : Returns the callback with the throttle applied. +### useLocalStorage + +`useLocalStorage` is a custom hook that makes it easy for React applications to use local storage, which allows you to implement the ability to store, update, and remove data on local storage, and optionally set the expiration time for data. + +```tsx +function App() { + const [name, setName, removeName] = useLocalStorage('name', 'old name', { + serializer: (value: string) => value, + deserializer: (storedValue: string) => storedValue, + }); // 24h + + return ( +
+

{`Name: ${name}`}

+ + +
+ ); +} +``` + +#### Generic + +``: Type of data stored on local storage. + +#### Function Arguments + +`key (string)`: Key to local storage + +`initialValue (T | null)`: Initial value of local storage + +`options (UseLocalStorageOptions | undefined)`: You can specify serializer and deserializer, which are executed when values are stored in and imported from local storage, respectively. + +#### Return Values + +`value (T | null)`: The value currently stored on local storage. + +`saveValue (function)`: A function that stores a new value on local storage. + +`removeValue (function)`: A function that removes values related to the current key from the local storage. + ## Animation The animation of this package is based on ClassName by default. diff --git a/package.json b/package.json index 23ae560..6d8fdbf 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "homepage": "https://github.com/rapiders/react-hooks", "scripts": { "test": "jest", + "test:coverage": "jest --coverage", + "test:watch": "jest --watch", "prepublishOnly": "npm run build", "build": "sh build.sh", "storybook": "storybook dev -p 6006", diff --git a/src/index.ts b/src/index.ts index 66c0b13..e147401 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import useAfterMountEffect from './useAfterMountEffect/useAfterMountEffect'; import useRadio from './useRadio/useRadio'; import useThrottle from './useThrottle/useThrottle'; import useDebounce from './useDebounce/useDebounce'; +import useLocalStorage from './useLocalStorage/useLocalStorage'; export { useInput, @@ -22,4 +23,5 @@ export { useRadio, useThrottle, useDebounce, + useLocalStorage, }; diff --git a/src/useLocalStorage/useLocalStorage.test.ts b/src/useLocalStorage/useLocalStorage.test.ts new file mode 100644 index 0000000..56e99a3 --- /dev/null +++ b/src/useLocalStorage/useLocalStorage.test.ts @@ -0,0 +1,105 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import useLocalStorage from './useLocalStorage'; +import * as utils from '../utils/isServer'; + +// 로컬 스토리지 목업 +const mockGetItem = jest.fn(); +const mockSetItem = jest.fn(); +const mockRemoveItem = jest.fn(); + +describe('useLocalStorage', () => { + beforeAll(() => { + Object.defineProperty(window, 'localStorage', { + value: { + getItem: mockGetItem, + setItem: mockSetItem, + removeItem: mockRemoveItem, + clear: jest.fn(), + length: 0, + key: jest.fn(), + }, + writable: true, + }); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('저장된 값이 없을 때 initialValue를 사용', () => { + mockGetItem.mockReturnValue(null); + const { result } = renderHook(() => useLocalStorage('test', 'initial')); + expect(result.current[0]).toBe('initial'); + }); + + it('저장된 값을 지울 수 있음', () => { + const { result } = renderHook(() => useLocalStorage('test', 'value')); + act(() => result.current[2]()); + expect(mockRemoveItem).toHaveBeenCalledWith('test'); + }); + + it('저장된 값이 있다면 저장된 값을 사용', () => { + mockGetItem.mockReturnValue(JSON.stringify('storedValue')); + + const { result } = renderHook(() => useLocalStorage('test', 'initialValue')); + expect(result.current[0]).toBe('storedValue'); + }); + + it('초기값이 null일 때 value가 null', () => { + mockGetItem.mockReturnValue(null); + const { result } = renderHook(() => useLocalStorage('test', null)); + expect(result.current[0]).toBeNull(); + }); + + it('값을 저장할 수 있음', () => { + const { result } = renderHook(() => useLocalStorage('test', 'initialValue')); + expect(result.current[0]).toBe('initialValue'); + + act(() => result.current[1]('newValue')); + waitFor(() => expect(result.current[0]).toBe('newValue')); + }); + + it('deserializer를 사용가능', () => { + mockGetItem.mockReturnValue('storedValue'); + const { result } = renderHook(() => + useLocalStorage('test', 'initialValue', { serializer: (value: string) => value, deserializer: (storedValue: string) => storedValue }), + ); + expect(result.current[0]).toBe('storedValue'); + }); + + it('serializer를 사용가능', () => { + const { result } = renderHook(() => + useLocalStorage('test', 'initialValue', { serializer: (value: string) => value, deserializer: (storedValue: string) => storedValue }), + ); + + act(() => result.current[1]('newValue')); + waitFor(() => expect(result.current[0]).toBe('newValue')); + }); + + it('같은 key를 갖는 두 훅이 서로의 값을 변경할 수 있음', () => { + const { result: firstHook } = renderHook(() => useLocalStorage('test', 'initialValue')); + const { result: secondHook } = renderHook(() => useLocalStorage('test', 'initialValue')); + + act(() => firstHook.current[1]('newValue')); + waitFor(() => expect(secondHook.current[0]).toBe('newValue')); + }); + + it('다른 key를 갖는 두 훅은 서로 영향을 주지 않음', () => { + const { result: firstHook } = renderHook(() => useLocalStorage('test', 'initialValue')); + const { result: secondHook } = renderHook(() => useLocalStorage('test2', 'initialValue2')); + + act(() => firstHook.current[1]('newValue')); + waitFor(() => { + expect(firstHook.current[0]).toBe('newValue'); + expect(secondHook.current[0]).toBe('initialValue2'); + }); + }); + + it('SSR 환경인 경우 초기 값을 반환', () => { + jest.spyOn(utils, 'isServer').mockImplementation(() => true); + + const { result: ssrResult } = renderHook(() => useLocalStorage('test', 'initialValue')); + expect(mockGetItem).not.toHaveBeenCalled(); // 로컬 스토리지에 접근하지 않음 + expect(ssrResult.current[0]).toBe('initialValue'); // 저장된 값이 있어도 초기 값을 반환 + }); +}); diff --git a/src/useLocalStorage/useLocalStorage.ts b/src/useLocalStorage/useLocalStorage.ts new file mode 100644 index 0000000..9088e44 --- /dev/null +++ b/src/useLocalStorage/useLocalStorage.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import { isServer } from '@/utils/isServer'; + +interface UseLocalStorageOptions { + serializer?: (value: T) => string; + deserializer?: (storedValue: string) => T; +} + +export default function useLocalStorage( + key: string, + initialValue: T | null, + options: UseLocalStorageOptions = {}, +): [T | null, (value: T) => void, () => void] { + const serialize = (plainValue: T) => { + if (options.serializer) return options.serializer(plainValue); + return JSON.stringify(plainValue); + }; + + const deserialize = (serializedValue: string): T => { + if (options.deserializer) return options.deserializer(serializedValue); + return JSON.parse(serializedValue) as T; + }; + + const getStoredValue = () => { + if (isServer()) return initialValue; + + const storedObj = window.localStorage.getItem(key); + if (!storedObj) return initialValue; + + return deserialize(storedObj); + }; + + const [value, setValue] = useState(getStoredValue()); + + const saveValue = (newValue: T) => { + window.localStorage.setItem(key, serialize(newValue)); + setValue(newValue); + window.dispatchEvent(new StorageEvent('local-storage', { key })); + }; + + const removeValue = () => { + window.localStorage.removeItem(key); + setValue(initialValue); + window.dispatchEvent(new StorageEvent('local-storage', { key })); + }; + + useEffect(() => { + setValue(getStoredValue()); + }, []); + + const handleStorageChange = (event: StorageEvent | CustomEvent) => { + if ((event as StorageEvent).key && (event as StorageEvent).key !== key) return; + setValue(getStoredValue()); + }; + + useEffect(() => { + window.addEventListener('local-storage', handleStorageChange); + window.addEventListener('storage', handleStorageChange); + return () => { + window.removeEventListener('local-storage', handleStorageChange); + window.removeEventListener('storage', handleStorageChange); + }; + }); + + return [value, saveValue, removeValue]; +} diff --git a/src/utils/isServer.test.ts b/src/utils/isServer.test.ts new file mode 100644 index 0000000..e32c3fe --- /dev/null +++ b/src/utils/isServer.test.ts @@ -0,0 +1,31 @@ +import { isServer } from './isServer'; + +describe('isServer', () => { + let originalWindow: typeof globalThis.window | undefined; + + beforeAll(() => { + originalWindow = globalThis.window; + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'window', { + value: originalWindow, + configurable: true, + writable: true, + }); + }); + + it('window 객체가 undefined인 경우 true 반환', () => { + Object.defineProperty(globalThis, 'window', { + value: undefined, + configurable: true, + writable: true, + }); + + expect(isServer()).toBe(true); + }); + + it('window 객체가 존재하는 경우 false 반환', () => { + expect(isServer()).toBe(false); + }); +}); diff --git a/src/utils/isServer.ts b/src/utils/isServer.ts new file mode 100644 index 0000000..c597692 --- /dev/null +++ b/src/utils/isServer.ts @@ -0,0 +1,3 @@ +export function isServer() { + return typeof window === 'undefined'; +}