Skip to content

Commit

Permalink
Port ngrx/entity and add createAsyncThunk (#352)
Browse files Browse the repository at this point in the history
* 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 <mark@isquaredsoftware.com>

* 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 <mail@lenzw.de>
Co-authored-by: Thibault Gouala <thibault.gouala@gmail.com>
Co-authored-by: Alexey Pyltsyn <lex61rus@gmail.com>
  • Loading branch information
4 people committed Feb 19, 2020
1 parent 9842933 commit 219be24
Show file tree
Hide file tree
Showing 40 changed files with 3,729 additions and 9,403 deletions.
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'}))`.
- `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)

## 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

0 comments on commit 219be24

Please sign in to comment.