├── .eslintignore ├── .gitignore ├── .prettierrc.yaml ├── index.ts ├── .gitattributes ├── .editorconfig ├── vitest.config.ts ├── .github └── workflows │ └── main.yml ├── .eslintrc.cjs ├── tsconfig.json ├── license ├── package.json ├── test ├── server.test.tsx └── browser.test.tsx ├── readme.md └── src └── useLocalStorageState.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | /test 2 | *.d.ts 3 | vitest.config.ts 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | 4 | *.js 5 | *.d.ts 6 | yarn.lock 7 | yarn-error.log 8 | .eslintcache 9 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | semi: false 2 | tabWidth: 4 3 | printWidth: 100 4 | singleQuote: true 5 | trailingComma: 'all' 6 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './src/useLocalStorageState.js' 2 | export type { LocalStorageOptions, LocalStorageState } from './src/useLocalStorageState.js' 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # based on Sindre Sorhus `node-module-boilerplate`: 2 | # https://github.com/sindresorhus/node-module-boilerplate/blob/9b8dc81a35acfbd3b75f7f19c9679e4f83b36df3/.gitattributes 3 | * text=auto eol=lf 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | restoreMocks: true, 6 | // otherwise @testing-library/react can't cleanup after tests 7 | globals: true, 8 | coverage: { 9 | enabled: true, 10 | extension: 'js', 11 | include: ['src/**/*'], 12 | }, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | # `fail-fast` is set to `false` because we won't know which Node versions are failing and which are passing 11 | fail-fast: false 12 | matrix: 13 | node-version: 14 | - 18 15 | - 20 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: yarn install 22 | - run: yarn test 23 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const confusingBrowserGlobals = require('confusing-browser-globals') 2 | 3 | module.exports = { 4 | parser: '@typescript-eslint/parser', 5 | 6 | extends: [ 7 | 'strictest/eslint', 8 | 'strictest/promise', 9 | 'strictest/react', 10 | 'strictest/react-hooks', 11 | 'strictest/typescript-eslint', 12 | 'strictest/unicorn', 13 | ], 14 | 15 | plugins: ['promise', 'react', 'react-hooks', '@typescript-eslint', 'unicorn'], 16 | 17 | parserOptions: { 18 | // enables the use of `import { a } from b` syntax. required for TypeScript imports 19 | sourceType: 'module', 20 | 21 | project: './tsconfig.json', 22 | }, 23 | 24 | env: { 25 | es6: true, 26 | browser: true, 27 | }, 28 | 29 | rules: { 30 | 'no-restricted-globals': ['error', ...confusingBrowserGlobals], 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "NodeNext", 5 | "jsx": "react", 6 | "esModuleInterop": true, 7 | "declaration": true, 8 | "moduleResolution": "nodenext", 9 | 10 | // strict options ensuring more stable code 11 | "strict": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "allowUnreachableCode": false, 15 | "noUncheckedIndexedAccess": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "forceConsistentCasingInFileNames": true, 18 | 19 | "types": ["node"], 20 | 21 | // - ℹ️ https://www.typescriptlang.org/tsconfig#isolatedModules 22 | // - 😃 Deno requires "isolatedModules" to be set to `true` 23 | // - 😃 the stricter version - I always prefer the stricter version 24 | // - 😃 probably the future 25 | "isolatedModules": true 26 | }, 27 | "include": ["index.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Antonio Stoilkov hello@astoilkov.com https://astoilkov.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-local-storage-state", 3 | "version": "19.5.0", 4 | "description": "React hook that persist data in localStorage", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/astoilkov/use-local-storage-state.git" 9 | }, 10 | "funding": "https://github.com/sponsors/astoilkov", 11 | "homepage": "https://github.com/astoilkov/use-local-storage-state", 12 | "author": { 13 | "name": "Antonio Stoilkov", 14 | "email": "hello@astoilkov.com", 15 | "url": "https://astoilkov.com" 16 | }, 17 | "keywords": [ 18 | "react", 19 | "hook", 20 | "localStorage", 21 | "persistent", 22 | "state", 23 | "useState", 24 | "hooks", 25 | "local storage", 26 | "store" 27 | ], 28 | "type": "module", 29 | "exports": { 30 | "types": "./index.d.ts", 31 | "default": "./index.js" 32 | }, 33 | "sideEffects": false, 34 | "scripts": { 35 | "build": "tsc", 36 | "size": "yarn build && size-limit", 37 | "lint": "eslint --cache --format=pretty --ext=.ts ./", 38 | "test": "yarn build && yarn lint && vitest --run", 39 | "release": "yarn build && np", 40 | "prettier": "prettier --write --config .prettierrc.yaml {*.ts,*.json}" 41 | }, 42 | "engines": { 43 | "node": ">=14" 44 | }, 45 | "files": [ 46 | "index.js", 47 | "index.d.ts", 48 | "src/**/*.js", 49 | "src/**/*.d.ts" 50 | ], 51 | "peerDependencies": { 52 | "react": ">=18", 53 | "react-dom": ">=18" 54 | }, 55 | "devDependencies": { 56 | "@size-limit/preset-small-lib": "^11.1.1", 57 | "@testing-library/react": "^14.0.0", 58 | "@types/react": "^18.2.67", 59 | "@types/react-dom": "^18.2.22", 60 | "@typescript-eslint/eslint-plugin": "^7.2.0", 61 | "@typescript-eslint/parser": "^7.2.0", 62 | "@vitest/coverage-v8": "^1.4.0", 63 | "confusing-browser-globals": "^1.0.11", 64 | "eslint": "^8.21.0", 65 | "eslint-config-strictest": "^0.8.1", 66 | "eslint-formatter-pretty": "^5.0.0", 67 | "eslint-plugin-promise": "^6.0.0", 68 | "eslint-plugin-react": "^7.29.4", 69 | "eslint-plugin-react-hooks": "^4.5.0", 70 | "eslint-plugin-unicorn": "^43.0.2", 71 | "jsdom": "^22.1.0", 72 | "np": "^7.6.3", 73 | "prettier": "^3.2.5", 74 | "react": "^18.2.0", 75 | "react-dom": "^18.2.0", 76 | "react-test-renderer": "^18.1.0", 77 | "size-limit": "^11.1.1", 78 | "superjson": "^2.2.1", 79 | "typescript": "^5.4.2", 80 | "vitest": "^1.4.0" 81 | }, 82 | "size-limit": [ 83 | { 84 | "name": "import *", 85 | "path": "index.js", 86 | "limit": "1.7 kB", 87 | "brotli": false 88 | }, 89 | { 90 | "name": "import *", 91 | "path": "index.js", 92 | "limit": "750 B" 93 | } 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /test/server.test.tsx: -------------------------------------------------------------------------------- 1 | import util from 'node:util' 2 | import ReactDOM from 'react-dom/server' 3 | import React, { MutableRefObject } from 'react' 4 | import useLocalStorageState from '../src/useLocalStorageState.js' 5 | import { beforeEach, describe, expect, test, vi } from 'vitest' 6 | 7 | function renderHookOnServer(useHook: () => T): { result: MutableRefObject } { 8 | const result: MutableRefObject = { 9 | current: undefined!, 10 | } 11 | 12 | function Component() { 13 | result.current = useHook() 14 | return null 15 | } 16 | 17 | ReactDOM.renderToString() 18 | 19 | return { result } 20 | } 21 | 22 | beforeEach(() => { 23 | // Throw an error when `console.error()` is called. This is especially useful in a React tests 24 | // because React uses it to show warnings and discourage you from shooting yourself in the foot. 25 | // Here are a few example warnings React throws: 26 | // - "Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded 27 | // into the server renderer's output format. This will lead to a mismatch between the initial, 28 | // non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in 29 | // components that render exclusively on the client. See 30 | // https://reactjs.org/link/uselayouteffect-ssr for common fixes." 31 | // - "Warning: Can't perform a React state update on an unmounted component. This is a no-op, 32 | // but it indicates a memory leak in your application. To fix, cancel all subscriptions and 33 | // asynchronous tasks in a useEffect cleanup function." 34 | // - "Warning: Cannot update a component (`Component`) while rendering a different component 35 | // (`Component`). To locate the bad setState() call inside `Component`, follow the stack trace 36 | // as described in https://reactjs.org/link/setstate-in-render" 37 | vi.spyOn(console, 'error').mockImplementation((format: string, ...args: any[]) => { 38 | throw new Error(util.format(format, ...args)) 39 | }) 40 | }) 41 | 42 | describe('useLocalStorageState()', () => { 43 | describe('SSR support', () => { 44 | test('defaultValue accepts lazy initializer (like useState)', () => { 45 | const { result } = renderHookOnServer(() => 46 | useLocalStorageState('todos', { 47 | defaultValue: () => ['first', 'second'], 48 | }), 49 | ) 50 | 51 | const [todos] = result.current 52 | expect(todos).toStrictEqual(['first', 'second']) 53 | }) 54 | 55 | test('returns default value on the server', () => { 56 | const { result } = renderHookOnServer(() => 57 | useLocalStorageState('todos', { 58 | defaultValue: ['first', 'second'], 59 | }), 60 | ) 61 | 62 | const [todos] = result.current 63 | expect(todos).toStrictEqual(['first', 'second']) 64 | }) 65 | 66 | test('returns default value on the server', () => { 67 | const { result } = renderHookOnServer(() => useLocalStorageState('todos')) 68 | 69 | const [todos] = result.current 70 | expect(todos).toBe(undefined) 71 | }) 72 | 73 | test('returns defaultServerValue on the server', () => { 74 | const { result } = renderHookOnServer(() => 75 | useLocalStorageState('todos', { 76 | defaultServerValue: ['third', 'forth'], 77 | }), 78 | ) 79 | 80 | const [todos] = result.current 81 | expect(todos).toStrictEqual(['third', 'forth']) 82 | }) 83 | 84 | test('defaultServerValue should overwrite defaultValue on the server', () => { 85 | const { result } = renderHookOnServer(() => 86 | useLocalStorageState('todos', { 87 | defaultValue: ['first', 'second'], 88 | defaultServerValue: ['third', 'forth'], 89 | }), 90 | ) 91 | 92 | const [todos] = result.current 93 | expect(todos).toStrictEqual(['third', 'forth']) 94 | }) 95 | 96 | test(`setValue() on server doesn't throw`, () => { 97 | const { result } = renderHookOnServer(() => 98 | useLocalStorageState('number', { 99 | defaultValue: 0, 100 | }), 101 | ) 102 | 103 | const setValue = result.current[1] 104 | expect(setValue).not.toThrow() 105 | }) 106 | 107 | test(`removeItem() on server doesn't throw`, () => { 108 | const { result } = renderHookOnServer(() => 109 | useLocalStorageState('number', { 110 | defaultValue: 0, 111 | }), 112 | ) 113 | 114 | const removeItem = result.current[2].removeItem 115 | expect(removeItem).not.toThrow() 116 | }) 117 | 118 | test('isPersistent returns true on the server', () => { 119 | const { result } = renderHookOnServer(() => 120 | useLocalStorageState('number', { 121 | defaultValue: 0, 122 | }), 123 | ) 124 | 125 | expect(result.current[2].isPersistent).toBe(true) 126 | }) 127 | 128 | test('can call mutation methods without throwing and without actually mutating the data', () => { 129 | const { result } = renderHookOnServer(() => { 130 | const hook = useLocalStorageState('number', { 131 | defaultValue: 0, 132 | }) 133 | const [, setValue, { removeItem }] = hook 134 | setValue(1) 135 | removeItem() 136 | return hook 137 | }) 138 | const hook = result.current 139 | 140 | const [value] = hook 141 | expect(value).toBe(0) 142 | }) 143 | }) 144 | }) 145 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # `use-local-storage-state` 2 | 3 | > React hook that persist data in `localStorage` 4 | 5 | [![Downloads](https://img.shields.io/npm/dm/use-local-storage-state)](https://npm.chart.dev/use-local-storage-state) 6 | [![Gzipped Size](https://img.shields.io/bundlephobia/minzip/use-local-storage-state)](https://bundlephobia.com/result?p=use-local-storage-state) 7 | [![Build Status](https://img.shields.io/github/actions/workflow/status/astoilkov/use-local-storage-state/main.yml?branch=main)](https://github.com/astoilkov/use-local-storage-state/actions/workflows/main.yml) 8 | 9 | ## Install 10 | 11 | React 18 and above: 12 | ```bash 13 | npm install use-local-storage-state 14 | ``` 15 | 16 | ⚠️ React 17 and below. For docs, go to the [react-17 branch](https://github.com/astoilkov/use-local-storage-state/tree/react-17). 17 | ```bash 18 | npm install use-local-storage-state@17 19 | ``` 20 | 21 | ## Why 22 | 23 | - Actively maintained for the past 4 years — see [contributors](https://github.com/astoilkov/use-local-storage-state/graphs/contributors) page. 24 | - Production ready. 25 | - 689 B (brotlied). 26 | - SSR support. 27 | - Works with React 18 concurrent rendering and React 19. 28 | - Handles the `Window` [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event and updates changes across browser tabs, windows, and iframe's. Disable with `storageSync: false`. 29 | - In-memory fallback when `localStorage` throws an error and can't store the data. Provides a `isPersistent` API to let you notify the user their data isn't currently being stored. 30 | - Aiming for high-quality with [my open-source principles](https://astoilkov.com/my-open-source-principles). 31 | 32 | ## Usage 33 | 34 | ```typescript 35 | import useLocalStorageState from 'use-local-storage-state' 36 | 37 | export default function Todos() { 38 | const [todos, setTodos] = useLocalStorageState('todos', { 39 | defaultValue: ['buy avocado', 'do 50 push-ups'] 40 | }) 41 | } 42 | ``` 43 | 44 |
45 | Todo list example 46 |

