├── .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 | [](https://www.npmjs.com/package/use-async-setstate)
4 | [](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 |
--------------------------------------------------------------------------------