Skip to content

feat: useLocalStorage 추가 #18

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>('name', 'old name', {
serializer: (value: string) => value,
deserializer: (storedValue: string) => storedValue,
}); // 24h

return (
<div>
<p>{`Name: ${name}`}</p>
<button onClick={() => setName('new name')}>change name</button>
<button onClick={removeName}>remove name</button>
</div>
);
}
```

#### Generic

`<T>`: 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.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,4 +23,5 @@ export {
useRadio,
useThrottle,
useDebounce,
useLocalStorage,
};
105 changes: 105 additions & 0 deletions src/useLocalStorage/useLocalStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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'); // 저장된 값이 있어도 초기 값을 반환
});
});
66 changes: 66 additions & 0 deletions src/useLocalStorage/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { isServer } from '@/utils/isServer';

interface UseLocalStorageOptions<T> {
serializer?: (value: T) => string;
deserializer?: (storedValue: string) => T;
}

export default function useLocalStorage<T>(
key: string,
initialValue: T | null,
options: UseLocalStorageOptions<T> = {},
): [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<T | null>(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];
}
31 changes: 31 additions & 0 deletions src/utils/isServer.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 3 additions & 0 deletions src/utils/isServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isServer() {
return typeof window === 'undefined';
}
Loading