47 | 48 | ```tsx 49 | import React, { useState } from 'react' 50 | import useLocalStorageState from 'use-local-storage-state' 51 | 52 | export default function Todos() { 53 | const [todos, setTodos] = useLocalStorageState('todos', { 54 | defaultValue: ['buy avocado'] 55 | }) 56 | const [query, setQuery] = useState('') 57 | 58 | function onClick() { 59 | setQuery('') 60 | setTodos([...todos, query]) 61 | } 62 | 63 | return ( 64 | <> 65 | setQuery(e.target.value)} /> 66 | 67 | {todos.map(todo => ( 68 |
{todo}
69 | ))} 70 | 71 | ) 72 | } 73 | 74 | ``` 75 | 76 |
77 | 78 |
79 | Notify the user when localStorage isn't saving the data using the isPersistent property 80 |

81 | 82 | There are a few cases when `localStorage` [isn't available](https://github.com/astoilkov/use-local-storage-state/blob/7db8872397eae8b9d2421f068283286847f326ac/index.ts#L3-L11). The `isPersistent` property tells you if the data is persisted in `localStorage` or in-memory. Useful when you want to notify the user that their data won't be persisted. 83 | 84 | ```tsx 85 | import React, { useState } from 'react' 86 | import useLocalStorageState from 'use-local-storage-state' 87 | 88 | export default function Todos() { 89 | const [todos, setTodos, { isPersistent }] = useLocalStorageState('todos', { 90 | defaultValue: ['buy avocado'] 91 | }) 92 | 93 | return ( 94 | <> 95 | {todos.map(todo => (
{todo}
))} 96 | {!isPersistent && Changes aren't currently persisted.} 97 | 98 | ) 99 | } 100 | 101 | ``` 102 | 103 |
104 | 105 |
106 | Removing the data from localStorage and resetting to the default 107 |

108 | 109 | The `removeItem()` method will reset the value to its default and will remove the key from the `localStorage`. It returns to the same state as when the hook was initially created. 110 | 111 | ```tsx 112 | import useLocalStorageState from 'use-local-storage-state' 113 | 114 | export default function Todos() { 115 | const [todos, setTodos, { removeItem }] = useLocalStorageState('todos', { 116 | defaultValue: ['buy avocado'] 117 | }) 118 | 119 | function onClick() { 120 | removeItem() 121 | } 122 | } 123 | ``` 124 | 125 |
126 | 127 |
128 | Why my component renders twice? 129 |

130 | 131 | If you are hydrating your component (for example, if you are using Next.js), your component might re-render twice. This is behavior specific to React and not to this library. It's caused by the `useSyncExternalStore()` hook. There is no workaround. This has been discussed in the issues: https://github.com/astoilkov/use-local-storage-state/issues/56. 132 | 133 | If you want to know if you are currently rendering the server value you can use this helper function: 134 | ```ts 135 | function useIsServerRender() { 136 | return useSyncExternalStore(() => { 137 | return () => {} 138 | }, () => false, () => true) 139 | } 140 | ``` 141 | 142 |
143 | 144 | ## API 145 | 146 | #### `useLocalStorageState(key: string, options?: LocalStorageOptions)` 147 | 148 | Returns `[value, setValue, { removeItem, isPersistent }]` when called. The first two values are the same as `useState()`. The third value contains two extra properties: 149 | - `removeItem()` — calls `localStorage.removeItem(key)` and resets the hook to it's default state 150 | - `isPersistent` — `boolean` property that returns `false` if `localStorage` is throwing an error and the data is stored only in-memory 151 | 152 | #### `key` 153 | 154 | Type: `string` 155 | 156 | The key used when calling `localStorage.setItem(key)` and `localStorage.getItem(key)`. 157 | 158 | ⚠️ Be careful with name conflicts as it is possible to access a property which is already in `localStorage` that was created from another place in the codebase or in an old version of the application. 159 | 160 | #### `options.defaultValue` 161 | 162 | Type: `any` 163 | 164 | Default: `undefined` 165 | 166 | The default value. You can think of it as the same as `useState(defaultValue)`. 167 | 168 | #### `options.defaultServerValue` 169 | 170 | Type: `any` 171 | 172 | Default: `undefined` 173 | 174 | The default value while server-rendering and hydrating. If not set, it will use `defaultValue` option instead. Set only if you want it to be different than the client value. 175 | 176 | #### `options.storageSync` 177 | 178 | Type: `boolean` 179 | 180 | Default: `true` 181 | 182 | Setting to `false` doesn't subscribe to the [Window storage event](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event). If you set to `false`, updates won't be synchronized across tabs, windows and iframes. 183 | 184 | #### `options.serializer` 185 | 186 | Type: `{ stringify, parse }` 187 | 188 | Default: `JSON` 189 | 190 | JSON does not serialize `Date`, `Regex`, or `BigInt` data. You can pass in [superjson](https://github.com/blitz-js/superjson) or other `JSON`-compatible serialization library for more advanced serialization. 191 | 192 | ## Related 193 | 194 | - [`use-storage-state`](https://github.com/astoilkov/use-storage-state) — Supports `localStorage`, `sessionStorage`, and any other [`Storage`](https://developer.mozilla.org/en-US/docs/Web/API/Storage) compatible API. 195 | - [`use-session-storage-state`](https://github.com/astoilkov/use-session-storage-state) — A clone of this library but for `sessionStorage`. 196 | - [`use-db`](https://github.com/astoilkov/use-db) — Similar to this hook but for `IndexedDB`. 197 | - [`local-db-storage`](https://github.com/astoilkov/local-db-storage) — Tiny wrapper around `IndexedDB` that mimics `localStorage` API. -------------------------------------------------------------------------------- /src/useLocalStorageState.ts: -------------------------------------------------------------------------------- 1 | import type { Dispatch, SetStateAction } from 'react' 2 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' 3 | 4 | // in memory fallback used when `localStorage` throws an error 5 | export const inMemoryData = new Map() 6 | 7 | export type LocalStorageOptions = { 8 | defaultValue?: T | (() => T) 9 | defaultServerValue?: T | (() => T) 10 | storageSync?: boolean 11 | serializer?: { 12 | stringify: (value: unknown) => string 13 | parse: (value: string) => unknown 14 | } 15 | } 16 | 17 | // - `useLocalStorageState()` return type 18 | // - first two values are the same as `useState` 19 | export type LocalStorageState = [ 20 | T, 21 | Dispatch>, 22 | { 23 | isPersistent: boolean 24 | removeItem: () => void 25 | }, 26 | ] 27 | 28 | export default function useLocalStorageState( 29 | key: string, 30 | options?: LocalStorageOptions, 31 | ): LocalStorageState 32 | export default function useLocalStorageState( 33 | key: string, 34 | options?: Omit, 'defaultValue'>, 35 | ): LocalStorageState 36 | export default function useLocalStorageState( 37 | key: string, 38 | options?: LocalStorageOptions, 39 | ): LocalStorageState 40 | export default function useLocalStorageState( 41 | key: string, 42 | options?: LocalStorageOptions, 43 | ): LocalStorageState { 44 | const serializer = options?.serializer 45 | const [defaultValue] = useState(options?.defaultValue) 46 | const [defaultServerValue] = useState(options?.defaultServerValue) 47 | return useLocalStorage( 48 | key, 49 | defaultValue, 50 | defaultServerValue, 51 | options?.storageSync, 52 | serializer?.parse, 53 | serializer?.stringify, 54 | ) 55 | } 56 | 57 | function useLocalStorage( 58 | key: string, 59 | defaultValue: T | undefined, 60 | defaultServerValue: T | undefined, 61 | storageSync: boolean = true, 62 | parse: (value: string) => unknown = parseJSON, 63 | stringify: (value: unknown) => string = JSON.stringify, 64 | ): LocalStorageState { 65 | // we keep the `parsed` value in a ref because `useSyncExternalStore` requires a cached version 66 | const storageItem = useRef<{ string: string | null; parsed: T | undefined }>({ 67 | string: null, 68 | parsed: undefined, 69 | }) 70 | 71 | const value = useSyncExternalStore( 72 | // useSyncExternalStore.subscribe 73 | useCallback( 74 | (onStoreChange) => { 75 | const onChange = (localKey: string): void => { 76 | if (key === localKey) { 77 | onStoreChange() 78 | } 79 | } 80 | callbacks.add(onChange) 81 | return (): void => { 82 | callbacks.delete(onChange) 83 | } 84 | }, 85 | [key], 86 | ), 87 | 88 | // useSyncExternalStore.getSnapshot 89 | () => { 90 | const string = goodTry(() => localStorage.getItem(key)) ?? null 91 | 92 | if (inMemoryData.has(key)) { 93 | storageItem.current.parsed = inMemoryData.get(key) as T | undefined 94 | } else if (string !== storageItem.current.string) { 95 | let parsed: T | undefined 96 | 97 | try { 98 | parsed = string === null ? defaultValue : (parse(string) as T) 99 | } catch { 100 | parsed = defaultValue 101 | } 102 | 103 | storageItem.current.parsed = parsed 104 | } 105 | 106 | storageItem.current.string = string 107 | 108 | // store default value in localStorage: 109 | // - initial issue: https://github.com/astoilkov/use-local-storage-state/issues/26 110 | // issues that were caused by incorrect initial and secondary implementations: 111 | // - https://github.com/astoilkov/use-local-storage-state/issues/30 112 | // - https://github.com/astoilkov/use-local-storage-state/issues/33 113 | if (defaultValue !== undefined && string === null) { 114 | // reasons for `localStorage` to throw an error: 115 | // - maximum quota is exceeded 116 | // - under Mobile Safari (since iOS 5) when the user enters private mode 117 | // `localStorage.setItem()` will throw 118 | // - trying to access localStorage object when cookies are disabled in Safari throws 119 | // "SecurityError: The operation is insecure." 120 | // eslint-disable-next-line no-console 121 | goodTry(() => { 122 | const string = stringify(defaultValue) 123 | localStorage.setItem(key, string) 124 | storageItem.current = { string, parsed: defaultValue } 125 | }) 126 | } 127 | 128 | return storageItem.current.parsed 129 | }, 130 | 131 | // useSyncExternalStore.getServerSnapshot 132 | () => defaultServerValue ?? defaultValue, 133 | ) 134 | const setState = useCallback( 135 | (newValue: SetStateAction): void => { 136 | const value = 137 | newValue instanceof Function ? newValue(storageItem.current.parsed) : newValue 138 | 139 | // reasons for `localStorage` to throw an error: 140 | // - maximum quota is exceeded 141 | // - under Mobile Safari (since iOS 5) when the user enters private mode 142 | // `localStorage.setItem()` will throw 143 | // - trying to access `localStorage` object when cookies are disabled in Safari throws 144 | // "SecurityError: The operation is insecure." 145 | try { 146 | localStorage.setItem(key, stringify(value)) 147 | 148 | inMemoryData.delete(key) 149 | } catch { 150 | inMemoryData.set(key, value) 151 | } 152 | 153 | triggerCallbacks(key) 154 | }, 155 | [key, stringify], 156 | ) 157 | const removeItem = useCallback(() => { 158 | goodTry(() => localStorage.removeItem(key)) 159 | 160 | inMemoryData.delete(key) 161 | 162 | triggerCallbacks(key) 163 | }, [key]) 164 | 165 | // - syncs change across tabs, windows, iframes 166 | // - the `storage` event is called only in all tabs, windows, iframe's except the one that 167 | // triggered the change 168 | useEffect(() => { 169 | if (!storageSync) { 170 | return undefined 171 | } 172 | 173 | const onStorage = (e: StorageEvent): void => { 174 | if (e.key === key && e.storageArea === goodTry(() => localStorage)) { 175 | triggerCallbacks(key) 176 | } 177 | } 178 | 179 | window.addEventListener('storage', onStorage) 180 | 181 | return (): void => window.removeEventListener('storage', onStorage) 182 | }, [key, storageSync]) 183 | 184 | return useMemo( 185 | () => [ 186 | value, 187 | setState, 188 | { 189 | isPersistent: value === defaultValue || !inMemoryData.has(key), 190 | removeItem, 191 | }, 192 | ], 193 | [key, setState, value, defaultValue, removeItem], 194 | ) 195 | } 196 | 197 | // notifies all instances using the same `key` to update 198 | const callbacks = new Set<(key: string) => void>() 199 | function triggerCallbacks(key: string): void { 200 | for (const callback of [...callbacks]) { 201 | callback(key) 202 | } 203 | } 204 | 205 | // a wrapper for `JSON.parse()` that supports "undefined" value. otherwise, 206 | // `JSON.parse(JSON.stringify(undefined))` returns the string "undefined" not the value `undefined` 207 | function parseJSON(value: string): unknown { 208 | return value === 'undefined' ? undefined : JSON.parse(value) 209 | } 210 | 211 | function goodTry(tryFn: () => T): T | undefined { 212 | try { 213 | return tryFn() 214 | } catch {} 215 | } 216 | -------------------------------------------------------------------------------- /test/browser.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | 5 | import util from 'node:util' 6 | import superjson from 'superjson' 7 | import { act, render, renderHook } from '@testing-library/react' 8 | import React, { useEffect, useLayoutEffect, useMemo } from 'react' 9 | import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' 10 | import useLocalStorageState, { inMemoryData } from '../src/useLocalStorageState.js' 11 | 12 | beforeEach(() => { 13 | // Throw an error when `console.error()` is called. This is especially useful in a React tests 14 | // because React uses it to show warnings and discourage you from shooting yourself in the foot. 15 | // Here are a few example warnings React throws: 16 | // - "Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded 17 | // into the server renderer's output format. This will lead to a mismatch between the initial, 18 | // non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in 19 | // components that render exclusively on the client. See 20 | // https://reactjs.org/link/uselayouteffect-ssr for common fixes." 21 | // - "Warning: Can't perform a React state update on an unmounted component. This is a no-op, 22 | // but it indicates a memory leak in your application. To fix, cancel all subscriptions and 23 | // asynchronous tasks in a useEffect cleanup function." 24 | // - "Warning: Cannot update a component (`Component`) while rendering a different component 25 | // (`Component`). To locate the bad setState() call inside `Component`, follow the stack trace 26 | // as described in https://reactjs.org/link/setstate-in-render" 27 | vi.spyOn(console, 'error').mockImplementation((format: string, ...args: any[]) => { 28 | throw new Error(util.format(format, ...args)) 29 | }) 30 | }) 31 | 32 | afterEach(() => { 33 | inMemoryData.clear() 34 | try { 35 | localStorage.clear() 36 | sessionStorage.clear() 37 | } catch {} 38 | }) 39 | 40 | describe('useLocalStorageState()', () => { 41 | test('defaultValue accepts lazy initializer (like useState)', () => { 42 | const { result } = renderHook(() => 43 | useLocalStorageState('todos', { 44 | defaultValue: () => ['first', 'second'], 45 | }), 46 | ) 47 | 48 | const [todos] = result.current 49 | expect(todos).toStrictEqual(['first', 'second']) 50 | }) 51 | 52 | test('initial state is written into the state', () => { 53 | const { result } = renderHook(() => 54 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 55 | ) 56 | 57 | const [todos] = result.current 58 | expect(todos).toStrictEqual(['first', 'second']) 59 | }) 60 | 61 | test('initial state is written to localStorage', () => { 62 | renderHook(() => useLocalStorageState('todos', { defaultValue: ['first', 'second'] })) 63 | 64 | expect(localStorage.getItem('todos')).toStrictEqual(JSON.stringify(['first', 'second'])) 65 | }) 66 | 67 | test('should return defaultValue instead of defaultServerValue on the browser', () => { 68 | const { result } = renderHook(() => 69 | useLocalStorageState('todos', { 70 | defaultValue: ['first', 'second'], 71 | defaultServerValue: ['third', 'forth'], 72 | }), 73 | ) 74 | 75 | const [todos] = result.current 76 | expect(todos).toStrictEqual(['first', 'second']) 77 | }) 78 | 79 | test('defaultServerValue should not written to localStorage', () => { 80 | renderHook(() => 81 | useLocalStorageState('todos', { 82 | defaultServerValue: ['third', 'forth'], 83 | }), 84 | ) 85 | 86 | expect(localStorage.getItem('todos')).toStrictEqual(null) 87 | }) 88 | 89 | test('updates state', () => { 90 | const { result } = renderHook(() => 91 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 92 | ) 93 | 94 | act(() => { 95 | const setTodos = result.current[1] 96 | 97 | setTodos(['third', 'forth']) 98 | }) 99 | 100 | const [todos] = result.current 101 | expect(todos).toStrictEqual(['third', 'forth']) 102 | expect(localStorage.getItem('todos')).toStrictEqual(JSON.stringify(['third', 'forth'])) 103 | }) 104 | 105 | test('updates state with callback function', () => { 106 | const { result } = renderHook(() => 107 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 108 | ) 109 | 110 | act(() => { 111 | const setTodos = result.current[1] 112 | 113 | setTodos((value) => [...value, 'third', 'forth']) 114 | }) 115 | 116 | const [todos] = result.current 117 | expect(todos).toStrictEqual(['first', 'second', 'third', 'forth']) 118 | expect(localStorage.getItem('todos')).toStrictEqual( 119 | JSON.stringify(['first', 'second', 'third', 'forth']), 120 | ) 121 | }) 122 | 123 | test('does not fail when having an invalid data in localStorage', () => { 124 | localStorage.setItem('todos', 'some random string') 125 | 126 | const { result } = renderHook(() => 127 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 128 | ) 129 | 130 | const [todos] = result.current 131 | expect(todos).toStrictEqual(['first', 'second']) 132 | }) 133 | 134 | test('updating writes into localStorage', () => { 135 | const { result } = renderHook(() => 136 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 137 | ) 138 | 139 | act(() => { 140 | const setTodos = result.current[1] 141 | 142 | setTodos(['third', 'forth']) 143 | }) 144 | 145 | expect(localStorage.getItem('todos')).toStrictEqual(JSON.stringify(['third', 'forth'])) 146 | }) 147 | 148 | test('initially gets value from local storage if there is a value', () => { 149 | localStorage.setItem('todos', JSON.stringify(['third', 'forth'])) 150 | 151 | const { result } = renderHook(() => 152 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 153 | ) 154 | 155 | const [todos] = result.current 156 | expect(todos).toStrictEqual(['third', 'forth']) 157 | }) 158 | 159 | test('handles errors thrown by localStorage', () => { 160 | vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { 161 | throw new Error() 162 | }) 163 | 164 | const { result } = renderHook(() => 165 | useLocalStorageState('set-item-will-throw', { defaultValue: '' }), 166 | ) 167 | 168 | expect(() => { 169 | act(() => { 170 | const setValue = result.current[1] 171 | setValue('will-throw') 172 | }) 173 | }).not.toThrow() 174 | }) 175 | 176 | // https://github.com/astoilkov/use-local-storage-state/issues/62 177 | test('simulate blocking all the cookies in Safari', () => { 178 | // in Safari, even just accessing `localStorage` throws "SecurityError: The operation is 179 | // insecure." 180 | vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => { 181 | throw new Error() 182 | }) 183 | 184 | const { result } = renderHook(() => 185 | useLocalStorageState('set-item-will-throw', { defaultValue: '' }), 186 | ) 187 | 188 | expect(() => { 189 | act(() => { 190 | const setValue = result.current[1] 191 | setValue('will-throw') 192 | }) 193 | }).not.toThrow() 194 | }) 195 | 196 | test('can set value to `undefined`', () => { 197 | const { result: resultA, unmount } = renderHook(() => 198 | useLocalStorageState('todos', { 199 | defaultValue: ['first', 'second'], 200 | }), 201 | ) 202 | act(() => { 203 | const [, setValue] = resultA.current 204 | setValue(undefined) 205 | }) 206 | unmount() 207 | 208 | const { result: resultB } = renderHook(() => 209 | useLocalStorageState('todos', { 210 | defaultValue: ['first', 'second'], 211 | }), 212 | ) 213 | const [value] = resultB.current 214 | expect(value).toBe(undefined) 215 | }) 216 | 217 | test('`defaultValue` can be set to `null`', () => { 218 | const { result } = renderHook(() => 219 | useLocalStorageState('todos', { 220 | defaultValue: null, 221 | }), 222 | ) 223 | const [value] = result.current 224 | expect(value).toBe(null) 225 | }) 226 | 227 | test('can set value to `null`', () => { 228 | const { result: resultA, unmount } = renderHook(() => 229 | useLocalStorageState('todos', { 230 | defaultValue: ['first', 'second'], 231 | }), 232 | ) 233 | act(() => { 234 | const [, setValue] = resultA.current 235 | setValue(null) 236 | }) 237 | unmount() 238 | 239 | const { result: resultB } = renderHook(() => 240 | useLocalStorageState('todos', { 241 | defaultValue: ['first', 'second'], 242 | }), 243 | ) 244 | const [value] = resultB.current 245 | expect(value).toBe(null) 246 | }) 247 | 248 | test('can reset value to default', () => { 249 | const { result: resultA, unmount: unmountA } = renderHook(() => 250 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 251 | ) 252 | act(() => { 253 | const [, setValue] = resultA.current 254 | setValue(['third', 'forth']) 255 | }) 256 | unmountA() 257 | 258 | const { result: resultB, unmount: unmountB } = renderHook(() => 259 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 260 | ) 261 | act(() => { 262 | const [, , { removeItem }] = resultB.current 263 | removeItem() 264 | }) 265 | unmountB() 266 | 267 | const { result: resultC } = renderHook(() => 268 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 269 | ) 270 | const [value] = resultC.current 271 | expect(value).toStrictEqual(['first', 'second']) 272 | }) 273 | 274 | test('returns the same update function when the value is saved', () => { 275 | const functionMock = vi.fn() 276 | const { rerender } = renderHook(() => { 277 | const [, setTodos] = useLocalStorageState('todos', { 278 | defaultValue: ['first', 'second'], 279 | }) 280 | useMemo(functionMock, [setTodos]) 281 | }) 282 | 283 | rerender() 284 | 285 | expect(functionMock.mock.calls.length).toStrictEqual(1) 286 | }) 287 | 288 | test('changing the "key" property updates the value from local storage', () => { 289 | localStorage.setItem('valueA', JSON.stringify('A')) 290 | localStorage.setItem('valueB', JSON.stringify('B')) 291 | 292 | const { result, rerender } = renderHook( 293 | (props) => useLocalStorageState(props.key, undefined), 294 | { 295 | initialProps: { 296 | key: 'valueA', 297 | }, 298 | }, 299 | ) 300 | 301 | rerender({ key: 'valueB' }) 302 | 303 | const [value] = result.current 304 | expect(value).toStrictEqual('B') 305 | }) 306 | 307 | // https://github.com/astoilkov/use-local-storage-state/issues/30 308 | test(`when defaultValue isn't provided — don't write to localStorage on initial render`, () => { 309 | renderHook(() => useLocalStorageState('todos')) 310 | 311 | expect(localStorage.getItem('todos')).toStrictEqual(null) 312 | }) 313 | 314 | // https://github.com/astoilkov/use-local-storage-state/issues/33 315 | test(`localStorage value shouldn't be overwritten`, () => { 316 | localStorage.setItem('color', 'red') 317 | 318 | renderHook(() => useLocalStorageState('todos', { defaultValue: 'blue' })) 319 | 320 | expect(localStorage.getItem('color')).toStrictEqual('red') 321 | }) 322 | 323 | test('calling update from one hook updates the other', () => { 324 | const { result: resultA } = renderHook(() => 325 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 326 | ) 327 | const { result: resultB } = renderHook(() => 328 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 329 | ) 330 | 331 | act(() => { 332 | const setTodos = resultA.current[1] 333 | 334 | setTodos(['third', 'forth']) 335 | }) 336 | 337 | const [todos] = resultB.current 338 | expect(todos).toStrictEqual(['third', 'forth']) 339 | }) 340 | 341 | test('can reset value to default', () => { 342 | const { result: resultA } = renderHook(() => 343 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 344 | ) 345 | const { result: resultB } = renderHook(() => 346 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 347 | ) 348 | 349 | act(() => { 350 | const setTodosA = resultA.current[1] 351 | const removeTodosB = resultB.current[2].removeItem 352 | 353 | setTodosA(['third', 'forth']) 354 | 355 | removeTodosB() 356 | }) 357 | 358 | const [todos] = resultB.current 359 | expect(todos).toStrictEqual(['first', 'second']) 360 | }) 361 | 362 | // https://github.com/astoilkov/use-local-storage-state/issues/30 363 | test("when defaultValue isn't provided — don't write to localStorage on initial render", () => { 364 | renderHook(() => useLocalStorageState('todos')) 365 | 366 | expect(localStorage.getItem('todos')).toStrictEqual(null) 367 | }) 368 | 369 | test('basic setup with default value', () => { 370 | const { result } = renderHook(() => 371 | useLocalStorageState('todos', { 372 | defaultValue: ['first', 'second'], 373 | }), 374 | ) 375 | 376 | const [todos] = result.current 377 | expect(todos).toStrictEqual(['first', 'second']) 378 | }) 379 | 380 | test('if there are already items in localStorage', () => { 381 | localStorage.setItem('todos', JSON.stringify([4, 5, 6])) 382 | 383 | const { result } = renderHook(() => 384 | useLocalStorageState('todos', { 385 | defaultValue: ['first', 'second'], 386 | }), 387 | ) 388 | 389 | const [todos] = result.current 390 | expect(todos).toStrictEqual([4, 5, 6]) 391 | }) 392 | 393 | test('supports changing the key', () => { 394 | let key = 'todos1' 395 | 396 | const { rerender } = renderHook(() => 397 | useLocalStorageState(key, { defaultValue: ['first', 'second'] }), 398 | ) 399 | 400 | key = 'todos2' 401 | 402 | rerender() 403 | 404 | expect(JSON.parse(localStorage.getItem('todos1')!)).toStrictEqual(['first', 'second']) 405 | expect(JSON.parse(localStorage.getItem('todos2')!)).toStrictEqual(['first', 'second']) 406 | }) 407 | 408 | // https://github.com/astoilkov/use-local-storage-state/issues/39 409 | // https://github.com/astoilkov/use-local-storage-state/issues/43 410 | // https://github.com/astoilkov/use-local-storage-state/pull/40 411 | test(`when ssr: true — don't call useEffect() and useLayoutEffect() on first render`, () => { 412 | let calls = 0 413 | 414 | function Component() { 415 | useLocalStorageState('color', { 416 | defaultValue: 'red', 417 | }) 418 | 419 | useEffect(() => { 420 | calls += 1 421 | }) 422 | 423 | return null 424 | } 425 | 426 | render() 427 | 428 | expect(calls).toBe(1) 429 | }) 430 | 431 | // https://github.com/astoilkov/use-local-storage-state/issues/44 432 | test(`setState() shouldn't change between renders`, () => { 433 | function Component() { 434 | const [value, setValue] = useLocalStorageState('number', { 435 | defaultValue: 1, 436 | }) 437 | 438 | useEffect(() => { 439 | setValue((value) => value + 1) 440 | }, [setValue]) 441 | 442 | return
{value}
443 | } 444 | 445 | const { queryByText } = render() 446 | 447 | expect(queryByText(/^2$/u)).toBeTruthy() 448 | }) 449 | 450 | // https://github.com/astoilkov/use-local-storage-state/issues/43 451 | test(`setState() during render`, () => { 452 | function Component() { 453 | const [value, setValue] = useLocalStorageState('number', { 454 | defaultValue: 0, 455 | }) 456 | 457 | if (value === 0) { 458 | setValue(1) 459 | } 460 | 461 | return
{value}
462 | } 463 | 464 | const { queryByText } = render() 465 | 466 | expect(queryByText(/^0$/u)).not.toBeTruthy() 467 | expect(queryByText(/^1$/u)).toBeTruthy() 468 | }) 469 | 470 | test(`calling setValue() from useLayoutEffect() should update all useLocalStorageState() instances`, () => { 471 | function App() { 472 | return ( 473 | <> 474 | 475 | 476 | 477 | ) 478 | } 479 | 480 | function Component({ update }: { update: boolean }) { 481 | const [value, setValue] = useLocalStorageState('number', { 482 | defaultValue: 0, 483 | }) 484 | 485 | useLayoutEffect(() => { 486 | if (update) { 487 | setValue(1) 488 | } 489 | }, []) 490 | 491 | return
{value}
492 | } 493 | 494 | const { queryAllByText } = render() 495 | 496 | expect(queryAllByText(/^1$/u)).toHaveLength(2) 497 | }) 498 | 499 | describe('hydration', () => { 500 | test(`non-primitive defaultValue to return the same value by reference`, () => { 501 | const defaultValue = ['first', 'second'] 502 | const hook = renderHook( 503 | () => 504 | useLocalStorageState('todos', { 505 | defaultValue, 506 | }), 507 | { hydrate: true }, 508 | ) 509 | const [todos] = hook.result.current 510 | expect(todos).toBe(defaultValue) 511 | }) 512 | }) 513 | 514 | describe('"storage" event', () => { 515 | const fireStorageEvent = (storageArea: Storage, key: string, newValue: unknown): void => { 516 | const oldValue = localStorage.getItem(key) 517 | 518 | if (newValue === null) { 519 | localStorage.removeItem(key) 520 | } else { 521 | localStorage.setItem(key, JSON.stringify(newValue)) 522 | } 523 | 524 | window.dispatchEvent( 525 | new StorageEvent('storage', { 526 | key, 527 | oldValue, 528 | storageArea, 529 | newValue: JSON.stringify(newValue), 530 | }), 531 | ) 532 | } 533 | 534 | test('storage event updates state', () => { 535 | const { result: resultA } = renderHook(() => 536 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 537 | ) 538 | const { result: resultB } = renderHook(() => 539 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 540 | ) 541 | 542 | act(() => { 543 | fireStorageEvent(localStorage, 'todos', ['third', 'forth']) 544 | }) 545 | 546 | const [todosA] = resultA.current 547 | expect(todosA).toStrictEqual(['third', 'forth']) 548 | 549 | const [todosB] = resultB.current 550 | expect(todosB).toStrictEqual(['third', 'forth']) 551 | }) 552 | 553 | test('"storage" event updates state to default value', () => { 554 | const { result } = renderHook(() => 555 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 556 | ) 557 | 558 | act(() => { 559 | const setTodos = result.current[1] 560 | setTodos(['third', 'forth']) 561 | 562 | fireStorageEvent(localStorage, 'todos', null) 563 | }) 564 | 565 | const [todosB] = result.current 566 | expect(todosB).toStrictEqual(['first', 'second']) 567 | }) 568 | 569 | test(`unrelated storage update doesn't do anything`, () => { 570 | const { result } = renderHook(() => 571 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 572 | ) 573 | 574 | act(() => { 575 | // trying with sessionStorage 576 | fireStorageEvent(sessionStorage, 'todos', ['third', 'forth']) 577 | 578 | // trying with a non-relevant key 579 | fireStorageEvent(localStorage, 'not-todos', ['third', 'forth']) 580 | }) 581 | 582 | const [todosA] = result.current 583 | expect(todosA).toStrictEqual(['first', 'second']) 584 | }) 585 | 586 | test('`storageSync: false` disables "storage" event', () => { 587 | const { result } = renderHook(() => 588 | useLocalStorageState('todos', { 589 | defaultValue: ['first', 'second'], 590 | storageSync: false, 591 | }), 592 | ) 593 | 594 | act(() => { 595 | fireStorageEvent(localStorage, 'todos', ['third', 'forth']) 596 | }) 597 | 598 | const [todosA] = result.current 599 | expect(todosA).toStrictEqual(['first', 'second']) 600 | }) 601 | }) 602 | 603 | describe('in memory fallback', () => { 604 | test('can retrieve data from in memory storage', () => { 605 | vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { 606 | throw new Error() 607 | }) 608 | 609 | const { result: resultA } = renderHook(() => 610 | useLocalStorageState('todos', { defaultValue: ['first'] }), 611 | ) 612 | 613 | act(() => { 614 | const setValue = resultA.current[1] 615 | setValue(['first', 'second']) 616 | }) 617 | 618 | const { result: resultB } = renderHook(() => 619 | useLocalStorageState('todos', { defaultValue: ['first'] }), 620 | ) 621 | 622 | const [value] = resultB.current 623 | expect(value).toStrictEqual(['first', 'second']) 624 | }) 625 | 626 | test('isPersistent returns true by default', () => { 627 | const { result } = renderHook(() => 628 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 629 | ) 630 | const [, , { isPersistent }] = result.current 631 | expect(isPersistent).toBe(true) 632 | }) 633 | 634 | test('isPersistent returns true when localStorage.setItem() throws an error but the value is the default value', () => { 635 | vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { 636 | throw new Error() 637 | }) 638 | 639 | const { result } = renderHook(() => 640 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 641 | ) 642 | 643 | const [, , { isPersistent }] = result.current 644 | expect(isPersistent).toBe(true) 645 | }) 646 | 647 | test('isPersistent returns false when localStorage.setItem() throws an error', () => { 648 | vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { 649 | throw new Error() 650 | }) 651 | 652 | const { result } = renderHook(() => 653 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 654 | ) 655 | 656 | act(() => { 657 | const [, setTodos] = result.current 658 | setTodos(['third', 'forth']) 659 | }) 660 | 661 | const [todos, , { isPersistent }] = result.current 662 | expect(isPersistent).toBe(false) 663 | expect(todos).toStrictEqual(['third', 'forth']) 664 | }) 665 | 666 | test('isPersistent becomes false when localStorage.setItem() throws an error on consecutive updates', () => { 667 | const { result } = renderHook(() => 668 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 669 | ) 670 | 671 | vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { 672 | throw new Error() 673 | }) 674 | 675 | act(() => { 676 | const setTodos = result.current[1] 677 | setTodos(['second', 'third']) 678 | }) 679 | 680 | const [todos, , { isPersistent }] = result.current 681 | expect(todos).toStrictEqual(['second', 'third']) 682 | expect(isPersistent).toBe(false) 683 | }) 684 | 685 | test('isPersistent returns true after "storage" event', () => { 686 | const { result } = renderHook(() => 687 | useLocalStorageState('todos', { defaultValue: ['first', 'second'] }), 688 | ) 689 | 690 | // #WET 2020-03-19T8:55:25+02:00 691 | act(() => { 692 | localStorage.setItem('todos', JSON.stringify(['third', 'forth'])) 693 | window.dispatchEvent( 694 | new StorageEvent('storage', { 695 | storageArea: localStorage, 696 | key: 'todos', 697 | oldValue: JSON.stringify(['first', 'second']), 698 | newValue: JSON.stringify(['third', 'forth']), 699 | }), 700 | ) 701 | }) 702 | 703 | const [, , { isPersistent }] = result.current 704 | expect(isPersistent).toBe(true) 705 | }) 706 | }) 707 | 708 | describe('"serializer" option', () => { 709 | test('can serialize Date from initial value', () => { 710 | const date = new Date() 711 | 712 | const { result } = renderHook(() => 713 | useLocalStorageState('date', { 714 | defaultValue: [date], 715 | serializer: superjson, 716 | }), 717 | ) 718 | 719 | const [value] = result.current 720 | expect(value).toStrictEqual([date]) 721 | }) 722 | 723 | test('can serialize Date (in array) from setValue', () => { 724 | const date = new Date() 725 | 726 | const { result } = renderHook(() => 727 | useLocalStorageState<(Date | null)[]>('date', { 728 | defaultValue: [null], 729 | serializer: superjson, 730 | }), 731 | ) 732 | 733 | act(() => { 734 | const setValue = result.current[1] 735 | setValue([date]) 736 | }) 737 | 738 | const [value, _] = result.current 739 | expect(value).toStrictEqual([date]) 740 | }) 741 | 742 | test(`JSON as serializer can't handle undefined as value`, () => { 743 | const { result: resultA, unmount } = renderHook(() => 744 | useLocalStorageState('todos', { 745 | defaultValue: ['first', 'second'], 746 | serializer: JSON, 747 | }), 748 | ) 749 | act(() => { 750 | const [, setValue] = resultA.current 751 | setValue(undefined) 752 | }) 753 | unmount() 754 | 755 | const { result: resultB } = renderHook(() => 756 | useLocalStorageState('todos', { 757 | defaultValue: ['first', 'second'], 758 | serializer: JSON, 759 | }), 760 | ) 761 | const [value] = resultB.current 762 | expect(value).not.toBe(undefined) 763 | }) 764 | }) 765 | }) 766 | --------------------------------------------------------------------------------