Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Port ngrx/entity and add createAsyncThunk #352

Merged
merged 80 commits into from
Feb 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
d0f4621
Initial port of `@ngrx/entity` implementation
markerikson Feb 9, 2020
fc3e33b
Remove deprecated addAll method
markerikson Feb 9, 2020
8bfdaf7
Port `@ngrx/entity` tests
markerikson Feb 10, 2020
1602a36
Simplify immutable entity operations by wrapping with Immer
markerikson Feb 10, 2020
46088ec
Don't overwrite state.ids if sorting order hasn't changed
markerikson Feb 10, 2020
d499052
Simplify state adapter logic using Immer
markerikson Feb 10, 2020
293b0d1
Add `isFSA` helper to createAction
markerikson Feb 11, 2020
2ccdbd4
Swap state operator order to `(state, arg)` and support FSAs
markerikson Feb 11, 2020
c088899
Add a test to verify adapter usage with createSlice
markerikson Feb 11, 2020
8c53d58
Document unexpected Immer behavior with nested produce calls
markerikson Feb 11, 2020
ff69954
Quiet lint warnings in tests
markerikson Feb 12, 2020
00a8685
Export Entity types as part of the public API
markerikson Feb 12, 2020
12351d7
Add createAsyncThunk
markerikson Feb 12, 2020
d72b051
Export createAsyncThunk as part of the public API
markerikson Feb 12, 2020
08a256b
Ignore VS Code folder
markerikson Feb 12, 2020
6918d66
Mark new types as alpha
markerikson Feb 12, 2020
edc481a
1.3.0-alpha.0
markerikson Feb 12, 2020
58df106
Remove `removeMany(predicate)` overload
markerikson Feb 13, 2020
a1d4091
Rework dispatched thunk action contents
markerikson Feb 13, 2020
71240c9
Merge branch 'master' into feature/entities
markerikson Feb 13, 2020
2774dd7
Update public API types
markerikson Feb 13, 2020
4940605
typings experiment
phryneas Feb 13, 2020
11ed168
Update createAsyncThunk tests to match API changes
markerikson Feb 14, 2020
24863a1
Simplify entity ID type definitions
markerikson Feb 14, 2020
113038c
Add a basic request ID counter to createAsyncThunk
markerikson Feb 14, 2020
fa3c86b
Add nanoid
markerikson Feb 14, 2020
6054a27
Include requestId in payload creator args, and use nanoid
markerikson Feb 14, 2020
d13ffde
Hopefully fix type definitions for empty thunk action params
markerikson Feb 14, 2020
da2fad9
Add overloads to make EntityAdapter methods createSlice-compatible
markerikson Feb 14, 2020
6e73961
Add a test that combines slices, async thunks, and entities
markerikson Feb 14, 2020
95d2bcd
Remove TS 3.3 and 3.4 from the Travis setup
markerikson Feb 14, 2020
a40e060
Update public API
markerikson Feb 14, 2020
9d57d62
1.3.0-alpha.1
markerikson Feb 14, 2020
d0f44b8
Rework createAsyncThunk error handling behavior
markerikson Feb 15, 2020
d28d779
Update public API
markerikson Feb 15, 2020
fbba32d
1.3.0-alpha.2
markerikson Feb 15, 2020
d13d26a
createAsyncThunk return fulfilled/rejected action instead of re-… (#361)
phryneas Feb 15, 2020
1076ab2
add .abort() to the createAsyncThunk thunkAction (#362)
phryneas Feb 15, 2020
5286c08
Add initial `getAsyncThunk` API docs and usage guide
markerikson Feb 15, 2020
a11b380
Merge branch 'master' into feature/entities
markerikson Feb 15, 2020
a53849b
Rename thunk types and fields and export SerializedError
markerikson Feb 15, 2020
6ce3e43
Update public API
markerikson Feb 15, 2020
fedd56c
1.3.0-alpha.3
markerikson Feb 15, 2020
cad5bf8
Initial fix for createAsyncThunk thunk types
markerikson Feb 16, 2020
a54b9ff
Rework `createAsyncThunk` types to enable specifying getState type
markerikson Feb 16, 2020
ab7cac9
Fix thunk test types
markerikson Feb 16, 2020
2f4ca1f
Update public API
markerikson Feb 16, 2020
6f19daf
1.3.0-alpha.4
markerikson Feb 16, 2020
39c5b31
manually import types to prevent a bundling issue
phryneas Feb 16, 2020
762f161
strongly type slice name (#354)
tgouala Feb 16, 2020
cd8329c
use ThunkApiConfig for optional type arguments (#364)
phryneas Feb 16, 2020
fc46763
Merge branch 'master' into feature/entities
markerikson Feb 16, 2020
3b1e203
1.3.0-alpha.5
markerikson Feb 16, 2020
5691d88
Modify createStateOperator to detect and handle Immer drafts
markerikson Feb 17, 2020
5170cd9
Update link styling to match main Redux site
markerikson Feb 17, 2020
d834671
Update blockquote styling to match main Redux site
markerikson Feb 17, 2020
e8ad717
Update side category menu styling to match main Redux site
markerikson Feb 17, 2020
d708ea1
Consolidate Update generic type and remove unused overload
markerikson Feb 17, 2020
bf23105
Update `combinedTest` based on `createStateOperator` fixes
markerikson Feb 17, 2020
dcb4fc1
Add API docs for `createEntityAdapter`
markerikson Feb 17, 2020
df69c19
guess what time it is again - it's public API time!
markerikson Feb 17, 2020
bfa0aac
1.3.0-alpha.6
markerikson Feb 17, 2020
63130ca
Remove accidental yarn.lock
markerikson Feb 17, 2020
da54c98
Merge branch 'master' into feature/entities
markerikson Feb 17, 2020
84d23fc
Try fixing Netlify deploys: 1
markerikson Feb 17, 2020
4296e74
Update DS to fix sidebar bug
markerikson Feb 17, 2020
3269da8
Merge branch 'master' into feature/entities
markerikson Feb 17, 2020
4c7c92a
Try forcing node version
markerikson Feb 17, 2020
8c6c792
createAsyncThunk improvements (#367)
phryneas Feb 18, 2020
491a122
Remove extraneous period from abort message
markerikson Feb 18, 2020
b9616df
Reorder cancellation content and improve wording
markerikson Feb 18, 2020
0f8a631
Fix code padding color busted from DS alpha.41
markerikson Feb 18, 2020
a0aeace
1.3.0-alpha.7
markerikson Feb 18, 2020
717658b
Update Docusaurus and add lockfile to 43 version (#369)
lex111 Feb 19, 2020
7415f46
Merge branch 'master' into feature/entities
markerikson Feb 19, 2020
69758eb
Try adding the compressed-size-action (#372)
markerikson Feb 19, 2020
34508e0
Merge branch 'master' into feature/entities
markerikson Feb 19, 2020
9eddab9
Fix potential entity bugs identified by code review
markerikson Feb 19, 2020
7b46be4
do that public API thing
markerikson Feb 19, 2020
0252d80
Document caveats with update operations
markerikson Feb 19, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: Compressed Size

on: [pull_request]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2-beta
with:
fetch-depth: 1
- uses: preactjs/compressed-size-action@v1
with:
repo-token: '${{ secrets.GITHUB_TOKEN }}'
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ node_modules
dist
lib
es
yarn.lock


.idea/
Expand Down
2 changes: 0 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ env:
- TYPESCRIPT_VERSION=3.7
- TYPESCRIPT_VERSION=3.6
- TYPESCRIPT_VERSION=3.5
- TYPESCRIPT_VERSION=3.4
- TYPESCRIPT_VERSION=3.3
install:
- npm ci --ignore-scripts
- npm install typescript@$TYPESCRIPT_VERSION
Expand Down
363 changes: 363 additions & 0 deletions docs/api/createAsyncThunk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,363 @@
---
id: createAsyncThunk
title: createAsyncThunk
sidebar_label: createAsyncThunk
hide_title: true
---

# `createAsyncThunk`

## Overview

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.

This abstracts the standard recommended approach for handling async request lifecycles.

Sample usage:

```js {5-11,22-25,30}
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'

// First, create the thunk
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
}
)

// Then, handle actions in your reducers:
const usersSlice = createSlice({
name: 'users',
initialState: { entities: [], loading: 'idle' },
reducers: {
// standard reducer logic, with auto-generated action types per reducer
},
extraReducers: {
// Add reducers for additional action types here, and handle loading state as needed
[fetchUserById.fulfilled]: (state, action) => {
// Add user to the state array
state.entities.push(action.payload)
}
}
})

// Later, dispatch the thunk as needed in the app
dispatch(fetchUserById(123))
```

## Parameters

`createAsyncThunk` accepts two parameters: a string action `type` value, and a `payloadCreator` callback.

### `type`

A string that will be used to generate additional Redux action type constants, representing the lifecycle of an async request:

For example, a `type` argument of `'users/requestStatus'` will generate these action types:

- `pending`: `'users/requestStatus/pending'`
- `fulfilled`: `'users/requestStatus/fulfilled'`
- `rejected`: `'users/requestStatus/rejected'`

### `payloadCreator`

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.

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.

The `payloadCreator` function will be called with two arguments:

- `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'}))`.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: treat the object as the default approach. Otherwise it is tempting to add positional args after the first one, which will not work, but you might not even notice for a while depending on how defaults are handled.

Suggested change
- `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'}))`.
`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. Passing an object is recommended for most cases,

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you talking about at strictly a documentation level, or something at the code level too?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just docs

- `thunkAPI`: an object containing all of the parameters that are normally passed to a Redux thunk function, as well as additional options:
- `dispatch`: the Redux store `dispatch` method
- `getState`: the Redux store `getState` method
- `extra`: the "extra argument" given to the thunk middleware on setup, if available
- `requestId`: a unique string ID value that was automatically generated to identify this request sequence
- `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.

The logic in the `payloadCreator` function may use any of these values as needed to calculate the result.

## Return Value

`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.

When dispatched, the thunk will:

- dispatch the `pending` action
- call the `payloadCreator` callback and wait for the returned promise to settle
- when the promise settles:
- if the promise resolved successfully, dispatch the `fulfilled` action with the promise value as `action.payload`
- if the promise failed, dispatch the `rejected` action with a serialized version of the error value as `action.error`
- Return a fulfilled promise containing the final dispatched action (either the `fulfilled` or `rejected` action object)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API comment: if awaiting the thunk returns a usable payload, it's very tempting to use the value directly (for example, by passing it to a local setState) instead of reading from redux state. I've seen this is a lot in an app that has a similar utility.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can already do that with thunks today, so I don't see any difference here conceptually.

Most of the time, folks are just interested in kicking off some kind of additional behavior after the thunk promise resolves:

dispatch(someThunk())
  .then(() => {})

The issue here is that since we're catching errors internally, we always return a fulfilled promise, and it's hypothetically possible the user might A) be interested in the original API call result, or B) want to deal with the API call having rejected.

So, we're trying to provide a way to enable that.


## Promise Lifecycle Actions

`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`.

The action creators will have these signatures:

```ts
interface SerializedError {
name?: string
message?: string
code?: string
stack?: string
}

interface PendingAction<ThunkArg> {
type: string
payload: undefined
meta: {
requestId: string
arg: ThunkArg
}
}

interface FulfilledAction<ThunkArg, PromiseResult> {
type: string
payload: PromiseResult
meta: {
requestId: string
arg: ThunkArg
}
}

interface RejectedAction<ThunkArg> {
type: string
payload: undefined
error: SerializedError | any
meta: {
requestId: string
arg: ThunkArg
aborted: boolean
}
}

type Pending = <ThunkArg>(
requestId: string,
arg: ThunkArg
) => PendingAction<ThunkArg>

type Fulfilled = <ThunkArg, PromiseResult>(
payload: PromiseResult,
requestId: string,
arg: ThunkArg
) => FulfilledAction<ThunkArg, PromiseResult>

type Rejected = <ThunkArg>(
requestId: string,
arg: ThunkArg
) => RejectedAction<ThunkArg>
```

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:

```js {2,6,14,23}
const reducer1 = createReducer(initialState, {
[fetchUserById.fulfilled]: (state, action) => {}
})

const reducer2 = createReducer(initialState, build => {
builder.addCase(fetchUserById.fulfilled, (state, action) => {})
})

const reducer3 = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers: {
[fetchUserById.fulfilled]: (state, action) => {}
}
})

const reducer4 = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers: builder => {
builder.addCase(fetchUserById.fulfilled, (state, action) => {})
}
})
```

## Handling Thunk Results

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:

```js
const onClick = () => {
dispatch(fetchUserById(userId)).then(() => {
// do additional work
})
}
```

The thunks generated by `createAsyncThunk` will always return a resolved promise with either the `fulfilled` action object or `rejected` action object inside, as appropriate.

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:

```js
import { unwrapResult } from '@reduxjs/toolkit'

// in the component
const onClick = () => {
dispatch(fetchUserById(userId))
.then(unwrapResult)
.then(originalPromiseResult => {})
.catch(serializedError => {})
}
```

## Cancellation

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))`.

A real-life example of that would look like this:

```ts
function MyComponent(props: { userId: string }) {
React.useEffect(() => {
// Dispatching the thunk returns a promise
const promise = dispatch(fetchUserById(props.userId))
return () => {
// `createAsyncThunk` attaches an `abort()` method to the promise
promise.abort()
}
}, [props.userId])
}
```

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.

Additionally, your `payloadCreator` can use the `AbortSignal` it is passed via `thunkApi.signal` to actually cancel a costly asynchronous action.

The `fetch` api of modern browsers already comes with support for an `AbortSignal`:

```ts
const fetchUserById = createAsyncThunk(
'users/fetchById',
async (userId, thunkAPI) => {
const response = await fetch(`https://reqres.in/api/users/${userId}`, {
signal: thunkAPI.signal
})
return await response.json()
}
)
```

### Checking Cancellation Status

### Reading the Signal Value

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:

```ts
const readStream = createAsyncThunk('readStream', async (stream: ReadableStream, {signal}) => {
const reader = stream.getReader();

let done = false;
let result = "";

while (!done) {
if (signal.aborted) {
throw new Error("stop the work, this has been aborted!");
}
const read = await reader.read();
result += read.value;
done = read.done;
}
return result;
}
```

#### Listening for Abort Events

You can also call `signal.addEventListener('abort', callback)` to have logic inside the thunk be notified when `promise.abort()` was called.

```ts
const readStream = createAsyncThunk(
'readStream',
(arg, { signal }) =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
reject(new DOMException('Was aborted while running', 'AbortError'))
})

