├── .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 | [![CI](https://img.shields.io/github/workflow/status/dai-shi/use-reducer-async/CI)](https://github.com/dai-shi/use-reducer-async/actions?query=workflow%3ACI) 4 | [![npm](https://img.shields.io/npm/v/use-reducer-async)](https://www.npmjs.com/package/use-reducer-async) 5 | [![size](https://img.shields.io/bundlephobia/minzip/use-reducer-async)](https://bundlephobia.com/result?p=use-reducer-async) 6 | [![discord](https://img.shields.io/discord/627656437971288081)](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 |
14 | 15 | 16 |
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 | --------------------------------------------------------------------------------