├── .gitignore ├── .travis.yml ├── README.md ├── example ├── .npmignore ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json └── yarn.lock ├── package.json ├── src └── index.ts ├── test └── useAsyncSetState.test.tsx ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | .rts2_cache_cjs 6 | .rts2_cache_esm 7 | .rts2_cache_umd 8 | dist 9 | 10 | .idea 11 | *.iml 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 8 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # use-async-setState 2 | 3 | [![NPM](https://img.shields.io/npm/dm/use-async-setstate.svg)](https://www.npmjs.com/package/use-async-setstate) 4 | [![Build Status](https://travis-ci.com/slorber/use-async-setState.svg?branch=master)](https://travis-ci.com/slorber/use-async-setState) 5 | 6 | 7 | `setState` returned by `useState` does not take a callback anymore, but this is sometimes convenient to chain `setState` calls one after the other. 8 | 9 | ```ts 10 | import { useAsyncSetState } from "use-async-setState"; 11 | 12 | const Comp = () => { 13 | const [state,setStateAsync] = useAsyncSetState({ counter: 0 }); 14 | 15 | const incrementAsync = async () => { 16 | await setStateAsync(s => ({...s, counter: s.counter+1})); 17 | await setStateAsync(s => ({...s, counter: s.counter+1})); 18 | await setStateAsync(s => ({...s, counter: s.counter+1})); 19 | } 20 | 21 | return
...
22 | } 23 | ``` 24 | 25 | # Reading your own writes in async closures 26 | 27 | **Warning: this applies if you use closures + `setState` in the non-functional way (`setState(newState)` instead of `setState(s => s)`)**. You'd rather always use the functional form when possible. 28 | 29 | 30 | ```ts 31 | import { useAsyncSetState, useGetState } from "use-async-setState"; 32 | 33 | const Comp = () => { 34 | const [state,setStateAsync] = useAsyncSetState({ counter: 0 }); 35 | 36 | const incrementTwiceAndSubmit = async () => { 37 | await setStateAsync({...state, counter: state.counter + 1}); 38 | await setStateAsync({...state, counter: state.counter + 1}); 39 | await setStateAsync({...state, counter: state.counter + 1}); 40 | } 41 | 42 | return
...
43 | } 44 | ``` 45 | 46 | The following won't work fine. In this case, the `state` variable has been captured by the closure. 47 | 48 | It's value is 0 and you are basically doing `await setStateAsync({...state: counter: 0 + 1});` 3 times: at the end the counter value is 1. 49 | 50 | If you need to use the non-functional `setState` (which I don't recommend for async stuff), you can use the `useGetState` helper to get access to the latest state inside your closure: 51 | 52 | ```ts 53 | import { useAsyncSetState, useGetState } from "use-async-setState"; 54 | 55 | const Comp = () => { 56 | const [state,setStateAsync] = useAsyncSetState({ counter: 0 }); 57 | const getState = useGetState(state); 58 | 59 | const incrementTwiceAndSubmit = async () => { 60 | await setStateAsync({...getState(), counter: getState().counter + 1}); 61 | await setStateAsync({...getState(), counter: getState().counter + 1}); 62 | await setStateAsync({...getState(), counter: getState().counter + 1}); 63 | } 64 | 65 | return
...
66 | } 67 | ``` 68 | 69 | Actually, it's exactly the same as when using a classes: you would read the state by using `this.state`, where `this` acts somehow as a mutable state ref to access the latest state. 70 | 71 | 72 | # License 73 | 74 | MIT 75 | 76 | # Hire a freelance expert 77 | 78 | Looking for a React/ReactNative freelance expert with more than 5 years production experience? 79 | Contact me from my [website](https://sebastienlorber.com/) or with [Twitter](https://twitter.com/sebastienlorber). 80 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { useAsyncSetState, useGetState } from '../.'; 5 | 6 | const buttonStyle = { 7 | margin: 10, 8 | padding: 10, 9 | border: 'solid', 10 | borderRadius: 5, 11 | cursor: 'pointer', 12 | }; 13 | 14 | const App = () => { 15 | const [state, setStateAsync] = useAsyncSetState({ counter: 0 }); 16 | 17 | const getState = useGetState(state); 18 | 19 | const incrementAsync = async () => { 20 | await setStateAsync({ counter: getState().counter + 1 }); 21 | }; 22 | 23 | const incrementAsyncFunctional = async () => { 24 | await setStateAsync(s => ({ ...s, counter: s.counter + 1 })); 25 | }; 26 | 27 | const repeatAsyncCall = async ( 28 | times: number, 29 | asyncCall: () => Promise 30 | ) => { 31 | for (let i = 0; i < times; i++) { 32 | await asyncCall(); 33 | } 34 | }; 35 | 36 | return ( 37 |
38 |
counter => {state.counter}
39 | 40 |
repeatAsyncCall(5, incrementAsync)} 43 | > 44 | Increment 5 times 45 |
46 | 47 |
repeatAsyncCall(5, incrementAsyncFunctional)} 50 | > 51 | Increment 5 times (functional) 52 |
53 |
54 | ); 55 | }; 56 | 57 | ReactDOM.render(, document.getElementById('root')); 58 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.8.15", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-async-setstate", 3 | "version": "0.1.1", 4 | "main": "dist/index.js", 5 | "module": "dist/use-async-setstate.esm.js", 6 | "typings": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "start": "tsdx watch", 12 | "build": "tsdx build", 13 | "test": "tsdx test --env=jsdom" 14 | }, 15 | "peerDependencies": { 16 | "react": ">=16" 17 | }, 18 | "husky": { 19 | "hooks": { 20 | "pre-commit": "pretty-quick --staged" 21 | } 22 | }, 23 | "prettier": { 24 | "printWidth": 80, 25 | "semi": true, 26 | "singleQuote": true, 27 | "trailingComma": "es5" 28 | }, 29 | "devDependencies": { 30 | "@testing-library/react-hooks": "^1.1.0", 31 | "@types/jest": "^24.0.15", 32 | "@types/react": "^16.8.22", 33 | "@types/react-dom": "^16.8.4", 34 | "husky": "^2.7.0", 35 | "prettier": "^1.18.2", 36 | "pretty-quick": "^1.11.1", 37 | "react": "^16.8.6", 38 | "react-dom": "^16.8.6", 39 | "react-test-renderer": "^16.8.6", 40 | "tsdx": "^0.7.2", 41 | "tslib": "^1.10.0", 42 | "typescript": "^3.5.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | export type SyncSetState = (stateUpdate: React.SetStateAction) => void; 4 | export type AsyncSetState = ( 5 | stateUpdate: React.SetStateAction 6 | ) => Promise; 7 | 8 | export const useAsyncSetStateFunction = ( 9 | state: S, 10 | setState: SyncSetState 11 | ): AsyncSetState => { 12 | // hold resolution function for all setState calls still unresolved 13 | const resolvers = useRef<((state: S) => void)[]>([]); 14 | 15 | // ensure resolvers are called once state updates have been applied 16 | useEffect(() => { 17 | resolvers.current.forEach(resolve => resolve(state)); 18 | resolvers.current = []; 19 | }, [state]); 20 | 21 | // make setState return a promise 22 | return useCallback( 23 | (stateUpdate: React.SetStateAction) => { 24 | return new Promise((resolve, reject) => { 25 | setState(stateBefore => { 26 | try { 27 | const stateAfter = 28 | stateUpdate instanceof Function 29 | ? stateUpdate(stateBefore) 30 | : stateUpdate; 31 | 32 | // If state does not change, we must resolve the promise because react won't re-render and effect will not resolve 33 | if (stateAfter === stateBefore) { 34 | resolve(stateAfter); 35 | } 36 | // Else we queue resolution until next state change 37 | else { 38 | resolvers.current.push(resolve); 39 | } 40 | return stateAfter; 41 | } catch (e) { 42 | reject(e); 43 | throw e; 44 | } 45 | }); 46 | }); 47 | }, 48 | [setState] 49 | ); 50 | }; 51 | 52 | export const useAsyncSetState = (initialState: S): [S, AsyncSetState] => { 53 | const [state, setState] = useState(initialState); 54 | const setStateAsync = useAsyncSetStateFunction(state, setState); 55 | return [state, setStateAsync]; 56 | }; 57 | 58 | export const useGetState = (state: S): (() => S) => { 59 | const stateRef = useRef(state); 60 | useEffect(() => { 61 | stateRef.current = state; 62 | }); 63 | return useCallback(() => stateRef.current, [stateRef]); 64 | }; 65 | -------------------------------------------------------------------------------- /test/useAsyncSetState.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks'; 2 | import { useAsyncSetState } from '../src/index'; 3 | 4 | type TestState = { 5 | counter: number; 6 | label: string; 7 | }; 8 | 9 | test('should increment counter', async () => { 10 | const { result } = renderHook(() => 11 | useAsyncSetState({ label: 'hey', counter: 0 }) 12 | ); 13 | 14 | const getState = () => result.current[0]; 15 | const setStateAsync = result.current[1]; 16 | 17 | const assertCounter = (expectedValue: number) => 18 | expect(getState().counter).toBe(expectedValue); 19 | 20 | const incrementAsync = async () => { 21 | let promise: any; 22 | act(() => { 23 | promise = setStateAsync({ 24 | ...getState(), 25 | counter: getState().counter + 1, 26 | }); 27 | }); 28 | await promise; 29 | }; 30 | 31 | assertCounter(0); 32 | await incrementAsync(); 33 | assertCounter(1); 34 | await incrementAsync(); 35 | assertCounter(2); 36 | await incrementAsync(); 37 | assertCounter(3); 38 | }); 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext","es2017"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | --------------------------------------------------------------------------------