├── .codesandbox
└── ci.json
├── .eslintrc.json
├── .github
└── workflows
│ ├── cd.yml
│ └── ci.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── __tests__
├── 01_basic_spec.tsx
└── __snapshots__
│ └── 01_basic_spec.tsx.snap
├── examples
├── 01_minimal
│ ├── package.json
│ ├── public
│ │ └── index.html
│ └── src
│ │ └── index.js
├── 02_typescript
│ ├── package.json
│ ├── public
│ │ └── index.html
│ └── src
│ │ ├── App.tsx
│ │ ├── Person.tsx
│ │ └── index.ts
├── 03_getstate
│ ├── package.json
│ ├── public
│ │ └── index.html
│ └── src
│ │ ├── App.tsx
│ │ ├── DelayedCounter.tsx
│ │ └── index.ts
└── 04_abort
│ ├── package.json
│ ├── public
│ └── index.html
│ └── src
│ ├── App.tsx
│ ├── Person.tsx
│ ├── Sleep.tsx
│ └── index.ts
├── package.json
├── src
└── index.ts
├── tsconfig.json
├── webpack.config.js
└── yarn.lock
/.codesandbox/ci.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": "compile",
3 | "sandboxes": ["new", "react-typescript-react-ts"],
4 | "node": "12"
5 | }
6 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "plugins": [
4 | "@typescript-eslint",
5 | "react-hooks"
6 | ],
7 | "extends": [
8 | "plugin:@typescript-eslint/recommended",
9 | "airbnb"
10 | ],
11 | "env": {
12 | "browser": true
13 | },
14 | "settings": {
15 | "import/resolver": {
16 | "node": {
17 | "extensions": [".js", ".ts", ".tsx"]
18 | }
19 | }
20 | },
21 | "rules": {
22 | "react-hooks/rules-of-hooks": "error",
23 | "react-hooks/exhaustive-deps": ["error", { "additionalHooks": "useIsomorphicLayoutEffect" }],
24 | "@typescript-eslint/explicit-function-return-type": "off",
25 | "@typescript-eslint/explicit-module-boundary-types": "off",
26 | "react/jsx-filename-extension": ["error", { "extensions": [".js", ".tsx"] }],
27 | "react/prop-types": "off",
28 | "react/jsx-one-expression-per-line": "off",
29 | "import/extensions": ["error", "never"],
30 | "import/no-unresolved": ["error", { "ignore": ["use-reducer-async"] }],
31 | "@typescript-eslint/no-explicit-any": "off",
32 | "no-use-before-define": "off",
33 | "no-unused-vars": "off",
34 | "no-redeclare": "off",
35 | "react/function-component-definition": ["error", { "namedComponents": "arrow-function" }]
36 | },
37 | "overrides": [{
38 | "files": ["__tests__/**/*"],
39 | "env": {
40 | "jest": true
41 | },
42 | "rules": {
43 | "import/no-extraneous-dependencies": ["error", { "devDependencies": true }]
44 | }
45 | }]
46 | }
47 |
--------------------------------------------------------------------------------
/.github/workflows/cd.yml:
--------------------------------------------------------------------------------
1 | name: CD
2 |
3 | on:
4 | push:
5 | tags:
6 | - v*
7 |
8 | jobs:
9 | publish:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v2
13 |
14 | - name: Setup Node
15 | uses: actions/setup-node@v1
16 | with:
17 | node-version: '12.x'
18 | registry-url: 'https://registry.npmjs.org'
19 |
20 | - name: Get yarn cache
21 | id: yarn-cache
22 | run: echo "::set-output name=dir::$(yarn cache dir)"
23 |
24 | - name: Cache dependencies
25 | uses: actions/cache@v1
26 | with:
27 | path: ${{ steps.yarn-cache.outputs.dir }}
28 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
29 | restore-keys: |
30 | ${{ runner.os }}-yarn-
31 |
32 | - name: Install dependencies
33 | run: yarn install
34 |
35 | - name: Test
36 | run: yarn test
37 |
38 | - name: Compile
39 | run: yarn run compile
40 |
41 | - name: Publish
42 | run: npm publish
43 | env:
44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
45 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 |
13 | - name: Setup Node
14 | uses: actions/setup-node@v1
15 | with:
16 | node-version: '12.x'
17 |
18 | - name: Get yarn cache
19 | id: yarn-cache
20 | run: echo "::set-output name=dir::$(yarn cache dir)"
21 |
22 | - name: Cache dependencies
23 | uses: actions/cache@v1
24 | with:
25 | path: ${{ steps.yarn-cache.outputs.dir }}
26 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
27 | restore-keys: |
28 | ${{ runner.os }}-yarn-
29 |
30 | - name: Install dependencies
31 | run: yarn install
32 |
33 | - name: Test
34 | run: yarn test
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | *.swp
3 | node_modules
4 | /dist
5 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## [Unreleased]
4 |
5 | ## [2.1.1] - 2022-05-24
6 | ### Changed
7 | - fix build for UMD
8 | - fix: useAbortSignal to work with React 18 #35
9 |
10 | ## [2.1.0] - 2021-12-23
11 | ### Changed
12 | - feat: support recursive dispatching async actions #26
13 |
14 | ## [2.0.2] - 2021-11-04
15 | ### Changed
16 | - Modern build
17 | - Fix package.json properly for ESM
18 |
19 | ## [2.0.1] - 2020-04-04
20 | ### Changed
21 | - Avoid initializing AbortController repeatedly
22 |
23 | ## [2.0.0] - 2020-02-29
24 | ### Changed
25 | - Improve isClient detection for useIsomorphicLayoutEffect
26 | - Support abortability
27 | - New API for async action handlers [BREAKING CHANGE]
28 |
29 | ## [1.0.0] - 2020-02-26
30 | ### Changed
31 | - Update only README
32 |
33 | ## [0.6.0] - 2020-01-18
34 | ### Changed
35 | - Make ExportAction type omittable
36 |
37 | ## [0.5.0] - 2019-11-01
38 | ### Changed
39 | - Support getState
40 | - Improve typings (breaking change)
41 |
42 | ## [0.4.0] - 2019-10-29
43 | ### Changed
44 | - Improve and simplity typing
45 |
46 | ## [0.3.0] - 2019-10-24
47 | ### Changed
48 | - Better typing and better type inference in examples
49 |
50 | ## [0.2.0] - 2019-10-22
51 | ### Changed
52 | - Better typing and checking type property existence
53 |
54 | ## [0.1.0] - 2019-10-22
55 | ### Added
56 | - Initial release
57 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019-2022 Daishi Kato
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # use-reducer-async
2 |
3 | [](https://github.com/dai-shi/use-reducer-async/actions?query=workflow%3ACI)
4 | [](https://www.npmjs.com/package/use-reducer-async)
5 | [](https://bundlephobia.com/result?p=use-reducer-async)
6 | [](https://discord.gg/MrQdmzd)
7 |
8 | React useReducer with async actions
9 |
10 | ## Introduction
11 |
12 | React useReducer doesn't support async actions natively.
13 | Unlike Redux, there's no middleware interface, but hooks are composable.
14 |
15 | This is a tiny library to extend useReducer's dispatch
16 | so that dispatching async actions invoke async functions.
17 |
18 | ## Install
19 |
20 | ```bash
21 | npm install use-reducer-async
22 | ```
23 |
24 | ## Usage
25 |
26 | ```javascript
27 |
28 | import { useReducerAsync } from "use-reducer-async";
29 |
30 | const initialState = {
31 | sleeping: false,
32 | };
33 |
34 | const reducer = (state, action) => {
35 | switch (action.type) {
36 | case 'START_SLEEP': return { ...state, sleeping: true };
37 | case 'END_SLEEP': return { ...state, sleeping: false };
38 | default: throw new Error('no such action type');
39 | }
40 | };
41 |
42 | const asyncActionHandlers = {
43 | SLEEP: ({ dispatch }) => async (action) => {
44 | dispatch({ type: 'START_SLEEP' });
45 | await new Promise(r => setTimeout(r, action.ms));
46 | dispatch({ type: 'END_SLEEP' });
47 | },
48 | };
49 |
50 | const Component = () => {
51 | const [state, dispatch] = useReducerAsync(reducer, initialState, asyncActionHandlers);
52 | return (
53 |
54 | {state.sleeping ? 'Sleeping' : 'Idle'}
55 |
56 |
57 | );
58 | };
59 | ```
60 |
61 | ### Notes for abortability
62 |
63 | All async action handlers receive `signal` in the argument.
64 | Refer [`examples/04_abort/src`](./examples/04\_abort/src) for the usage.
65 |
66 | Note: The implementation depends on [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) in the DOM spec.
67 | If you are using an environment that doesn't have AbortController (for example IE11), you need a polyfill:
68 | [1](https://github.com/mo/abortcontroller-polyfill)
69 | [2](https://github.com/mysticatea/abort-controller)
70 |
71 | ## API
72 |
73 |
74 |
75 | ### useReducerAsync
76 |
77 | useReducer with async actions
78 |
79 | #### Parameters
80 |
81 | * `reducer` **R**
82 | * `initialState` **ReducerState\**
83 | * `asyncActionHandlers` **AsyncActionHandlers\**
84 |
85 | #### Examples
86 |
87 | ```javascript
88 | import { useReducerAsync } from 'use-reducer-async';
89 |
90 | const asyncActionHandlers = {
91 | SLEEP: ({ dispatch, getState, signal }) => async (action) => {
92 | dispatch({ type: 'START_SLEEP' });
93 | await new Promise(r => setTimeout(r, action.ms));
94 | dispatch({ type: 'END_SLEEP' });
95 | },
96 | FETCH: ({ dispatch, getState, signal }) => async (action) => {
97 | dispatch({ type: 'START_FETCH' });
98 | try {
99 | const response = await fetch(action.url);
100 | const data = await response.json();
101 | dispatch({ type: 'FINISH_FETCH', data });
102 | } catch (error) {
103 | dispatch({ type: 'ERROR_FETCH', error });
104 | }
105 | },
106 | };
107 | const [state, dispatch] = useReducerAsync(reducer, initialState, asyncActionHandlers);
108 | ```
109 |
110 | Returns **\[ReducerState\, Dispatch\]**
111 |
112 | ## Examples
113 |
114 | The [examples](examples) folder contains working examples.
115 | You can run one of them with
116 |
117 | ```bash
118 | PORT=8080 npm run examples:01_minimal
119 | ```
120 |
121 | and open in your web browser.
122 |
123 | You can also try them in codesandbox.io:
124 | [01](https://codesandbox.io/s/github/dai-shi/use-reducer-async/tree/main/examples/01\_minimal)
125 | [02](https://codesandbox.io/s/github/dai-shi/use-reducer-async/tree/main/examples/02\_typescript)
126 | [03](https://codesandbox.io/s/github/dai-shi/use-reducer-async/tree/main/examples/03\_getstate)
127 | [04](https://codesandbox.io/s/github/dai-shi/use-reducer-async/tree/main/examples/04\_abort)
128 |
129 | ## Blogs
130 |
131 | * [How to Handle Async Actions for Global State With React Hooks and Context](https://blog.axlight.com/posts/how-to-handle-async-actions-for-global-state-with-react-hooks-and-context/)
132 |
--------------------------------------------------------------------------------
/__tests__/01_basic_spec.tsx:
--------------------------------------------------------------------------------
1 | import React, { StrictMode, Reducer } from 'react';
2 |
3 | import { render, fireEvent, cleanup } from '@testing-library/react';
4 |
5 | import { useReducerAsync, AsyncActionHandlers } from '../src/index';
6 |
7 | describe('basic spec', () => {
8 | afterEach(cleanup);
9 |
10 | it('sleep', async () => {
11 | type State = {
12 | sleeping: boolean;
13 | };
14 | const initialState: State = {
15 | sleeping: false,
16 | };
17 | type Action =
18 | | { type: 'START_SLEEP' }
19 | | { type: 'END_SLEEP' };
20 | const reducer: Reducer = (state, action) => {
21 | switch (action.type) {
22 | case 'START_SLEEP': return { ...state, sleeping: true };
23 | case 'END_SLEEP': return { ...state, sleeping: false };
24 | default: throw new Error('no such action type');
25 | }
26 | };
27 | type AsyncAction = { type: 'SLEEP'; ms: number };
28 | const asyncActionHandlers: AsyncActionHandlers, AsyncAction> = {
29 | SLEEP: ({ dispatch }) => async (action) => {
30 | dispatch({ type: 'START_SLEEP' });
31 | await new Promise((r) => {
32 | setTimeout(r, action.ms);
33 | });
34 | dispatch({ type: 'END_SLEEP' });
35 | },
36 | };
37 | const Component = () => {
38 | const [state, dispatch] = useReducerAsync<
39 | Reducer,
40 | AsyncAction,
41 | AsyncAction | Action
42 | >(
43 | reducer,
44 | initialState,
45 | asyncActionHandlers,
46 | );
47 | return (
48 |
49 | {state.sleeping ? 'Sleeping' : 'Idle'}
50 |
51 |
52 | );
53 | };
54 | const App = () => (
55 |
56 |
57 |
58 | );
59 | const { getAllByText, findAllByText, container } = render();
60 | expect(container).toMatchSnapshot();
61 | fireEvent.click(getAllByText('Click')[0] as Element);
62 | expect(container).toMatchSnapshot();
63 | await findAllByText('Idle');
64 | expect(container).toMatchSnapshot();
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/__tests__/__snapshots__/01_basic_spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`basic spec sleep 1`] = `
4 |
5 |
6 |
7 | Idle
8 |
9 |
14 |
15 |
16 | `;
17 |
18 | exports[`basic spec sleep 2`] = `
19 |
20 |
21 |
22 | Sleeping
23 |
24 |
29 |
30 |
31 | `;
32 |
33 | exports[`basic spec sleep 3`] = `
34 |
35 |
36 |
37 | Idle
38 |
39 |
44 |
45 |
46 | `;
47 |
--------------------------------------------------------------------------------
/examples/01_minimal/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-reducer-async-example",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "react": "experimental",
7 | "react-dom": "experimental",
8 | "use-reducer-async": "latest",
9 | "react-scripts": "latest"
10 | },
11 | "scripts": {
12 | "start": "react-scripts start",
13 | "build": "react-scripts build",
14 | "test": "react-scripts test",
15 | "eject": "react-scripts eject"
16 | },
17 | "browserslist": [
18 | ">0.2%",
19 | "not dead",
20 | "not ie <= 11",
21 | "not op_mini all"
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/examples/01_minimal/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | use-reducer-async example
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/examples/01_minimal/src/index.js:
--------------------------------------------------------------------------------
1 | import React, { StrictMode } from 'react';
2 | import { createRoot } from 'react-dom/client';
3 |
4 | import { useReducerAsync } from 'use-reducer-async';
5 |
6 | const initialState = {
7 | sleeping: false,
8 | };
9 |
10 | const reducer = (state, action) => {
11 | switch (action.type) {
12 | case 'START_SLEEP': return { ...state, sleeping: true };
13 | case 'END_SLEEP': return { ...state, sleeping: false };
14 | default: throw new Error('no such action type');
15 | }
16 | };
17 |
18 | const asyncActions = {
19 | SLEEP: ({ dispatch }) => async (action) => {
20 | dispatch({ type: 'START_SLEEP' });
21 | await new Promise((r) => {
22 | setTimeout(r, action.ms);
23 | });
24 | dispatch({ type: 'END_SLEEP' });
25 | },
26 | };
27 |
28 | const Component = () => {
29 | const [state, dispatch] = useReducerAsync(reducer, initialState, asyncActions);
30 | return (
31 |
32 | {state.sleeping ? 'Sleeping' : 'Idle'}
33 |
34 |
35 | );
36 | };
37 |
38 | const App = () => (
39 |
40 |
41 |
42 | );
43 |
44 | createRoot(document.getElementById('app')).render();
45 |
--------------------------------------------------------------------------------
/examples/02_typescript/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-reducer-async-example",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@types/react": "latest",
7 | "@types/react-dom": "latest",
8 | "react": "experimental",
9 | "react-dom": "experimental",
10 | "use-reducer-async": "latest",
11 | "react-scripts": "latest",
12 | "typescript": "latest"
13 | },
14 | "scripts": {
15 | "start": "react-scripts start",
16 | "build": "react-scripts build",
17 | "test": "react-scripts test",
18 | "eject": "react-scripts eject"
19 | },
20 | "browserslist": [
21 | ">0.2%",
22 | "not dead",
23 | "not ie <= 11",
24 | "not op_mini all"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/02_typescript/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | use-reducer-async example
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/examples/02_typescript/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { StrictMode } from 'react';
2 |
3 | import Person from './Person';
4 |
5 | const App = () => (
6 |
7 |
8 |
9 | );
10 |
11 | export default App;
12 |
--------------------------------------------------------------------------------
/examples/02_typescript/src/Person.tsx:
--------------------------------------------------------------------------------
1 | import React, { Reducer } from 'react';
2 |
3 | import { useReducerAsync, AsyncActionHandlers } from 'use-reducer-async';
4 |
5 | type State = {
6 | firstName: string;
7 | loading: boolean;
8 | count: number;
9 | };
10 |
11 | const initialState: State = {
12 | firstName: '',
13 | loading: false,
14 | count: 0,
15 | };
16 |
17 | type InnerAction =
18 | | { type: 'START_FETCH' }
19 | | { type: 'FINISH_FETCH'; firstName: string }
20 | | { type: 'ERROR_FETCH' };
21 |
22 | type OuterAction = { type: 'INCREMENT' };
23 |
24 | type Action = InnerAction | OuterAction;
25 |
26 | const reducer: Reducer = (state, action) => {
27 | switch (action.type) {
28 | case 'START_FETCH':
29 | return {
30 | ...state,
31 | loading: true,
32 | };
33 | case 'FINISH_FETCH':
34 | return {
35 | ...state,
36 | loading: false,
37 | firstName: action.firstName,
38 | };
39 | case 'ERROR_FETCH':
40 | return {
41 | ...state,
42 | loading: false,
43 | };
44 | case 'INCREMENT':
45 | return {
46 | ...state,
47 | count: state.count + 1,
48 | };
49 | default:
50 | throw new Error('unknown action type');
51 | }
52 | };
53 |
54 | type AsyncAction = { type: 'FETCH_PERSON'; id: number }
55 |
56 | const asyncActionHandlers: AsyncActionHandlers, AsyncAction> = {
57 | FETCH_PERSON: ({ dispatch }) => async (action) => {
58 | dispatch({ type: 'START_FETCH' });
59 | try {
60 | const response = await fetch(`https://reqres.in/api/users/${action.id}?delay=1`);
61 | const data = await response.json();
62 | const firstName = data.data.first_name;
63 | if (typeof firstName !== 'string') throw new Error();
64 | dispatch({ type: 'FINISH_FETCH', firstName });
65 | } catch (e) {
66 | dispatch({ type: 'ERROR_FETCH' });
67 | }
68 | },
69 | };
70 |
71 | const Person = () => {
72 | const [state, dispatch] = useReducerAsync<
73 | Reducer,
74 | AsyncAction,
75 | AsyncAction | OuterAction
76 | >(
77 | reducer,
78 | initialState,
79 | asyncActionHandlers,
80 | );
81 | return (
82 |
83 | First Name:
84 | {state.firstName}
85 |
{state.loading && 'Loading...'}
86 |
92 |
93 | );
94 | };
95 |
96 | export default Person;
97 |
--------------------------------------------------------------------------------
/examples/02_typescript/src/index.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 |
4 | import App from './App';
5 |
6 | const ele = document.getElementById('app');
7 | if (!ele) throw new Error('no app');
8 | createRoot(ele).render(React.createElement(App));
9 |
--------------------------------------------------------------------------------
/examples/03_getstate/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-reducer-async-example",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@types/react": "latest",
7 | "@types/react-dom": "latest",
8 | "react": "experimental",
9 | "react-dom": "experimental",
10 | "use-reducer-async": "latest",
11 | "react-scripts": "latest",
12 | "typescript": "latest"
13 | },
14 | "scripts": {
15 | "start": "react-scripts start",
16 | "build": "react-scripts build",
17 | "test": "react-scripts test",
18 | "eject": "react-scripts eject"
19 | },
20 | "browserslist": [
21 | ">0.2%",
22 | "not dead",
23 | "not ie <= 11",
24 | "not op_mini all"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/03_getstate/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | use-reducer-async example
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/examples/03_getstate/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { StrictMode } from 'react';
2 |
3 | import DelayedCounter from './DelayedCounter';
4 |
5 | const App = () => (
6 |
7 |
8 |
9 | );
10 |
11 | export default App;
12 |
--------------------------------------------------------------------------------
/examples/03_getstate/src/DelayedCounter.tsx:
--------------------------------------------------------------------------------
1 | import React, { Reducer } from 'react';
2 |
3 | import { useReducerAsync, AsyncActionHandlers } from 'use-reducer-async';
4 |
5 | type State = {
6 | count1: number;
7 | count2: number;
8 | };
9 |
10 | const initialState: State = {
11 | count1: 0,
12 | count2: 0,
13 | };
14 |
15 | type InnerAction = { type: 'SET_COUNT2'; count2: number };
16 |
17 | type OuterAction = { type: 'INCREMENT1' };
18 |
19 | type Action = InnerAction | OuterAction;
20 |
21 | const reducer: Reducer = (state, action) => {
22 | switch (action.type) {
23 | case 'INCREMENT1':
24 | return {
25 | ...state,
26 | count1: state.count1 + 1,
27 | };
28 | case 'SET_COUNT2':
29 | return {
30 | ...state,
31 | count2: action.count2,
32 | };
33 | default:
34 | throw new Error('unknown action type');
35 | }
36 | };
37 |
38 | type AsyncAction = { type: 'DELAYED_INCREMENT2' }
39 |
40 | const asyncActionHandlers: AsyncActionHandlers, AsyncAction> = {
41 | DELAYED_INCREMENT2: ({ dispatch, getState }) => async () => {
42 | await new Promise((r) => {
43 | setTimeout(r, 1000);
44 | });
45 | dispatch({ type: 'SET_COUNT2', count2: getState().count2 + 1 });
46 | },
47 | };
48 |
49 | const DelayedCounter = () => {
50 | const [state, dispatch] = useReducerAsync<
51 | Reducer,
52 | AsyncAction,
53 | AsyncAction | OuterAction
54 | >(
55 | reducer,
56 | initialState,
57 | asyncActionHandlers,
58 | );
59 | return (
60 |
61 |
Normal Counter
62 | Count1: {state.count1}
63 |
69 | Delayed Counter
70 | Count2: {state.count2}
71 |
77 |
78 | );
79 | };
80 |
81 | export default DelayedCounter;
82 |
--------------------------------------------------------------------------------
/examples/03_getstate/src/index.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 |
4 | import App from './App';
5 |
6 | const ele = document.getElementById('app');
7 | if (!ele) throw new Error('no app');
8 | createRoot(ele).render(React.createElement(App));
9 |
--------------------------------------------------------------------------------
/examples/04_abort/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-reducer-async-example",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@types/react": "latest",
7 | "@types/react-dom": "latest",
8 | "react": "experimental",
9 | "react-dom": "experimental",
10 | "use-reducer-async": "latest",
11 | "react-scripts": "latest",
12 | "typescript": "latest"
13 | },
14 | "scripts": {
15 | "start": "react-scripts start",
16 | "build": "react-scripts build",
17 | "test": "react-scripts test",
18 | "eject": "react-scripts eject"
19 | },
20 | "browserslist": [
21 | ">0.2%",
22 | "not dead",
23 | "not ie <= 11",
24 | "not op_mini all"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/04_abort/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | use-reducer-async example
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/examples/04_abort/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, { StrictMode, useReducer } from 'react';
2 |
3 | import Sleep from './Sleep';
4 | import Person from './Person';
5 |
6 | const App = () => {
7 | const [show, toggleShow] = useReducer((s) => !s, true);
8 | return (
9 |
10 |
11 |
12 | {show && (
13 |
17 | )}
18 |
19 | );
20 | };
21 |
22 | export default App;
23 |
--------------------------------------------------------------------------------
/examples/04_abort/src/Person.tsx:
--------------------------------------------------------------------------------
1 | import React, { Reducer } from 'react';
2 |
3 | import { useReducerAsync, AsyncActionHandlers } from 'use-reducer-async';
4 |
5 | type State = {
6 | firstName: string;
7 | loading: boolean;
8 | count: number;
9 | };
10 |
11 | const initialState: State = {
12 | firstName: '',
13 | loading: false,
14 | count: 0,
15 | };
16 |
17 | type InnerAction =
18 | | { type: 'START_FETCH' }
19 | | { type: 'FINISH_FETCH'; firstName: string }
20 | | { type: 'ERROR_FETCH' };
21 |
22 | type OuterAction = { type: 'INCREMENT' };
23 |
24 | type Action = InnerAction | OuterAction;
25 |
26 | const reducer: Reducer = (state, action) => {
27 | switch (action.type) {
28 | case 'START_FETCH':
29 | return {
30 | ...state,
31 | loading: true,
32 | };
33 | case 'FINISH_FETCH':
34 | return {
35 | ...state,
36 | loading: false,
37 | firstName: action.firstName,
38 | };
39 | case 'ERROR_FETCH':
40 | return {
41 | ...state,
42 | loading: false,
43 | };
44 | case 'INCREMENT':
45 | return {
46 | ...state,
47 | count: state.count + 1,
48 | };
49 | default:
50 | throw new Error('unknown action type');
51 | }
52 | };
53 |
54 | type AsyncAction = { type: 'FETCH_PERSON'; id: number }
55 |
56 | const asyncActionHandlers: AsyncActionHandlers, AsyncAction> = {
57 | FETCH_PERSON: ({ dispatch, signal }) => async (action) => {
58 | dispatch({ type: 'START_FETCH' });
59 | try {
60 | const response = await fetch(`https://reqres.in/api/users/${action.id}?delay=1`, { signal });
61 | const data = await response.json();
62 | const firstName = data.data.first_name;
63 | if (typeof firstName !== 'string') throw new Error();
64 | if (!signal.aborted) dispatch({ type: 'FINISH_FETCH', firstName });
65 | } catch (e) {
66 | if (!signal.aborted) dispatch({ type: 'ERROR_FETCH' });
67 | }
68 | },
69 | };
70 |
71 | const Person = () => {
72 | const [state, dispatch] = useReducerAsync<
73 | Reducer,
74 | AsyncAction,
75 | AsyncAction | OuterAction
76 | >(
77 | reducer,
78 | initialState,
79 | asyncActionHandlers,
80 | );
81 | return (
82 |
83 | First Name:
84 | {state.firstName}
85 |
{state.loading && 'Loading...'}
86 |
92 |
93 | );
94 | };
95 |
96 | export default Person;
97 |
--------------------------------------------------------------------------------
/examples/04_abort/src/Sleep.tsx:
--------------------------------------------------------------------------------
1 | import React, { Reducer } from 'react';
2 |
3 | import { useReducerAsync, AsyncActionHandlers } from 'use-reducer-async';
4 |
5 | type State = {
6 | sleeping: boolean;
7 | };
8 |
9 | const initialState: State = {
10 | sleeping: false,
11 | };
12 |
13 | type Action =
14 | | { type: 'START_SLEEP' }
15 | | { type: 'END_SLEEP' };
16 |
17 | const reducer: Reducer = (state, action) => {
18 | switch (action.type) {
19 | case 'START_SLEEP': return { ...state, sleeping: true };
20 | case 'END_SLEEP': return { ...state, sleeping: false };
21 | default: throw new Error('no such action type');
22 | }
23 | };
24 |
25 | type AsyncAction = { type: 'SLEEP'; ms: number }
26 |
27 | const asyncActionHandlers: AsyncActionHandlers, AsyncAction> = {
28 | SLEEP: ({ dispatch, signal }) => async (action) => {
29 | dispatch({ type: 'START_SLEEP' });
30 | const timer = setTimeout(() => {
31 | dispatch({ type: 'END_SLEEP' });
32 | }, action.ms);
33 | signal.addEventListener('abort', () => {
34 | clearTimeout(timer);
35 | });
36 | },
37 | };
38 |
39 | const Sleep: React.FC = () => {
40 | const [state, dispatch] = useReducerAsync(reducer, initialState, asyncActionHandlers);
41 | return (
42 |
43 | {state.sleeping ? 'Sleeping' : 'Idle'}
44 |
45 |
46 | );
47 | };
48 |
49 | export default Sleep;
50 |
--------------------------------------------------------------------------------
/examples/04_abort/src/index.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 |
4 | import App from './App';
5 |
6 | const ele = document.getElementById('app');
7 | if (!ele) throw new Error('no app');
8 | createRoot(ele).render(React.createElement(App));
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "use-reducer-async",
3 | "description": "React useReducer with async actions",
4 | "version": "2.1.1",
5 | "author": "Daishi Kato",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/dai-shi/use-reducer-async.git"
9 | },
10 | "source": "./src/index.ts",
11 | "main": "./dist/index.umd.js",
12 | "module": "./dist/index.modern.js",
13 | "types": "./dist/src/index.d.ts",
14 | "exports": {
15 | "./package.json": "./package.json",
16 | ".": {
17 | "types": "./dist/src/index.d.ts",
18 | "module": "./dist/index.modern.js",
19 | "import": "./dist/index.modern.mjs",
20 | "default": "./dist/index.umd.js"
21 | }
22 | },
23 | "sideEffects": false,
24 | "files": [
25 | "src",
26 | "dist"
27 | ],
28 | "scripts": {
29 | "compile": "microbundle build -f modern,umd --globals react=React",
30 | "postcompile": "cp dist/index.modern.js dist/index.modern.mjs && cp dist/index.modern.js.map dist/index.modern.mjs.map",
31 | "test": "run-s eslint tsc-test jest",
32 | "eslint": "eslint --ext .js,.ts,.tsx --ignore-pattern dist .",
33 | "jest": "jest",
34 | "tsc-test": "tsc --project . --noEmit",
35 | "apidoc": "documentation readme --section API --markdown-toc false --parse-extension ts src/*.ts",
36 | "examples:01_minimal": "DIR=01_minimal EXT=js webpack serve",
37 | "examples:02_typescript": "DIR=02_typescript webpack serve",
38 | "examples:03_getstate": "DIR=03_getstate webpack serve",
39 | "examples:04_abort": "DIR=04_abort webpack serve"
40 | },
41 | "jest": {
42 | "testEnvironment": "jsdom",
43 | "preset": "ts-jest/presets/js-with-ts"
44 | },
45 | "keywords": [
46 | "react",
47 | "reducer",
48 | "async"
49 | ],
50 | "license": "MIT",
51 | "dependencies": {},
52 | "devDependencies": {
53 | "@testing-library/react": "^13.2.0",
54 | "@types/jest": "^27.5.1",
55 | "@types/react": "^18.0.9",
56 | "@types/react-dom": "^18.0.5",
57 | "@typescript-eslint/eslint-plugin": "^5.26.0",
58 | "@typescript-eslint/parser": "^5.26.0",
59 | "documentation": "^13.2.5",
60 | "eslint": "^8.16.0",
61 | "eslint-config-airbnb": "^19.0.4",
62 | "eslint-plugin-import": "^2.26.0",
63 | "eslint-plugin-jsx-a11y": "^6.5.1",
64 | "eslint-plugin-react": "^7.30.0",
65 | "eslint-plugin-react-hooks": "^4.5.0",
66 | "html-webpack-plugin": "^5.5.0",
67 | "jest": "^28.1.0",
68 | "jest-environment-jsdom": "^28.1.0",
69 | "microbundle": "^0.15.0",
70 | "npm-run-all": "^4.1.5",
71 | "react": "^18.1.0",
72 | "react-dom": "^18.1.0",
73 | "ts-jest": "^28.0.3",
74 | "ts-loader": "^9.3.0",
75 | "typescript": "^4.6.4",
76 | "webpack": "^5.72.1",
77 | "webpack-cli": "^4.9.2",
78 | "webpack-dev-server": "^4.9.0"
79 | },
80 | "peerDependencies": {
81 | "react": ">=16.8.0"
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | useCallback,
3 | useEffect,
4 | useLayoutEffect,
5 | useReducer,
6 | useRef,
7 | useState,
8 | Dispatch,
9 | Reducer,
10 | ReducerState,
11 | ReducerAction,
12 | } from 'react';
13 |
14 | const isClient = (
15 | typeof window !== 'undefined'
16 | && !/ServerSideRendering/.test(window.navigator && window.navigator.userAgent)
17 | );
18 |
19 | const useIsomorphicLayoutEffect = isClient ? useLayoutEffect : useEffect;
20 |
21 | const useAbortSignal = () => {
22 | const [controller, setController] = useState(() => new AbortController());
23 | const lastController = useRef(controller);
24 | useEffect(() => {
25 | const abort = () => {
26 | lastController.current.abort();
27 | lastController.current = new AbortController();
28 | setController(lastController.current);
29 | };
30 | return abort;
31 | }, []);
32 | return controller.signal;
33 | };
34 |
35 | export type AsyncActionHandlers<
36 | R extends Reducer,
37 | AsyncAction extends { type: string }
38 | > = {
39 | [T in AsyncAction['type']]: AsyncAction extends infer A ? A extends {
40 | type: T;
41 | } ? (s: {
42 | dispatch: Dispatch>;
43 | getState: () => ReducerState;
44 | signal: AbortSignal;
45 | }) => (a: A) => Promise : never : never;
46 | };
47 |
48 | export function useReducerAsync<
49 | R extends Reducer,
50 | I,
51 | AsyncAction extends { type: string },
52 | ExportAction extends AsyncAction | ReducerAction = AsyncAction | ReducerAction
53 | >(
54 | reducer: R,
55 | initializerArg: I,
56 | initializer: (arg: I) => ReducerState,
57 | asyncActionHandlers: AsyncActionHandlers,
58 | ): [ReducerState, Dispatch];
59 |
60 | /**
61 | * useReducer with async actions
62 | * @example
63 | * import { useReducerAsync } from 'use-reducer-async';
64 | *
65 | * const asyncActionHandlers = {
66 | * SLEEP: ({ dispatch, getState, signal }) => async (action) => {
67 | * dispatch({ type: 'START_SLEEP' });
68 | * await new Promise(r => setTimeout(r, action.ms));
69 | * dispatch({ type: 'END_SLEEP' });
70 | * },
71 | * FETCH: ({ dispatch, getState, signal }) => async (action) => {
72 | * dispatch({ type: 'START_FETCH' });
73 | * try {
74 | * const response = await fetch(action.url);
75 | * const data = await response.json();
76 | * dispatch({ type: 'FINISH_FETCH', data });
77 | * } catch (error) {
78 | * dispatch({ type: 'ERROR_FETCH', error });
79 | * }
80 | * },
81 | * };
82 | * const [state, dispatch] = useReducerAsync(reducer, initialState, asyncActionHandlers);
83 | */
84 | export function useReducerAsync<
85 | R extends Reducer,
86 | AsyncAction extends { type: string },
87 | ExportAction extends AsyncAction | ReducerAction = AsyncAction | ReducerAction
88 | >(
89 | reducer: R,
90 | initialState: ReducerState,
91 | asyncActionHandlers: AsyncActionHandlers,
92 | ): [ReducerState, Dispatch];
93 |
94 | export function useReducerAsync<
95 | R extends Reducer,
96 | I,
97 | AsyncAction extends { type: string },
98 | ExportAction extends AsyncAction | ReducerAction = AsyncAction | ReducerAction
99 | >(
100 | reducer: R,
101 | initializerArg: I | ReducerState,
102 | initializer: unknown,
103 | asyncActionHandlers?: AsyncActionHandlers,
104 | ): [ReducerState, Dispatch] {
105 | const signal = useAbortSignal();
106 | const aaHandlers = (
107 | asyncActionHandlers || initializer
108 | ) as AsyncActionHandlers;
109 | const [state, dispatch] = useReducer(
110 | reducer,
111 | initializerArg as any,
112 | asyncActionHandlers && initializer as any,
113 | );
114 | const lastState = useRef(state);
115 | useIsomorphicLayoutEffect(() => {
116 | lastState.current = state;
117 | }, [state]);
118 | const getState = useCallback((() => lastState.current), []);
119 | const wrappedDispatch = useCallback((action: AsyncAction | ReducerAction) => {
120 | const { type } = (action || {}) as { type?: AsyncAction['type'] };
121 | const aaHandler = (
122 | (type && aaHandlers[type]) || null
123 | ) as (typeof action extends AsyncAction ? (s: {
124 | dispatch: Dispatch>;
125 | getState: () => ReducerState;
126 | signal: AbortSignal;
127 | }) => (a: typeof action) => Promise : null);
128 | if (aaHandler) {
129 | aaHandler({ dispatch: wrappedDispatch, getState, signal })(action as AsyncAction);
130 | } else {
131 | dispatch(action as ReducerAction);
132 | }
133 | }, [aaHandlers, getState, signal]);
134 | return [state, wrappedDispatch];
135 | }
136 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "target": "es5",
5 | "esModuleInterop": true,
6 | "module": "es2015",
7 | "moduleResolution": "node",
8 | "jsx": "react",
9 | "allowJs": true,
10 | "noUnusedLocals": true,
11 | "noUnusedParameters": true,
12 | "noUncheckedIndexedAccess": true,
13 | "exactOptionalPropertyTypes": true,
14 | "sourceMap": true,
15 | "baseUrl": ".",
16 | "paths": {
17 | "use-reducer-async": ["./src"]
18 | },
19 | "outDir": "./dist"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 |
4 | const { DIR, EXT = 'ts' } = process.env;
5 |
6 | module.exports = {
7 | mode: 'development',
8 | devtool: 'cheap-module-source-map',
9 | entry: `./examples/${DIR}/src/index.${EXT}`,
10 | output: {
11 | publicPath: '/',
12 | },
13 | plugins: [
14 | new HtmlWebpackPlugin({
15 | template: `./examples/${DIR}/public/index.html`,
16 | }),
17 | ],
18 | module: {
19 | rules: [{
20 | test: /\.[jt]sx?$/,
21 | exclude: /node_modules/,
22 | loader: 'ts-loader',
23 | options: {
24 | transpileOnly: true,
25 | },
26 | }],
27 | },
28 | resolve: {
29 | extensions: ['.js', '.jsx', '.ts', '.tsx'],
30 | alias: {
31 | 'use-reducer-async': `${__dirname}/src`,
32 | },
33 | },
34 | devServer: {
35 | port: process.env.PORT || '8080',
36 | static: {
37 | directory: `./examples/${DIR}/public`,
38 | },
39 | historyApiFallback: true,
40 | },
41 | };
42 |
--------------------------------------------------------------------------------