startActionA(arg)
.then(startActionB)
.then(startActionC)
.then(startActionD)
.then(resolve)
})
)
```

## Examples

Requesting a user by ID, with loading state, and only one request at a time:

```js
import { createAsyncThunk, createSlice, unwrapResult } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'

const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId, { getState }) => {
const { loading } = getState().users
if (loading !== 'idle') {
return
}
const response = await userAPI.fetchById(userId)
return response.data
}
)

const usersSlice = createSlice({
name: 'users',
initialState: {
entities: [],
loading: 'idle',
error: null
},
reducers: {},
extraReducers: {
[fetchUserById.pending]: (state, action) => {
if (state.loading === 'idle') {
state.loading = 'pending'
}
},
[fetchUserById.fulfilled]: (state, action) => {
if (state.loading === 'pending') {
state.loading = 'idle'
state.push(action.payload)
}
},
[fetchUserById.rejected]: (state, action) => {
if (state.loading === 'pending') {
state.loading = 'idle'
state.error = action.error
}
}
}
})

const UsersComponent = () => {
const { users, loading, error } = useSelector(state => state.users)
const dispatch = useDispatch()

const fetchOneUser = async userId => {
try {
const resultAction = dispatch(fetchUserById(userId))
const user = unwrapResult(resultAction)
showToast('success', `Fetched ${user.name}`)
} catch (err) {
showToast('error', `Fetch failed: ${err.message}`)
}
}

// render UI here
}
```
Loading