From bfc68faf51322ba0d1484f1b282a25b80b98cf57 Mon Sep 17 00:00:00 2001 From: "batuhan.topcu" Date: Mon, 11 Nov 2024 10:54:33 +0300 Subject: [PATCH] add defaultServerValue property --- readme.md | 46 +++++++++++++++++++++++++++++++++++++ src/useLocalStorageState.ts | 6 ++++- test/browser.test.tsx | 24 ++++++++++++++++++- test/server.test.tsx | 29 +++++++++++++++++++++-- 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index 3080265..0c53eaf 100644 --- a/readme.md +++ b/readme.md @@ -141,6 +141,44 @@ function useIsServerRender() { +
+Preventing hydration errors when server default value should be different from client default value +

+ +Using the `defaultValue` property to differ between server and client default values will cause hydration errors. To prevent them you can use the `defaultServerValue` property. + +```tsx +// this will throw hydration error +const [state, setState] = useLocalStorageState( + 'key', + { + defaultValue() { + if (isServer) { + return "server default value"; + } + return "client default value"; + }, + }, + ); +``` + +`defaultServerValue` will overwrite `defaultValue` in the `useSyncExternalStore()` hook's `getSnapshot()` function. +Difference is server's `getSnapshot()` will run on server and client's hydration, then `defaultValue` or local storage value will be used after that. + +```tsx +const [state, setState] = useLocalStorageState( + 'key', + { + defaultValue: "client default value", + defaultServerValue: "server default value", + }, + ); +``` + +> Example use cases: You want to show a modal when the user's first enter and you are persistently storing that data on `localStorage`. Using `defaultServerValue` will allow you to show the modal only on the server. + +
+ ## API #### `useLocalStorageState(key: string, options?: LocalStorageOptions)` @@ -165,6 +203,14 @@ Default: `undefined` The default value. You can think of it as the same as `useState(defaultValue)`. +#### `options.defaultServerValue` + +Type: `any` + +Default: `undefined` + +The default value for the server if it should be different from the client's default value. Server will use `defaultValue` if `defaultServerValue` is not provided. + #### `options.storageSync` Type: `boolean` diff --git a/src/useLocalStorageState.ts b/src/useLocalStorageState.ts index 47e884b..96723b9 100644 --- a/src/useLocalStorageState.ts +++ b/src/useLocalStorageState.ts @@ -6,6 +6,7 @@ export const inMemoryData = new Map() export type LocalStorageOptions = { defaultValue?: T | (() => T) + defaultServerValue?: T | (() => T) storageSync?: boolean serializer?: { stringify: (value: unknown) => string @@ -42,9 +43,11 @@ export default function useLocalStorageState( ): LocalStorageState { const serializer = options?.serializer const [defaultValue] = useState(options?.defaultValue) + const [defaultServerValue] = useState(options?.defaultServerValue) return useLocalStorage( key, defaultValue, + defaultServerValue, options?.storageSync, serializer?.parse, serializer?.stringify, @@ -54,6 +57,7 @@ export default function useLocalStorageState( function useLocalStorage( key: string, defaultValue: T | undefined, + defaultServerValue: T | undefined, storageSync: boolean = true, parse: (value: string) => unknown = parseJSON, stringify: (value: unknown) => string = JSON.stringify, @@ -125,7 +129,7 @@ function useLocalStorage( }, // useSyncExternalStore.getServerSnapshot - () => defaultValue, + () => defaultServerValue ?? defaultValue, ) const setState = useCallback( (newValue: SetStateAction): void => { diff --git a/test/browser.test.tsx b/test/browser.test.tsx index cdd4d8c..07503e2 100644 --- a/test/browser.test.tsx +++ b/test/browser.test.tsx @@ -58,12 +58,34 @@ describe('useLocalStorageState()', () => { expect(todos).toStrictEqual(['first', 'second']) }) - test(`initial state is written to localStorage`, () => { + test('initial state is written to localStorage', () => { renderHook(() => useLocalStorageState('todos', { defaultValue: ['first', 'second'] })) expect(localStorage.getItem('todos')).toStrictEqual(JSON.stringify(['first', 'second'])) }) + test('should return defaultValue instead of defaultServerValue on the browser', () => { + const { result } = renderHook(() => + useLocalStorageState('todos', { + defaultValue: ['first', 'second'], + defaultServerValue: ['third', 'forth'], + }), + ) + + const [todos] = result.current + expect(todos).toStrictEqual(['first', 'second']) + }) + + test('defaultServerValue should not written to localStorage', () => { + renderHook(() => + useLocalStorageState('todos', { + defaultServerValue: ['third', 'forth'], + }), + ) + + expect(localStorage.getItem('todos')).toStrictEqual(null) + }) + test('updates state', () => { const { result } = renderHook(() => useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), diff --git a/test/server.test.tsx b/test/server.test.tsx index 1b64110..ed5c7c5 100644 --- a/test/server.test.tsx +++ b/test/server.test.tsx @@ -59,13 +59,38 @@ describe('useLocalStorageState()', () => { }), ) - expect(result.current[0]).toEqual(['first', 'second']) + const [todos] = result.current + expect(todos).toStrictEqual(['first', 'second']) }) test('returns default value on the server', () => { const { result } = renderHookOnServer(() => useLocalStorageState('todos')) - expect(result.current[0]).toEqual(undefined) + const [todos] = result.current + expect(todos).toBe(undefined) + }) + + test('returns defaultServerValue on the server', () => { + const { result } = renderHookOnServer(() => + useLocalStorageState('todos', { + defaultServerValue: ['third', 'forth'], + }), + ) + + const [todos] = result.current + expect(todos).toStrictEqual(['third', 'forth']) + }) + + test('defaultServerValue should overwrite defaultValue on the server', () => { + const { result } = renderHookOnServer(() => + useLocalStorageState('todos', { + defaultValue: ['first', 'second'], + defaultServerValue: ['third', 'forth'], + }), + ) + + const [todos] = result.current + expect(todos).toStrictEqual(['third', 'forth']) }) test(`setValue() on server doesn't throw`, () => {