Skip to content

Commit 219be24

Browse files
markeriksonphryneastgoualalex111
authored
Port ngrx/entity and add createAsyncThunk (#352)
* Initial port of `@ngrx/entity` implementation * Remove deprecated addAll method * Port `@ngrx/entity` tests * Simplify immutable entity operations by wrapping with Immer * Don't overwrite state.ids if sorting order hasn't changed * Simplify state adapter logic using Immer - Removed all references to DidMutate enum - Removed unneeded logic that only checked if state was mutated * Add `isFSA` helper to createAction * Swap state operator order to `(state, arg)` and support FSAs - Swapped arguments to state operators so that they can be reused as mostly standard Redux reducers - Added a check to handle arg as either an FSA action or a value - Swapped argument order in all test cases - Added one test to provide reading payload from FSAs works * Add a test to verify adapter usage with createSlice * Document unexpected Immer behavior with nested produce calls * Quiet lint warnings in tests I have no idea why the NgRx code is mutating the Array prototype in the first place, but let's leave that there for now. * Export Entity types as part of the public API * Add createAsyncThunk * Export createAsyncThunk as part of the public API * Ignore VS Code folder * Mark new types as alpha * 1.3.0-alpha.0 * Remove `removeMany(predicate)` overload * Rework dispatched thunk action contents - Move args inside `meta` - Include contents directly as `payload` * Update public API types * typings experiment * Update createAsyncThunk tests to match API changes * Simplify entity ID type definitions * Add a basic request ID counter to createAsyncThunk * Add nanoid * Include requestId in payload creator args, and use nanoid * Hopefully fix type definitions for empty thunk action params - Made `ActionParams = void`, which allows not declaring any args in the payload creation function without TS complaining - Found out I can switch the args order back so it's `(args, other)` * Add overloads to make EntityAdapter methods createSlice-compatible The overloads that had `TypeOrPayloadAction<T>` were resulting in a payload of `undefined` for the associated action creator when passed directly as a case reducer to `createSlice`. Adding overloads that explicitly reference `PayloadAction<T>` allows the inference to work correctly so that action payloads are detected. * Add a test that combines slices, async thunks, and entities * Remove TS 3.3 and 3.4 from the Travis setup * Update public API * 1.3.0-alpha.1 * Rework createAsyncThunk error handling behavior - Removed `finished` action - Serialized `Error` objects to a plain object - Ensured errors in `fulfilled` dispatches won't get caught wrongly - Changed to re-throw errors in case the user wants to handle them * Update public API * 1.3.0-alpha.2 * createAsyncThunk return fulfilled/rejected action instead of re-… (#361) * createAsyncThunk return fulfilled/rejected action instead of re-trowing errors * add unwrapResult helper * add .abort() to the createAsyncThunk thunkAction (#362) * add .abort() to the createAsyncThunk thunkAction * per review comments * put `abort` on the promise returned by `dispatch(asyncThunk())` * remove reference to DOMException * simplify rejected action creator * fix error==undefined case, reduce diff * update api report * Add initial `getAsyncThunk` API docs and usage guide * Rename thunk types and fields and export SerializedError * Update public API * 1.3.0-alpha.3 * Initial fix for createAsyncThunk thunk types * Rework `createAsyncThunk` types to enable specifying getState type * Fix thunk test types * Update public API * 1.3.0-alpha.4 * manually import types to prevent a bundling issue * strongly type slice name (#354) * strongly type slice name * move new generic to the end and default it to string * use ThunkApiConfig for optional type arguments (#364) * 1.3.0-alpha.5 * Modify createStateOperator to detect and handle Immer drafts * Update link styling to match main Redux site * Update blockquote styling to match main Redux site * Update side category menu styling to match main Redux site * Consolidate Update generic type and remove unused overload * Update `combinedTest` based on `createStateOperator` fixes * Add API docs for `createEntityAdapter` * guess what time it is again - it's public API time! * 1.3.0-alpha.6 * Remove accidental yarn.lock * Try fixing Netlify deploys: 1 * Update DS to fix sidebar bug * Try forcing node version * createAsyncThunk improvements (#367) * prevent dispatching of further actions if asyncThunk has been cancelled, even if the payloadCreator didn't react to the `abort` request * * add race between payloadCreator and abortedPromise * simplify createAsyncThunk * remove complicated logic where an AbortError thrown from the `payloadCreator` could influence the return value * api report * doc examples for cancellation * Remove extraneous period from abort message * Reorder cancellation content and improve wording * Fix code padding color busted from DS alpha.41 * 1.3.0-alpha.7 * Update Docusaurus and add lockfile to 43 version (#369) * Update Docusaurus and add lockfile to 43 version * Fix lockfile * Update netlify.toml to remove Yarn command * Try forcing node version Co-authored-by: Mark Erikson <[email protected]> * Try adding the compressed-size-action (#372) * Fix potential entity bugs identified by code review - Comparer should always return a number for sorting - Fixed missed state arg in add/remove test - Added test to confirm expected ID change behavior - Fixed bug in updateMany where multiple renames of one ID led to corrupted values in entities table afterwards * do that public API thing * Document caveats with update operations Co-authored-by: Lenz Weber <[email protected]> Co-authored-by: Thibault Gouala <[email protected]> Co-authored-by: Alexey Pyltsyn <[email protected]>
1 parent 9842933 commit 219be24

40 files changed

+3729
-9403
lines changed

.github/workflows/main.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Compressed Size
2+
3+
on: [pull_request]
4+
5+
jobs:
6+
build:
7+
runs-on: ubuntu-latest
8+
9+
steps:
10+
- uses: actions/checkout@v2-beta
11+
with:
12+
fetch-depth: 1
13+
- uses: preactjs/compressed-size-action@v1
14+
with:
15+
repo-token: '${{ secrets.GITHUB_TOKEN }}'

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ node_modules
44
dist
55
lib
66
es
7+
yarn.lock
78

89

910
.idea/

.travis.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ env:
55
- TYPESCRIPT_VERSION=3.7
66
- TYPESCRIPT_VERSION=3.6
77
- TYPESCRIPT_VERSION=3.5
8-
- TYPESCRIPT_VERSION=3.4
9-
- TYPESCRIPT_VERSION=3.3
108
install:
119
- npm ci --ignore-scripts
1210
- npm install typescript@$TYPESCRIPT_VERSION

docs/api/createAsyncThunk.md

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
---
2+
id: createAsyncThunk
3+
title: createAsyncThunk
4+
sidebar_label: createAsyncThunk
5+
hide_title: true
6+
---
7+
8+
# `createAsyncThunk`
9+
10+
## Overview
11+
12+
A function that accepts a Redux action type string and a callback function that should return a promise. It generates promise lifecycle action types based on the provided action type, and returns a thunk action creator that will run the promise callback and dispatch the lifecycle actions based on the returned promise.
13+
14+
This abstracts the standard recommended approach for handling async request lifecycles.
15+
16+
Sample usage:
17+
18+
```js {5-11,22-25,30}
19+
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
20+
import { userAPI } from './userAPI'
21+
22+
// First, create the thunk
23+
const fetchUserById = createAsyncThunk(
24+
'users/fetchByIdStatus',
25+
async (userId, thunkAPI) => {
26+
const response = await userAPI.fetchById(userId)
27+
return response.data
28+
}
29+
)
30+
31+
// Then, handle actions in your reducers:
32+
const usersSlice = createSlice({
33+
name: 'users',
34+
initialState: { entities: [], loading: 'idle' },
35+
reducers: {
36+
// standard reducer logic, with auto-generated action types per reducer
37+
},
38+
extraReducers: {
39+
// Add reducers for additional action types here, and handle loading state as needed
40+
[fetchUserById.fulfilled]: (state, action) => {
41+
// Add user to the state array
42+
state.entities.push(action.payload)
43+
}
44+
}
45+
})
46+
47+
// Later, dispatch the thunk as needed in the app
48+
dispatch(fetchUserById(123))
49+
```
50+
51+
## Parameters
52+
53+
`createAsyncThunk` accepts two parameters: a string action `type` value, and a `payloadCreator` callback.
54+
55+
### `type`
56+
57+
A string that will be used to generate additional Redux action type constants, representing the lifecycle of an async request:
58+
59+
For example, a `type` argument of `'users/requestStatus'` will generate these action types:
60+
61+
- `pending`: `'users/requestStatus/pending'`
62+
- `fulfilled`: `'users/requestStatus/fulfilled'`
63+
- `rejected`: `'users/requestStatus/rejected'`
64+
65+
### `payloadCreator`
66+
67+
A callback function that should return a promise containing the result of some asynchronous logic. It may also return a value synchronously. If there is an error, it should return a rejected promise containing either an `Error` instance or a plain value such as a descriptive error message.
68+
69+
The `payloadCreator` function can contain whatever logic you need to calculate an appropriate result. This could include a standard AJAX data fetch request, multiple AJAX calls with the results combined into a final value, interactions with React Native `AsyncStorage`, and so on.
70+
71+
The `payloadCreator` function will be called with two arguments:
72+
73+
- `arg`: a single value, containing the first parameter that was passed to the thunk action creator when it was dispatched. This is useful for passing in values like item IDs that may be needed as part of the request. If you need to pass in multiple values, pass them together in an object when you dispatch the thunk, like `dispatch(fetchUsers({status: 'active', sortBy: 'name'}))`.
74+
- `thunkAPI`: an object containing all of the parameters that are normally passed to a Redux thunk function, as well as additional options:
75+
- `dispatch`: the Redux store `dispatch` method
76+
- `getState`: the Redux store `getState` method
77+
- `extra`: the "extra argument" given to the thunk middleware on setup, if available
78+
- `requestId`: a unique string ID value that was automatically generated to identify this request sequence
79+
- `signal`: an [`AbortController.signal` object](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal) that may be used to see if another part of the app logic has marked this request as needing cancelation.
80+
81+
The logic in the `payloadCreator` function may use any of these values as needed to calculate the result.
82+
83+
## Return Value
84+
85+
`createAsyncThunk` returns a standard Redux thunk action creator. The thunk action creator function will have plain action creators for the `pending`, `fulfilled`, and `rejected` cases attached as nested fields.
86+
87+
When dispatched, the thunk will:
88+
89+
- dispatch the `pending` action
90+
- call the `payloadCreator` callback and wait for the returned promise to settle
91+
- when the promise settles:
92+
- if the promise resolved successfully, dispatch the `fulfilled` action with the promise value as `action.payload`
93+
- if the promise failed, dispatch the `rejected` action with a serialized version of the error value as `action.error`
94+
- Return a fulfilled promise containing the final dispatched action (either the `fulfilled` or `rejected` action object)
95+
96+
## Promise Lifecycle Actions
97+
98+
`createAsyncThunk` will generate three Redux action creators using [`createAction`](./createAction.md): `pending`, `fulfilled`, and `rejected`. Each lifecycle action creator will be attached to the returned thunk action creator so that your reducer logic can reference the action types and respond to the actions when dispatched. Each action object will contain the current unique `requestId` and `args` values under `action.meta`.
99+
100+
The action creators will have these signatures:
101+
102+
```ts
103+
interface SerializedError {
104+
name?: string
105+
message?: string
106+
code?: string
107+
stack?: string
108+
}
109+
110+
interface PendingAction<ThunkArg> {
111+
type: string
112+
payload: undefined
113+
meta: {
114+
requestId: string
115+
arg: ThunkArg
116+
}
117+
}
118+
119+
interface FulfilledAction<ThunkArg, PromiseResult> {
120+
type: string
121+
payload: PromiseResult
122+
meta: {
123+
requestId: string
124+
arg: ThunkArg
125+
}
126+
}
127+
128+
interface RejectedAction<ThunkArg> {
129+
type: string
130+
payload: undefined
131+
error: SerializedError | any
132+
meta: {
133+
requestId: string
134+
arg: ThunkArg
135+
aborted: boolean
136+
}
137+
}
138+
139+
type Pending = <ThunkArg>(
140+
requestId: string,
141+
arg: ThunkArg
142+
) => PendingAction<ThunkArg>
143+
144+
type Fulfilled = <ThunkArg, PromiseResult>(
145+
payload: PromiseResult,
146+
requestId: string,
147+
arg: ThunkArg
148+
) => FulfilledAction<ThunkArg, PromiseResult>
149+
150+
type Rejected = <ThunkArg>(
151+
requestId: string,
152+
arg: ThunkArg
153+
) => RejectedAction<ThunkArg>
154+
```
155+
156+
To handle these actions in your reducers, reference the action creators in `createReducer` or `createSlice` using either the object key notation or the "builder callback" notation:
157+
158+
```js {2,6,14,23}
159+
const reducer1 = createReducer(initialState, {
160+
[fetchUserById.fulfilled]: (state, action) => {}
161+
})
162+
163+
const reducer2 = createReducer(initialState, build => {
164+
builder.addCase(fetchUserById.fulfilled, (state, action) => {})
165+
})
166+
167+
const reducer3 = createSlice({
168+
name: 'users',
169+
initialState,
170+
reducers: {},
171+
extraReducers: {
172+
[fetchUserById.fulfilled]: (state, action) => {}
173+
}
174+
})
175+
176+
const reducer4 = createSlice({
177+
name: 'users',
178+
initialState,
179+
reducers: {},
180+
extraReducers: builder => {
181+
builder.addCase(fetchUserById.fulfilled, (state, action) => {})
182+
}
183+
})
184+
```
185+
186+
## Handling Thunk Results
187+
188+
Thunks may return a value when dispatched. A common use case is to return a promise from the thunk, dispatch the thunk from a component, and then wait for the promise to resolve before doing additional work:
189+
190+
```js
191+
const onClick = () => {
192+
dispatch(fetchUserById(userId)).then(() => {
193+
// do additional work
194+
})
195+
}
196+
```
197+
198+
The thunks generated by `createAsyncThunk` will always return a resolved promise with either the `fulfilled` action object or `rejected` action object inside, as appropriate.
199+
200+
The calling logic may wish to treat these actions as if they were the original promise contents. Redux Toolkit exports an `unwrapResult` function that can be used to extract the `payload` or `error` from the action and return or throw the result:
201+
202+
```js
203+
import { unwrapResult } from '@reduxjs/toolkit'
204+
205+
// in the component
206+
const onClick = () => {
207+
dispatch(fetchUserById(userId))
208+
.then(unwrapResult)
209+
.then(originalPromiseResult => {})
210+
.catch(serializedError => {})
211+
}
212+
```
213+
214+
## Cancellation
215+
216+
If you want to cancel your running thunk before it has finished, you can use the `abort` method of the promise returned by `dispatch(fetchUserById(userId))`.
217+
218+
A real-life example of that would look like this:
219+
220+
```ts
221+
function MyComponent(props: { userId: string }) {
222+
React.useEffect(() => {
223+
// Dispatching the thunk returns a promise
224+
const promise = dispatch(fetchUserById(props.userId))
225+
return () => {
226+
// `createAsyncThunk` attaches an `abort()` method to the promise
227+
promise.abort()
228+
}
229+
}, [props.userId])
230+
}
231+
```
232+
233+
After a thunk has been cancelled this way, it will dispatch (and return) a `"thunkName/rejected"` action with an `AbortError` on the `error` property. The thunk will not dispatch any further actions.
234+
235+
Additionally, your `payloadCreator` can use the `AbortSignal` it is passed via `thunkApi.signal` to actually cancel a costly asynchronous action.
236+
237+
The `fetch` api of modern browsers already comes with support for an `AbortSignal`:
238+
239+
```ts
240+
const fetchUserById = createAsyncThunk(
241+
'users/fetchById',
242+
async (userId, thunkAPI) => {
243+
const response = await fetch(`https://reqres.in/api/users/${userId}`, {
244+
signal: thunkAPI.signal
245+
})
246+
return await response.json()
247+
}
248+
)
249+
```
250+
251+
### Checking Cancellation Status
252+
253+
### Reading the Signal Value
254+
255+
You can use the `signal.aborted` property to regularly check if the thunk has been aborted and in that case stop costly long-running work:
256+
257+
```ts
258+
const readStream = createAsyncThunk('readStream', async (stream: ReadableStream, {signal}) => {
259+
const reader = stream.getReader();
260+
261+
let done = false;
262+
let result = "";
263+
264+
while (!done) {
265+
if (signal.aborted) {
266+
throw new Error("stop the work, this has been aborted!");
267+
}
268+
const read = await reader.read();
269+
result += read.value;
270+
done = read.done;
271+
}
272+
return result;
273+
}
274+
```
275+
276+
#### Listening for Abort Events
277+
278+
You can also call `signal.addEventListener('abort', callback)` to have logic inside the thunk be notified when `promise.abort()` was called.
279+
280+
```ts
281+
const readStream = createAsyncThunk(
282+
'readStream',
283+
(arg, { signal }) =>
284+
new Promise((resolve, reject) => {
285+
signal.addEventListener('abort', () => {
286+
reject(new DOMException('Was aborted while running', 'AbortError'))
287+
})
288+
289+
startActionA(arg)
290+
.then(startActionB)
291+
.then(startActionC)
292+
.then(startActionD)
293+
.then(resolve)
294+
})
295+
)
296+
```
297+
298+
## Examples
299+
300+
Requesting a user by ID, with loading state, and only one request at a time:
301+
302+
```js
303+
import { createAsyncThunk, createSlice, unwrapResult } from '@reduxjs/toolkit'
304+
import { userAPI } from './userAPI'
305+
306+
const fetchUserById = createAsyncThunk(
307+
'users/fetchByIdStatus',
308+
async (userId, { getState }) => {
309+
const { loading } = getState().users
310+
if (loading !== 'idle') {
311+
return
312+
}
313+
const response = await userAPI.fetchById(userId)
314+
return response.data
315+
}
316+
)
317+
318+
const usersSlice = createSlice({
319+
name: 'users',
320+
initialState: {
321+
entities: [],
322+
loading: 'idle',
323+
error: null
324+
},
325+
reducers: {},
326+
extraReducers: {
327+
[fetchUserById.pending]: (state, action) => {
328+
if (state.loading === 'idle') {
329+
state.loading = 'pending'
330+
}
331+
},
332+
[fetchUserById.fulfilled]: (state, action) => {
333+
if (state.loading === 'pending') {
334+
state.loading = 'idle'
335+
state.push(action.payload)
336+
}
337+
},
338+
[fetchUserById.rejected]: (state, action) => {
339+
if (state.loading === 'pending') {
340+
state.loading = 'idle'
341+
state.error = action.error
342+
}
343+
}
344+
}
345+
})
346+
347+
const UsersComponent = () => {
348+
const { users, loading, error } = useSelector(state => state.users)
349+
const dispatch = useDispatch()
350+
351+
const fetchOneUser = async userId => {
352+
try {
353+
const resultAction = dispatch(fetchUserById(userId))
354+
const user = unwrapResult(resultAction)
355+
showToast('success', `Fetched ${user.name}`)
356+
} catch (err) {
357+
showToast('error', `Fetch failed: ${err.message}`)
358+
}
359+
}
360+
361+
// render UI here
362+
}
363+
```

0 commit comments

Comments
 (0)