├── .gitignore ├── .npmrc ├── .travis.yml ├── LICENSE ├── README.md ├── examples ├── .storybook │ └── main.js ├── package-lock.json ├── package.json ├── src │ ├── Examples.stories.tsx │ └── react-app-env.d.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── common.ts ├── index.ts ├── reducer.ts ├── state.ts └── tests │ ├── reducer.node.test.ts │ ├── reducer.test-d.ts │ ├── reducer.test.ts │ ├── state.node.test.ts │ ├── state.test-d.ts │ ├── state.test.ts │ └── utils.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and not Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Stores VSCode versions used for testing VSCode extensions 107 | .vscode-test 108 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | message="Version %s" -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: lts/* 3 | cache: npm 4 | before_script: npm install codecov -g 5 | script: npm run check 6 | after_success: codecov 7 | deploy: 8 | provider: npm 9 | email: $NPM_EMAIL 10 | api_key: $NPM_TOKEN 11 | skip_cleanup: true 12 | on: 13 | tags: true 14 | branch: master 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Ramón Guijarro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-storage-hooks 2 | 3 | [![Version](https://img.shields.io/npm/v/react-storage-hooks.svg)](https://www.npmjs.com/package/react-storage-hooks) 4 | ![Dependencies](https://img.shields.io/david/soyguijarro/react-storage-hooks.svg) 5 | ![Dev dependencies](https://img.shields.io/david/dev/soyguijarro/react-storage-hooks.svg) 6 | [![Build status](https://travis-ci.com/soyguijarro/react-storage-hooks.svg?branch=master)](https://travis-ci.com/soyguijarro/react-storage-hooks) 7 | [![Test coverage](https://codecov.io/gh/soyguijarro/react-storage-hooks/branch/master/graph/badge.svg)](https://codecov.io/gh/soyguijarro/react-storage-hooks) 8 | ![Bundle size](https://img.shields.io/bundlephobia/minzip/react-storage-hooks.svg) 9 | [![MIT licensed](https://img.shields.io/github/license/soyguijarro/react-storage-hooks.svg)](https://github.com/soyguijarro/react-storage-hooks/blob/master/LICENSE) 10 | 11 | Custom [React hooks](https://reactjs.org/docs/hooks-intro) for keeping application state in sync with `localStorage` or `sessionStorage`. 12 | 13 | :book: **Familiar API**. You already know how to use this library! Replace [`useState`](https://reactjs.org/docs/hooks-reference.html#usestate) and [`useReducer`](https://reactjs.org/docs/hooks-reference.html#usereducer) hooks with the ones in this library and get persistent state for free. 14 | 15 | :sparkles: **Fully featured**. Automatically stringifies and parses values coming and going to storage, keeps state in sync between tabs by listening to [storage events](https://developer.mozilla.org/docs/Web/API/StorageEvent) and handles non-straightforward use cases correctly. 16 | 17 | :zap: **Tiny and fast**. Less than 700 bytes gzipped, enforced with [`size-limit`](https://github.com/ai/size-limit). No external dependencies. Only reads from storage when necessary and writes to storage after rendering. 18 | 19 | :capital_abcd: **Completely typed**. Written in TypeScript. Type definitions included and verified with [`tsd`](https://github.com/SamVerschueren/tsd). 20 | 21 | :muscle: **Backed by tests**. Full coverage of the API. 22 | 23 | ## Requirements 24 | 25 | You need to use [version 16.8.0](https://github.com/facebook/react/blob/master/CHANGELOG.md#1680-february-6-2019) or greater of React, since that's the first one to include hooks. If you still need to create your application, [Create React App](https://create-react-app.dev/) is the officially supported way. 26 | 27 | ## Installation 28 | 29 | Add the package to your React project: 30 | 31 | npm install --save react-storage-hooks 32 | 33 | Or with yarn: 34 | 35 | yarn add react-storage-hooks 36 | 37 | ## Usage 38 | 39 | The `useStorageState` and `useStorageReducer` hooks included in this library work like [`useState`](https://reactjs.org/docs/hooks-reference.html#usestate) and [`useReducer`](https://reactjs.org/docs/hooks-reference.html#usereducer). The only but important differences are: 40 | 41 | - Two additional mandatory parameters: [**`Storage` object**](https://developer.mozilla.org/en-US/docs/Web/API/Storage) (`localStorage` or `sessionStorage`) and **storage key**. 42 | - Initial state parameters only apply if there's no data in storage for the provided key. Otherwise data from storage will be used as initial state. Think about it as **default** or **fallback state**. 43 | - The array returned by hooks has an extra last item for **write errors**. It is initially `undefined`, and will be updated with [`Error` objects](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Error) thrown by `Storage.setItem`. However the hook will keep updating state even if new values fail to be written to storage, to ensure that your application doesn't break. 44 | 45 | ### `useStorageState` 46 | 47 | #### Example 48 | 49 | ```javascript 50 | import React from 'react'; 51 | import { useStorageState } from 'react-storage-hooks'; 52 | 53 | function StateCounter() { 54 | const [count, setCount, writeError] = useStorageState( 55 | localStorage, 56 | 'state-counter', 57 | 0 58 | ); 59 | 60 | return ( 61 | <> 62 |

You clicked {count} times

63 | 64 | 65 | {writeError && ( 66 |
Cannot write to localStorage: {writeError.message}
67 | )} 68 | 69 | ); 70 | } 71 | ``` 72 | 73 | #### Signature 74 | 75 | ```typescript 76 | function useStorageState( 77 | storage: Storage, 78 | key: string, 79 | defaultState?: S | (() => S) 80 | ): [S, React.Dispatch>, Error | undefined]; 81 | ``` 82 | 83 | ### `useStorageReducer` 84 | 85 | #### Example 86 | 87 | ```javascript 88 | import React from 'react'; 89 | import { useStorageReducer } from 'react-storage-hooks'; 90 | 91 | function reducer(state, action) { 92 | switch (action.type) { 93 | case 'inc': 94 | return { count: state.count + 1 }; 95 | case 'dec': 96 | return { count: state.count - 1 }; 97 | default: 98 | return state; 99 | } 100 | } 101 | 102 | function ReducerCounter() { 103 | const [state, dispatch, writeError] = useStorageReducer( 104 | localStorage, 105 | 'reducer-counter', 106 | reducer, 107 | { count: 0 } 108 | ); 109 | 110 | return ( 111 | <> 112 |

You clicked {state.count} times

113 | 114 | 115 | {writeError && ( 116 |
Cannot write to localStorage: {writeError.message}
117 | )} 118 | 119 | ); 120 | } 121 | ``` 122 | 123 | #### Signature 124 | 125 | ```typescript 126 | function useStorageReducer( 127 | storage: Storage, 128 | key: string, 129 | reducer: React.Reducer, 130 | defaultState: S 131 | ): [S, React.Dispatch, Error | undefined]; 132 | 133 | function useStorageReducer( 134 | storage: Storage, 135 | key: string, 136 | reducer: React.Reducer, 137 | defaultInitialArg: I, 138 | defaultInit: (defaultInitialArg: I) => S 139 | ): [S, React.Dispatch, Error | undefined]; 140 | ``` 141 | 142 | ## Advanced usage 143 | 144 | ### Alternative storage objects 145 | 146 | The `storage` parameter of the hooks can be any object that implements the `getItem`, `setItem` and `removeItem` methods of the [`Storage` interface](https://developer.mozilla.org/en-US/docs/Web/API/Storage). Keep in mind that storage values will be automatically [serialized](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) and [parsed](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse) before and after calling these methods. 147 | 148 | ```typescript 149 | interface Storage { 150 | getItem(key: string): string | null; 151 | setItem(key: string, value: string): void; 152 | removeItem(key: string): void; 153 | } 154 | ``` 155 | 156 | ### Server-side rendering (SSR) 157 | 158 | This library checks for the existence of the `window` object and even has some [tests in a node-like environment](https://jestjs.io/docs/en/configuration#testenvironment-string). However in your server code you will need to provide a storage object to the hooks that works server-side. A simple solution is to use a dummy object like this: 159 | 160 | ```javascript 161 | const dummyStorage = { 162 | getItem: () => null, 163 | setItem: () => {}, 164 | removeItem: () => {}, 165 | }; 166 | ``` 167 | 168 | The important bit here is to have the `getItem` method return `null`, so that the default state parameters of the hooks get applied as initial state. 169 | 170 | ### Convenience custom hook 171 | 172 | If you're using a few hooks in your application with the same type of storage, it might bother you to have to specify the storage object all the time. To alleviate this, you can write a custom hook like this: 173 | 174 | ```javascript 175 | import { useStorageState } from 'react-storage-hooks'; 176 | 177 | export function useLocalStorageState(...args) { 178 | return useStorageState(localStorage, ...args); 179 | } 180 | ``` 181 | 182 | And then use it in your components: 183 | 184 | ```javascript 185 | import { useLocalStorageState } from './my-hooks'; 186 | 187 | function Counter() { 188 | const [count, setCount] = useLocalStorageState('counter', 0); 189 | 190 | // Rest of the component 191 | } 192 | ``` 193 | 194 | ## Development 195 | 196 | Install development dependencies: 197 | 198 | npm install 199 | 200 | To set up the examples: 201 | 202 | npm run examples:setup 203 | 204 | To start a server with the examples in watch mode (reloads whenever examples or library code change): 205 | 206 | npm run examples:watch 207 | 208 | ### Tests 209 | 210 | Run tests: 211 | 212 | npm test 213 | 214 | Run tests in watch mode: 215 | 216 | npm run test:watch 217 | 218 | See code coverage information: 219 | 220 | npm run test:coverage 221 | 222 | ### Publish 223 | 224 | Go to the `master` branch: 225 | 226 | git checkout master 227 | 228 | Bump the version number: 229 | 230 | npm version [major | minor | patch] 231 | 232 | Run the release script: 233 | 234 | npm run release 235 | 236 | All code quality checks will run, the tagged commit generated by `npm version` will be pushed and [Travis CI](https://travis-ci.com/github/soyguijarro/react-storage-hooks) will publish the new package version to the npm registry. 237 | 238 | ## License 239 | 240 | This library is [MIT licensed](LICENSE). 241 | -------------------------------------------------------------------------------- /examples/.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.stories.tsx'], 3 | addons: ['@storybook/preset-create-react-app'], 4 | }; 5 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "start": "start-storybook -p 9009 -s public --ci", 5 | "build": "build-storybook -s public" 6 | }, 7 | "eslintConfig": { 8 | "extends": "react-app" 9 | }, 10 | "browserslist": { 11 | "production": [ 12 | ">0.2%", 13 | "not dead", 14 | "not op_mini all" 15 | ], 16 | "development": [ 17 | "last 1 chrome version", 18 | "last 1 firefox version", 19 | "last 1 safari version" 20 | ] 21 | }, 22 | "devDependencies": { 23 | "@storybook/preset-create-react-app": "^2.1.1", 24 | "@storybook/react": "^5.3.18", 25 | "@types/react": "^16.9.34", 26 | "@types/react-dom": "^16.9.6", 27 | "react": "^16.13.1", 28 | "react-dom": "^16.13.1", 29 | "react-scripts": "^3.4.1", 30 | "typescript": "^3.8.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/src/Examples.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useStorageState, useStorageReducer } from 'react-storage-hooks'; 4 | 5 | export function StateCounter() { 6 | const [count, setCount, writeError] = useStorageState( 7 | localStorage, 8 | 'state-counter', 9 | 0 10 | ); 11 | 12 | return ( 13 | <> 14 |

You clicked {count} times

15 | 16 | 17 | {writeError && ( 18 |
Cannot write to localStorage: {writeError.message}
19 | )} 20 | 21 | ); 22 | } 23 | 24 | function reducer(state: { count: number }, action: { type: 'inc' | 'dec' }) { 25 | switch (action.type) { 26 | case 'inc': 27 | return { count: state.count + 1 }; 28 | case 'dec': 29 | return { count: state.count - 1 }; 30 | default: 31 | return state; 32 | } 33 | } 34 | 35 | export function ReducerCounter() { 36 | const [state, dispatch, writeError] = useStorageReducer( 37 | localStorage, 38 | 'reducer-counter', 39 | reducer, 40 | { count: 0 } 41 | ); 42 | 43 | return ( 44 | <> 45 |

You clicked {state.count} times

46 | 47 | 48 | {writeError && ( 49 |
Cannot write to localStorage: {writeError.message}
50 | )} 51 | 52 | ); 53 | } 54 | 55 | export default { title: 'Examples' }; 56 | -------------------------------------------------------------------------------- /examples/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-storage-hooks", 3 | "version": "4.0.1", 4 | "description": "React hooks for persistent state", 5 | "keywords": [ 6 | "react", 7 | "react-hooks", 8 | "persistent", 9 | "useState", 10 | "useReducer", 11 | "storage", 12 | "localstorage", 13 | "sessionstorage" 14 | ], 15 | "main": "dist/index.js", 16 | "types": "dist/index.d.ts", 17 | "scripts": { 18 | "lint": "eslint src/*.ts", 19 | "fmt": "prettier --check *.md *.json src/*.ts", 20 | "types": "tsd", 21 | "test": "jest", 22 | "test:watch": "npm test -- --watch", 23 | "test:coverage": "npm test -- --coverage", 24 | "test:staged": "npm test -- --findRelatedTests --bail", 25 | "prebuild": "del dist", 26 | "build": "tsc", 27 | "build:watch": "npm run build -- --watch", 28 | "size": "size-limit", 29 | "precheck": "npm run build", 30 | "check": "run-s lint fmt types test:coverage size", 31 | "examples:setup": "cd examples && npm install && npm link ../.", 32 | "examples:start": "cd examples && npm start", 33 | "examples:watch": "run-p build:watch examples:start", 34 | "prerelease": "npm run check", 35 | "release": "git push --follow-tags origin master" 36 | }, 37 | "files": [ 38 | "dist", 39 | "dist/index.d.ts" 40 | ], 41 | "author": "Ramón Guijarro ", 42 | "license": "MIT", 43 | "repository": "https://github.com/soyguijarro/react-storage-hooks", 44 | "peerDependencies": { 45 | "react": "^16.8.0" 46 | }, 47 | "husky": { 48 | "hooks": { 49 | "pre-commit": "lint-staged" 50 | } 51 | }, 52 | "prettier": { 53 | "singleQuote": true 54 | }, 55 | "eslintConfig": { 56 | "parser": "@typescript-eslint/parser", 57 | "plugins": [ 58 | "@typescript-eslint", 59 | "react-hooks" 60 | ], 61 | "extends": [ 62 | "plugin:@typescript-eslint/recommended", 63 | "plugin:react-app/recommended", 64 | "prettier", 65 | "prettier/@typescript-eslint" 66 | ], 67 | "rules": { 68 | "no-console": "error", 69 | "react-hooks/rules-of-hooks": "error", 70 | "react-hooks/exhaustive-deps": "error", 71 | "@typescript-eslint/explicit-function-return-type": "off" 72 | }, 73 | "settings": { 74 | "react": { 75 | "version": "detect" 76 | } 77 | } 78 | }, 79 | "lint-staged": { 80 | "*.{md,json}": "prettier --write", 81 | "*.ts": [ 82 | "prettier --write", 83 | "eslint --fix", 84 | "npm run test:staged" 85 | ] 86 | }, 87 | "jest": { 88 | "moduleFileExtensions": [ 89 | "ts", 90 | "js" 91 | ], 92 | "preset": "ts-jest", 93 | "testMatch": [ 94 | "**/src/tests/*.test.ts" 95 | ], 96 | "coveragePathIgnorePatterns": [ 97 | "/node_modules/", 98 | "/src/tests/" 99 | ], 100 | "coverageThreshold": { 101 | "global": { 102 | "branches": 100, 103 | "functions": 100, 104 | "lines": 100, 105 | "statements": 100 106 | } 107 | } 108 | }, 109 | "tsd": { 110 | "directory": "src" 111 | }, 112 | "size-limit": [ 113 | { 114 | "limit": "700 B", 115 | "path": "dist/index.js" 116 | } 117 | ], 118 | "devDependencies": { 119 | "@size-limit/preset-small-lib": "^4.4.5", 120 | "@testing-library/react-hooks": "^3.2.1", 121 | "@types/jest": "^25.2.1", 122 | "@types/react": "^16.9.34", 123 | "@typescript-eslint/eslint-plugin": "^2.28.0", 124 | "@typescript-eslint/parser": "^2.28.0", 125 | "del-cli": "^3.0.0", 126 | "eslint": "^6.8.0", 127 | "eslint-config-prettier": "^6.10.1", 128 | "eslint-plugin-react-app": "^6.2.2", 129 | "eslint-plugin-react-hooks": "^3.0.0", 130 | "husky": "^4.2.5", 131 | "jest": "^25.3.0", 132 | "lint-staged": "^10.1.5", 133 | "npm-run-all": "^4.1.5", 134 | "prettier": "^2.0.4", 135 | "react": "^16.13.1", 136 | "react-test-renderer": "^16.13.1", 137 | "size-limit": "^4.4.5", 138 | "ts-jest": "^25.4.0", 139 | "tsd": "^0.11.0", 140 | "typescript": "^3.8.3" 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef, useMemo } from 'react'; 2 | 3 | export type StorageObj = Pick; 4 | 5 | function fromStorage(value: string | null) { 6 | return value !== null ? (JSON.parse(value) as T) : null; 7 | } 8 | 9 | function readItem(storage: StorageObj, key: string) { 10 | try { 11 | const storedValue = storage.getItem(key); 12 | return fromStorage(storedValue); 13 | } catch (e) { 14 | return null; 15 | } 16 | } 17 | 18 | function toStorage(value: T | null) { 19 | return JSON.stringify(value); 20 | } 21 | 22 | function writeItem(storage: StorageObj, key: string, value: T | null) { 23 | try { 24 | if (value !== null) { 25 | storage.setItem(key, toStorage(value)); 26 | } else { 27 | storage.removeItem(key); 28 | } 29 | return Promise.resolve(); 30 | } catch (error) { 31 | return Promise.reject(error); 32 | } 33 | } 34 | 35 | export function useInitialState( 36 | storage: StorageObj, 37 | key: string, 38 | defaultState: S 39 | ) { 40 | const defaultStateRef = useRef(defaultState); 41 | 42 | return useMemo(() => readItem(storage, key) ?? defaultStateRef.current, [ 43 | key, 44 | storage, 45 | ]); 46 | } 47 | 48 | export function useStorageWriter( 49 | storage: StorageObj, 50 | key: string, 51 | state: S 52 | ) { 53 | const [writeError, setWriteError] = useState(undefined); 54 | 55 | useEffect(() => { 56 | writeItem(storage, key, state).catch((error) => { 57 | if (!error || !error.message || error.message !== writeError?.message) { 58 | setWriteError(error); 59 | } 60 | }); 61 | 62 | if (writeError) { 63 | return () => { 64 | setWriteError(undefined); 65 | }; 66 | } 67 | }, [state, key, writeError, storage]); 68 | 69 | return writeError; 70 | } 71 | 72 | export function useStorageListener( 73 | storage: StorageObj, 74 | key: string, 75 | defaultState: S, 76 | onChange: (newValue: S) => void 77 | ) { 78 | const defaultStateRef = useRef(defaultState); 79 | const onChangeRef = useRef(onChange); 80 | 81 | const firstRun = useRef(true); 82 | useEffect(() => { 83 | if (firstRun.current) { 84 | firstRun.current = false; 85 | return; 86 | } 87 | 88 | onChangeRef.current(readItem(storage, key) ?? defaultStateRef.current); 89 | }, [key, storage]); 90 | 91 | useEffect(() => { 92 | function onStorageChange(event: StorageEvent) { 93 | if (event.key === key) { 94 | onChangeRef.current( 95 | fromStorage(event.newValue) ?? defaultStateRef.current 96 | ); 97 | } 98 | } 99 | 100 | if ( 101 | typeof window !== 'undefined' && 102 | typeof window.addEventListener !== 'undefined' 103 | ) { 104 | window.addEventListener('storage', onStorageChange); 105 | return () => { 106 | window.removeEventListener('storage', onStorageChange); 107 | }; 108 | } 109 | }, [key]); 110 | } 111 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useStorageState } from './state'; 2 | export { default as useStorageReducer } from './reducer'; 3 | -------------------------------------------------------------------------------- /src/reducer.ts: -------------------------------------------------------------------------------- 1 | import { useReducer, Reducer, Dispatch } from 'react'; 2 | 3 | import { 4 | useInitialState, 5 | useStorageListener, 6 | useStorageWriter, 7 | StorageObj, 8 | } from './common'; 9 | 10 | const FORCE_STATE_ACTION = '__FORCE_STATE_INTERNAL_API__'; 11 | type ForceStateAction = { type: typeof FORCE_STATE_ACTION; payload: S }; 12 | 13 | function isForceStateAction( 14 | action: A | ForceStateAction 15 | ): action is ForceStateAction { 16 | return ( 17 | typeof action === 'object' && 18 | action !== null && 19 | 'type' in action && 20 | action.type === FORCE_STATE_ACTION 21 | ); 22 | } 23 | 24 | function addForceStateActionToReducer(reducer: Reducer) { 25 | return (state: S, action: A | ForceStateAction) => { 26 | if (isForceStateAction(action)) return action.payload; 27 | return reducer(state, action); 28 | }; 29 | } 30 | 31 | function useStorageReducer( 32 | storage: StorageObj, 33 | key: string, 34 | reducer: Reducer, 35 | defaultState: S 36 | ): [S, Dispatch
, Error | undefined]; 37 | 38 | function useStorageReducer( 39 | storage: StorageObj, 40 | key: string, 41 | reducer: Reducer, 42 | defaultInitialArg: I, 43 | defaultInit: (defaultInitialArg: I) => S 44 | ): [S, Dispatch, Error | undefined]; 45 | 46 | function useStorageReducer( 47 | storage: StorageObj, 48 | key: string, 49 | reducer: Reducer, 50 | defaultInitialArg: I, 51 | defaultInit: (defaultInitialArg: I | S) => S = (x) => x as S 52 | ): [S, Dispatch, Error | undefined] { 53 | const defaultState = defaultInit(defaultInitialArg); 54 | 55 | const [state, dispatch] = useReducer( 56 | addForceStateActionToReducer(reducer), 57 | useInitialState(storage, key, defaultState) 58 | ); 59 | 60 | useStorageListener(storage, key, defaultState, (newValue: S) => { 61 | dispatch({ type: FORCE_STATE_ACTION, payload: newValue }); 62 | }); 63 | const writeError = useStorageWriter(storage, key, state); 64 | 65 | return [state, dispatch, writeError]; 66 | } 67 | 68 | export default useStorageReducer; 69 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { useState, Dispatch, SetStateAction } from 'react'; 2 | 3 | import { 4 | useInitialState, 5 | useStorageListener, 6 | useStorageWriter, 7 | StorageObj, 8 | } from './common'; 9 | 10 | function useStorageState( 11 | storage: StorageObj, 12 | key: string, 13 | defaultState: S | (() => S) 14 | ): [S, Dispatch>, Error | undefined]; 15 | 16 | function useStorageState( 17 | storage: StorageObj, 18 | key: string 19 | ): [S | null, Dispatch>, Error | undefined]; 20 | 21 | function useStorageState( 22 | storage: StorageObj, 23 | key: string, 24 | defaultState: S | (() => S) | null = null 25 | ) { 26 | const [state, setState] = useState( 27 | useInitialState(storage, key, defaultState) 28 | ); 29 | 30 | useStorageListener(storage, key, defaultState, setState); 31 | const writeError = useStorageWriter(storage, key, state); 32 | 33 | return [state, setState, writeError]; 34 | } 35 | 36 | export default useStorageState; 37 | -------------------------------------------------------------------------------- /src/tests/reducer.node.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { renderHook } from '@testing-library/react-hooks'; 6 | 7 | import { useStorageReducer } from '..'; 8 | import { storageLikeObject } from './utils'; 9 | 10 | function reducer(state: { value: number }) { 11 | return state; 12 | } 13 | 14 | it('returns default state', () => { 15 | const { result } = renderHook(() => 16 | useStorageReducer(storageLikeObject, 'key', reducer, { 17 | value: 0, 18 | }) 19 | ); 20 | 21 | const [state] = result.current; 22 | expect(state).toStrictEqual({ value: 0 }); 23 | }); 24 | 25 | it('returns default state (lazy initialization)', () => { 26 | const { result } = renderHook(() => 27 | useStorageReducer(storageLikeObject, 'key', reducer, 0, value => ({ 28 | value, 29 | })) 30 | ); 31 | 32 | const [state] = result.current; 33 | expect(state).toStrictEqual({ value: 0 }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/tests/reducer.test-d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | 3 | import { Dispatch } from 'react'; 4 | import { expectType, expectError } from 'tsd'; 5 | 6 | import { useStorageReducer } from '..'; 7 | import { storageLikeObject } from './utils'; 8 | 9 | type State = { value: number }; 10 | type Action = { type: 'inc' | 'dec' }; 11 | 12 | function reducer(state: State, action: Action) { 13 | switch (action.type) { 14 | case 'inc': 15 | return { value: state.value + 1 }; 16 | case 'dec': 17 | return { value: state.value - 1 }; 18 | default: 19 | return state; 20 | } 21 | } 22 | 23 | const [state, dispatch, writeError] = useStorageReducer( 24 | localStorage, 25 | 'key', 26 | reducer, 27 | { value: 0 } 28 | ); 29 | expectType(state); 30 | expectType>(dispatch); 31 | expectType(writeError); 32 | expectError(() => dispatch({ type: 'other' })); 33 | 34 | const [otherState, otherDispatch] = useStorageReducer( 35 | localStorage, 36 | 'key', 37 | reducer, 38 | 0, 39 | value => ({ value }) 40 | ); 41 | expectType(otherState); 42 | expectType>(otherDispatch); 43 | 44 | useStorageReducer(storageLikeObject, 'key', reducer, { value: 0 }); 45 | 46 | expectError(() => useStorageReducer()); 47 | expectError(() => useStorageReducer(localStorage)); 48 | expectError(() => useStorageReducer(localStorage, 'key')); 49 | expectError(() => useStorageReducer(localStorage, 'key', reducer)); 50 | 51 | expectError(() => useStorageReducer({}, 'key', reducer, { value: 0 })); 52 | expectError(() => useStorageReducer(localStorage, 0, reducer, { value: 0 })); 53 | expectError(() => 54 | useStorageReducer(localStorage, 'key', () => 0, { value: 0 }) 55 | ); 56 | expectError(() => 57 | useStorageReducer(localStorage, 'key', reducer, { value: 'value' }) 58 | ); 59 | expectError(() => 60 | useStorageReducer(localStorage, 'key', reducer, 'value', value => ({ value })) 61 | ); 62 | -------------------------------------------------------------------------------- /src/tests/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks'; 2 | 3 | import { useStorageReducer } from '..'; 4 | import { 5 | mockStorageError, 6 | mockStorageErrorOnce, 7 | fireStorageEvent, 8 | } from './utils'; 9 | 10 | afterEach(() => { 11 | localStorage.clear(); 12 | jest.restoreAllMocks(); 13 | }); 14 | 15 | function reducer(state: { value: number }, action: { type: 'inc' | 'dec' }) { 16 | switch (action.type) { 17 | case 'inc': 18 | return { value: state.value + 1 }; 19 | case 'dec': 20 | return { value: state.value - 1 }; 21 | default: 22 | return state; 23 | } 24 | } 25 | 26 | describe('initialization', () => { 27 | it('returns storage value when available', () => { 28 | localStorage.setItem('key', '{"value":1}'); 29 | 30 | const { result } = renderHook(() => 31 | useStorageReducer(localStorage, 'key', reducer, { 32 | value: 0, 33 | }) 34 | ); 35 | 36 | const [state] = result.current; 37 | expect(state).toStrictEqual({ value: 1 }); 38 | }); 39 | 40 | it('returns default state when storage empty and writes it to storage', () => { 41 | const { result } = renderHook(() => 42 | useStorageReducer(localStorage, 'key', reducer, { value: 0 }) 43 | ); 44 | 45 | const [state] = result.current; 46 | expect(state).toStrictEqual({ value: 0 }); 47 | expect(localStorage.getItem('key')).toBe('{"value":0}'); 48 | }); 49 | 50 | it('returns default state when storage empty and writes it to storage (lazy initialization)', () => { 51 | const { result } = renderHook(() => 52 | useStorageReducer(localStorage, 'key', reducer, 0, value => ({ value })) 53 | ); 54 | 55 | const [state] = result.current; 56 | expect(state).toStrictEqual({ value: 0 }); 57 | expect(localStorage.getItem('key')).toBe('{"value":0}'); 58 | }); 59 | 60 | it('returns default state when storage reading fails', () => { 61 | mockStorageErrorOnce(localStorage, 'getItem', 'Error message'); 62 | localStorage.setItem('key', '{"value":1}'); 63 | 64 | const { result } = renderHook(() => 65 | useStorageReducer(localStorage, 'key', reducer, { value: 0 }) 66 | ); 67 | 68 | const { 69 | current: [state], 70 | } = result; 71 | expect(state).toStrictEqual({ value: 0 }); 72 | }); 73 | }); 74 | 75 | describe('updates', () => { 76 | it('returns new state and writes to storage', () => { 77 | const { result } = renderHook(() => 78 | useStorageReducer(localStorage, 'key', reducer, { value: 0 }) 79 | ); 80 | const [, dispatch] = result.current; 81 | act(() => dispatch({ type: 'inc' })); 82 | 83 | const [newState] = result.current; 84 | expect(newState).toStrictEqual({ value: 1 }); 85 | expect(localStorage.getItem('key')).toBe('{"value":1}'); 86 | }); 87 | 88 | it('returns new state and write error when storage writing fails once', async () => { 89 | mockStorageErrorOnce(localStorage, 'setItem', 'Error message'); 90 | 91 | const { result, waitForNextUpdate } = renderHook(() => 92 | useStorageReducer(localStorage, 'key', reducer, { value: 0 }) 93 | ); 94 | const [, dispatch] = result.current; 95 | act(() => dispatch({ type: 'inc' })); 96 | await waitForNextUpdate(); 97 | 98 | const [newState, , writeError] = result.current; 99 | expect(newState).toStrictEqual({ value: 1 }); 100 | expect(writeError).toEqual(Error('Error message')); 101 | }); 102 | 103 | it('returns new state and previous write error when storage writing fails multiple times', async () => { 104 | mockStorageError(localStorage, 'setItem', 'Error message'); 105 | 106 | const { result, waitForNextUpdate } = renderHook(() => 107 | useStorageReducer(localStorage, 'key', reducer, { value: 0 }) 108 | ); 109 | const [, dispatch] = result.current; 110 | act(() => dispatch({ type: 'inc' })); 111 | await waitForNextUpdate(); 112 | 113 | const [, newDispatch, writeError] = result.current; 114 | expect(writeError).toEqual(Error('Error message')); 115 | 116 | act(() => newDispatch({ type: 'inc' })); 117 | await waitForNextUpdate(); 118 | 119 | const [, , newWriteError] = result.current; 120 | expect(newWriteError).toEqual(Error('Error message')); 121 | }); 122 | 123 | it('returns new state and no previous write error when storage writing works after failing', async () => { 124 | mockStorageErrorOnce(localStorage, 'setItem', 'Error message'); 125 | 126 | const { result, waitForNextUpdate } = renderHook(() => 127 | useStorageReducer(localStorage, 'key', reducer, { value: 0 }) 128 | ); 129 | const [, dispatch] = result.current; 130 | act(() => dispatch({ type: 'inc' })); 131 | await waitForNextUpdate(); 132 | 133 | const [, newDispatch, writeError] = result.current; 134 | expect(writeError).toEqual(Error('Error message')); 135 | 136 | act(() => newDispatch({ type: 'inc' })); 137 | 138 | const [, , newWriteError] = result.current; 139 | expect(newWriteError).toBeUndefined(); 140 | }); 141 | 142 | it('returns same state when default state changes', () => { 143 | localStorage.setItem('key', '{"value":1}'); 144 | 145 | const { result, rerender } = renderHook( 146 | defaultState => 147 | useStorageReducer(localStorage, 'key', reducer, defaultState), 148 | { initialProps: { value: 0 } } 149 | ); 150 | rerender({ value: 2 }); 151 | 152 | const [newState] = result.current; 153 | expect(newState).toStrictEqual({ value: 1 }); 154 | }); 155 | 156 | it('returns same state when storage empty and default state changes', () => { 157 | const { result, rerender } = renderHook( 158 | defaultState => 159 | useStorageReducer(localStorage, 'key', reducer, defaultState), 160 | { initialProps: { value: 0 } } 161 | ); 162 | rerender({ value: 1 }); 163 | 164 | const [newState] = result.current; 165 | expect(newState).toStrictEqual({ value: 0 }); 166 | }); 167 | 168 | it('returns new state when storage event fired for key', () => { 169 | const { result } = renderHook(() => 170 | useStorageReducer(localStorage, 'key', reducer, { value: 0 }) 171 | ); 172 | 173 | act(() => fireStorageEvent('key', '{"value":1}')); 174 | 175 | const [newState] = result.current; 176 | expect(newState).toStrictEqual({ value: 1 }); 177 | }); 178 | 179 | it('returns same state when storage event fired for key and storage empty', () => { 180 | const { result } = renderHook(() => 181 | useStorageReducer(localStorage, 'key', reducer, { value: 0 }) 182 | ); 183 | 184 | act(() => fireStorageEvent('key', null)); 185 | 186 | const [newState] = result.current; 187 | expect(newState).toStrictEqual({ value: 0 }); 188 | }); 189 | 190 | it('returns same state when storage event fired for other key', () => { 191 | const { result } = renderHook(() => 192 | useStorageReducer(localStorage, 'key', reducer, { value: 0 }) 193 | ); 194 | 195 | act(() => { 196 | fireStorageEvent('other-key', '{"value":1}'); 197 | }); 198 | 199 | const [newState] = result.current; 200 | expect(newState).toStrictEqual({ value: 0 }); 201 | }); 202 | }); 203 | 204 | describe('resetting', () => { 205 | it('returns new storage value when key changes and storage value available', () => { 206 | localStorage.setItem('new-key', '{"value":1}'); 207 | 208 | const { result, rerender } = renderHook( 209 | key => useStorageReducer(localStorage, key, reducer, { value: 0 }), 210 | { 211 | initialProps: 'key', 212 | } 213 | ); 214 | rerender('new-key'); 215 | 216 | const [newState] = result.current; 217 | expect(newState).toStrictEqual({ value: 1 }); 218 | }); 219 | 220 | it('returns default state when key changes and storage empty', () => { 221 | localStorage.setItem('key', '{"value":1}'); 222 | 223 | const { result, rerender } = renderHook( 224 | key => useStorageReducer(localStorage, key, reducer, { value: 0 }), 225 | { 226 | initialProps: 'key', 227 | } 228 | ); 229 | rerender('new-key'); 230 | 231 | const [newState] = result.current; 232 | expect(newState).toStrictEqual({ value: 0 }); 233 | }); 234 | 235 | it('returns no previous write error when key changes', async () => { 236 | mockStorageErrorOnce(localStorage, 'setItem', 'Error message'); 237 | 238 | const { result, rerender, waitForNextUpdate } = renderHook( 239 | key => useStorageReducer(localStorage, key, reducer, { value: 0 }), 240 | { initialProps: 'key' } 241 | ); 242 | const [, dispatch] = result.current; 243 | act(() => dispatch({ type: 'inc' })); 244 | await waitForNextUpdate(); 245 | 246 | const [, , writeError] = result.current; 247 | expect(writeError).toEqual(Error('Error message')); 248 | 249 | rerender('new-key'); 250 | 251 | const [, , newWriteError] = result.current; 252 | expect(newWriteError).toBeUndefined(); 253 | }); 254 | 255 | it('writes to new key when key changes', () => { 256 | localStorage.setItem('key', '{"value":1}'); 257 | localStorage.setItem('new-key', '{"value":2}'); 258 | 259 | const { result, rerender } = renderHook( 260 | key => useStorageReducer(localStorage, key, reducer, { value: 0 }), 261 | { 262 | initialProps: 'key', 263 | } 264 | ); 265 | rerender('new-key'); 266 | const [, dispatch] = result.current; 267 | act(() => dispatch({ type: 'inc' })); 268 | 269 | expect(localStorage.getItem('key')).toStrictEqual('{"value":1}'); 270 | expect(localStorage.getItem('new-key')).toStrictEqual('{"value":3}'); 271 | }); 272 | }); 273 | -------------------------------------------------------------------------------- /src/tests/state.node.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | 5 | import { renderHook } from '@testing-library/react-hooks'; 6 | 7 | import { useStorageState } from '..'; 8 | import { storageLikeObject } from './utils'; 9 | 10 | it('returns default state', () => { 11 | const { result } = renderHook(() => 12 | useStorageState(storageLikeObject, 'key', { value: 0 }) 13 | ); 14 | const [state] = result.current; 15 | expect(state).toStrictEqual({ value: 0 }); 16 | }); 17 | 18 | it('returns default state (lazy initialization)', () => { 19 | const { result } = renderHook(() => 20 | useStorageState(storageLikeObject, 'key', () => ({ value: 0 })) 21 | ); 22 | 23 | const [state] = result.current; 24 | expect(state).toStrictEqual({ value: 0 }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/tests/state.test-d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | 3 | import { Dispatch, SetStateAction } from 'react'; 4 | import { expectType, expectError } from 'tsd'; 5 | 6 | import { useStorageState } from '..'; 7 | import { storageLikeObject } from './utils'; 8 | 9 | type SetState = Dispatch>; 10 | 11 | const [inferredString, setInferredString, writeError] = useStorageState( 12 | localStorage, 13 | 'key', 14 | 'test' 15 | ); 16 | expectType(inferredString); 17 | expectType>(setInferredString); 18 | expectType(writeError); 19 | expectError(() => setInferredString(0)); 20 | 21 | const [inferredNumber, setInferredNumber] = useStorageState( 22 | localStorage, 23 | 'key', 24 | 0 25 | ); 26 | expectType(inferredNumber); 27 | expectType>(setInferredNumber); 28 | expectError(() => setInferredNumber('test')); 29 | 30 | const [inferredNumberLazy, setInferredNumberLazy] = useStorageState( 31 | localStorage, 32 | 'key', 33 | () => 0 34 | ); 35 | expectType(inferredNumberLazy); 36 | expectType>(setInferredNumberLazy); 37 | expectError(() => setInferredNumberLazy('test')); 38 | 39 | const [declaredNumber, setDeclaredNumber] = useStorageState( 40 | localStorage, 41 | 'key' 42 | ); 43 | expectType(declaredNumber); 44 | expectType>(setDeclaredNumber); 45 | expectError(() => setDeclaredNumber('test')); 46 | 47 | const [unknown, setUnknown] = useStorageState(localStorage, 'key'); 48 | expectType(unknown); 49 | expectType>(setUnknown); 50 | 51 | useStorageState(storageLikeObject, 'key', 0); 52 | 53 | expectError(() => useStorageState()); 54 | expectError(() => useStorageState(localStorage)); 55 | 56 | expectError(() => useStorageState({}, 'key')); 57 | expectError(() => useStorageState(localStorage, 0)); 58 | -------------------------------------------------------------------------------- /src/tests/state.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks'; 2 | 3 | import { useStorageState } from '..'; 4 | import { 5 | mockStorageError, 6 | mockStorageErrorOnce, 7 | fireStorageEvent, 8 | } from './utils'; 9 | 10 | afterEach(() => { 11 | localStorage.clear(); 12 | jest.restoreAllMocks(); 13 | }); 14 | 15 | describe('initialization', () => { 16 | it('returns storage value when available', () => { 17 | localStorage.setItem('key', '{"value":1}'); 18 | 19 | const { result } = renderHook(() => 20 | useStorageState(localStorage, 'key', { value: 0 }) 21 | ); 22 | 23 | const [state] = result.current; 24 | expect(state).toStrictEqual({ value: 1 }); 25 | }); 26 | 27 | it('returns default state when storage empty and writes it to storage', () => { 28 | const { result } = renderHook(() => 29 | useStorageState(localStorage, 'key', { value: 0 }) 30 | ); 31 | 32 | const [state] = result.current; 33 | expect(state).toStrictEqual({ value: 0 }); 34 | expect(localStorage.getItem('key')).toBe('{"value":0}'); 35 | }); 36 | 37 | it('returns default state when storage empty and writes it to storage (lazy initialization)', () => { 38 | const { result } = renderHook(() => 39 | useStorageState(localStorage, 'key', () => ({ value: 0 })) 40 | ); 41 | 42 | const [state] = result.current; 43 | expect(state).toStrictEqual({ value: 0 }); 44 | expect(localStorage.getItem('key')).toBe('{"value":0}'); 45 | }); 46 | 47 | it('returns null when storage empty and no default provided', () => { 48 | const { result } = renderHook(() => useStorageState(localStorage, 'key')); 49 | 50 | const [state] = result.current; 51 | expect(state).toBeNull(); 52 | }); 53 | 54 | it('returns default state when storage reading fails', () => { 55 | mockStorageErrorOnce(localStorage, 'getItem', 'Error message'); 56 | localStorage.setItem('key', '{"value":1}'); 57 | 58 | const { result } = renderHook(() => 59 | useStorageState(localStorage, 'key', { value: 0 }) 60 | ); 61 | 62 | const { 63 | current: [state], 64 | } = result; 65 | expect(state).toStrictEqual({ value: 0 }); 66 | }); 67 | 68 | it('returns null when storage reading fails and no default provided', () => { 69 | mockStorageErrorOnce(localStorage, 'getItem', 'Error message'); 70 | localStorage.setItem('key', '{"value":1}'); 71 | 72 | const { result } = renderHook(() => useStorageState(localStorage, 'key')); 73 | 74 | const { 75 | current: [state], 76 | } = result; 77 | expect(state).toBeNull(); 78 | }); 79 | }); 80 | 81 | describe('updates', () => { 82 | it('returns new state and writes to storage', () => { 83 | const { result } = renderHook(() => 84 | useStorageState(localStorage, 'key', { value: 0 }) 85 | ); 86 | const [, setState] = result.current; 87 | act(() => setState({ value: 1 })); 88 | 89 | const [newState] = result.current; 90 | expect(newState).toStrictEqual({ value: 1 }); 91 | expect(localStorage.getItem('key')).toBe('{"value":1}'); 92 | }); 93 | 94 | it('returns new state and write error when storage writing fails once', async () => { 95 | mockStorageErrorOnce(localStorage, 'setItem', 'Error message'); 96 | 97 | const { result, waitForNextUpdate } = renderHook(() => 98 | useStorageState(localStorage, 'key', { value: 0 }) 99 | ); 100 | const [, setState] = result.current; 101 | act(() => setState({ value: 1 })); 102 | await waitForNextUpdate(); 103 | 104 | const [newState, , writeError] = result.current; 105 | expect(newState).toStrictEqual({ value: 1 }); 106 | expect(writeError).toEqual(Error('Error message')); 107 | }); 108 | 109 | it('returns new state and previous write error when storage writing fails multiple times', async () => { 110 | mockStorageError(localStorage, 'setItem', 'Error message'); 111 | 112 | const { result, waitForNextUpdate } = renderHook(() => 113 | useStorageState(localStorage, 'key', { value: 0 }) 114 | ); 115 | const [, setState] = result.current; 116 | act(() => setState({ value: 1 })); 117 | await waitForNextUpdate(); 118 | 119 | const [, newSetState, writeError] = result.current; 120 | expect(writeError).toEqual(Error('Error message')); 121 | 122 | act(() => newSetState({ value: 2 })); 123 | await waitForNextUpdate(); 124 | 125 | const [, , newWriteError] = result.current; 126 | expect(newWriteError).toEqual(Error('Error message')); 127 | }); 128 | 129 | it('returns new state and no previous write error when storage writing works after failing', async () => { 130 | mockStorageErrorOnce(localStorage, 'setItem', 'Error message'); 131 | 132 | const { result, waitForNextUpdate } = renderHook(() => 133 | useStorageState(localStorage, 'key', { value: 0 }) 134 | ); 135 | const [, setState] = result.current; 136 | act(() => setState({ value: 1 })); 137 | await waitForNextUpdate(); 138 | 139 | const [, newSetState, writeError] = result.current; 140 | expect(writeError).toEqual(Error('Error message')); 141 | 142 | act(() => newSetState({ value: 2 })); 143 | 144 | const [, , newWriteError] = result.current; 145 | expect(newWriteError).toBeUndefined(); 146 | }); 147 | 148 | it('returns same state when default state changes', () => { 149 | localStorage.setItem('key', '{"value":1}'); 150 | 151 | const { result, rerender } = renderHook( 152 | defaultState => useStorageState(localStorage, 'key', defaultState), 153 | { initialProps: { value: 0 } } 154 | ); 155 | rerender({ value: 2 }); 156 | 157 | const [newState] = result.current; 158 | expect(newState).toStrictEqual({ value: 1 }); 159 | }); 160 | 161 | it('returns same state when storage empty and default state changes', () => { 162 | const { result, rerender } = renderHook( 163 | defaultState => useStorageState(localStorage, 'key', defaultState), 164 | { initialProps: { value: 0 } } 165 | ); 166 | rerender({ value: 1 }); 167 | 168 | const [newState] = result.current; 169 | expect(newState).toStrictEqual({ value: 0 }); 170 | }); 171 | 172 | it('returns null and removes key from storage when null provided', () => { 173 | localStorage.setItem('key', '{"value":1}'); 174 | 175 | const { result } = renderHook(() => 176 | useStorageState<{ value: number } | null>(localStorage, 'key', { 177 | value: 0, 178 | }) 179 | ); 180 | const [, setState] = result.current; 181 | act(() => setState(null)); 182 | 183 | const [newState] = result.current; 184 | expect(newState).toBeNull(); 185 | expect(localStorage.getItem('key')).toBeNull(); 186 | }); 187 | 188 | it('returns new state when storage event fired for key', () => { 189 | const { result } = renderHook(() => 190 | useStorageState(localStorage, 'key', 0) 191 | ); 192 | 193 | act(() => fireStorageEvent('key', '{"value":1}')); 194 | 195 | const [newState] = result.current; 196 | expect(newState).toStrictEqual({ value: 1 }); 197 | }); 198 | 199 | it('returns same state when storage event fired for key and storage empty', () => { 200 | const { result } = renderHook(() => 201 | useStorageState(localStorage, 'key', { value: 0 }) 202 | ); 203 | 204 | act(() => fireStorageEvent('key', null)); 205 | 206 | const [newState] = result.current; 207 | expect(newState).toStrictEqual({ value: 0 }); 208 | }); 209 | 210 | it('returns same state when storage event fired for other key', () => { 211 | const { result } = renderHook(() => 212 | useStorageState(localStorage, 'key', { value: 0 }) 213 | ); 214 | 215 | act(() => { 216 | fireStorageEvent('other-key', '{"value":1}'); 217 | }); 218 | 219 | const [newState] = result.current; 220 | expect(newState).toStrictEqual({ value: 0 }); 221 | }); 222 | }); 223 | 224 | describe('resetting', () => { 225 | it('returns new storage value when key changes and storage value available', () => { 226 | localStorage.setItem('new-key', '{"value":1}'); 227 | 228 | const { result, rerender } = renderHook( 229 | key => useStorageState(localStorage, key, { value: 0 }), 230 | { 231 | initialProps: 'key', 232 | } 233 | ); 234 | rerender('new-key'); 235 | 236 | const [newState] = result.current; 237 | expect(newState).toStrictEqual({ value: 1 }); 238 | }); 239 | 240 | it('returns default state when key changes and storage empty', () => { 241 | localStorage.setItem('key', '1'); 242 | 243 | const { result, rerender } = renderHook( 244 | key => useStorageState(localStorage, key, { value: 0 }), 245 | { 246 | initialProps: 'key', 247 | } 248 | ); 249 | rerender('new-key'); 250 | 251 | const [newState] = result.current; 252 | expect(newState).toStrictEqual({ value: 0 }); 253 | }); 254 | 255 | it('returns null when key changes, storage empty and no default provided', () => { 256 | localStorage.setItem('key', '{"value":1}'); 257 | 258 | const { result, rerender } = renderHook( 259 | key => useStorageState(localStorage, key), 260 | { 261 | initialProps: 'key', 262 | } 263 | ); 264 | rerender('new-key'); 265 | 266 | const [newState] = result.current; 267 | expect(newState).toBeNull(); 268 | }); 269 | 270 | it('returns no previous write error when key changes', async () => { 271 | mockStorageErrorOnce(localStorage, 'setItem', 'Error message'); 272 | 273 | const { result, rerender, waitForNextUpdate } = renderHook( 274 | key => useStorageState(localStorage, key, { value: 0 }), 275 | { initialProps: 'key' } 276 | ); 277 | const [, setState] = result.current; 278 | act(() => setState({ value: 1 })); 279 | await waitForNextUpdate(); 280 | 281 | const [, , writeError] = result.current; 282 | expect(writeError).toEqual(Error('Error message')); 283 | 284 | rerender('new-key'); 285 | 286 | const [, , newWriteError] = result.current; 287 | expect(newWriteError).toBeUndefined(); 288 | }); 289 | 290 | it('writes to new key when key changes', () => { 291 | localStorage.setItem('key', '{"value":1}'); 292 | localStorage.setItem('new-key', '{"value":2}'); 293 | 294 | const { result, rerender } = renderHook( 295 | key => useStorageState(localStorage, key, { value: 0 }), 296 | { 297 | initialProps: 'key', 298 | } 299 | ); 300 | rerender('new-key'); 301 | const [, setState] = result.current; 302 | act(() => setState({ value: 3 })); 303 | 304 | expect(localStorage.getItem('key')).toBe('{"value":1}'); 305 | expect(localStorage.getItem('new-key')).toBe('{"value":3}'); 306 | }); 307 | }); 308 | -------------------------------------------------------------------------------- /src/tests/utils.ts: -------------------------------------------------------------------------------- 1 | function getStorageSpy(storage: Storage, method: 'getItem' | 'setItem') { 2 | // Cannot mock Storage methods directly: https://github.com/facebook/jest/issues/6798 3 | return jest.spyOn(Object.getPrototypeOf(storage), method); 4 | } 5 | 6 | export function mockStorageError( 7 | storage: Storage, 8 | method: 'getItem' | 'setItem', 9 | errorMessage: string 10 | ) { 11 | getStorageSpy(storage, method).mockImplementation(() => { 12 | throw new Error(errorMessage); 13 | }); 14 | } 15 | 16 | export function mockStorageErrorOnce( 17 | storage: Storage, 18 | method: 'getItem' | 'setItem', 19 | errorMessage: string 20 | ) { 21 | getStorageSpy(storage, method).mockImplementationOnce(() => { 22 | throw new Error(errorMessage); 23 | }); 24 | } 25 | 26 | export function fireStorageEvent(key: string, value: string | null) { 27 | window.dispatchEvent(new StorageEvent('storage', { key, newValue: value })); 28 | } 29 | 30 | export const storageLikeObject = { 31 | getItem: (key: string) => null, 32 | /* eslint-disable @typescript-eslint/no-empty-function */ 33 | setItem: (key: string, value: string) => {}, 34 | removeItem: (key: string) => {}, 35 | /* eslint-enable @typescript-eslint/no-empty-function */ 36 | }; 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["es2016", "dom"], 6 | "jsx": "react", 7 | "declaration": true, 8 | "sourceMap": true, 9 | "outDir": "./dist/", 10 | "strict": true, 11 | "moduleResolution": "node", 12 | "esModuleInterop": true 13 | }, 14 | "include": ["src/index.ts"] 15 | } 16 | --------------------------------------------------------------------------------