Proposal: a modern & typed way of writing Redux actions doing API requests (#30270)
This commit is contained in:
parent
3a862439df
commit
10ec421dd4
14 changed files with 281 additions and 125 deletions
|
@ -2,6 +2,8 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
|
|||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import type { BaseThunkAPI } from '@reduxjs/toolkit/dist/createAsyncThunk';
|
||||
|
||||
import type { AppDispatch, RootState } from './store';
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
|
@ -13,8 +15,192 @@ export interface AsyncThunkRejectValue {
|
|||
error?: unknown;
|
||||
}
|
||||
|
||||
interface AppMeta {
|
||||
skipLoading?: boolean;
|
||||
}
|
||||
|
||||
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||
state: RootState;
|
||||
dispatch: AppDispatch;
|
||||
rejectValue: AsyncThunkRejectValue;
|
||||
}>();
|
||||
|
||||
type AppThunkApi = Pick<
|
||||
BaseThunkAPI<
|
||||
RootState,
|
||||
unknown,
|
||||
AppDispatch,
|
||||
AsyncThunkRejectValue,
|
||||
AppMeta,
|
||||
AppMeta
|
||||
>,
|
||||
'getState' | 'dispatch'
|
||||
>;
|
||||
|
||||
interface AppThunkOptions {
|
||||
skipLoading?: boolean;
|
||||
}
|
||||
|
||||
const createBaseAsyncThunk = createAsyncThunk.withTypes<{
|
||||
state: RootState;
|
||||
dispatch: AppDispatch;
|
||||
rejectValue: AsyncThunkRejectValue;
|
||||
fulfilledMeta: AppMeta;
|
||||
rejectedMeta: AppMeta;
|
||||
}>();
|
||||
|
||||
export function createThunk<Arg = void, Returned = void>(
|
||||
name: string,
|
||||
creator: (arg: Arg, api: AppThunkApi) => Returned | Promise<Returned>,
|
||||
options: AppThunkOptions = {},
|
||||
) {
|
||||
return createBaseAsyncThunk(
|
||||
name,
|
||||
async (
|
||||
arg: Arg,
|
||||
{ getState, dispatch, fulfillWithValue, rejectWithValue },
|
||||
) => {
|
||||
try {
|
||||
const result = await creator(arg, { dispatch, getState });
|
||||
|
||||
return fulfillWithValue(result, {
|
||||
skipLoading: options.skipLoading,
|
||||
});
|
||||
} catch (error) {
|
||||
return rejectWithValue({ error }, { skipLoading: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
getPendingMeta() {
|
||||
if (options.skipLoading) return { skipLoading: true };
|
||||
return {};
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const discardLoadDataInPayload = Symbol('discardLoadDataInPayload');
|
||||
type DiscardLoadData = typeof discardLoadDataInPayload;
|
||||
|
||||
type OnData<LoadDataResult, ReturnedData> = (
|
||||
data: LoadDataResult,
|
||||
api: AppThunkApi & {
|
||||
discardLoadData: DiscardLoadData;
|
||||
},
|
||||
) => ReturnedData | DiscardLoadData | Promise<ReturnedData | DiscardLoadData>;
|
||||
|
||||
// Overload when there is no `onData` method, the payload is the `onData` result
|
||||
export function createDataLoadingThunk<
|
||||
LoadDataResult,
|
||||
Args extends readonly unknown[],
|
||||
>(
|
||||
name: string,
|
||||
loadData: (...args: Args) => Promise<LoadDataResult>,
|
||||
thunkOptions?: AppThunkOptions,
|
||||
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
|
||||
|
||||
// Overload when the `onData` method returns discardLoadDataInPayload, then the payload is empty
|
||||
export function createDataLoadingThunk<
|
||||
LoadDataResult,
|
||||
Args extends readonly unknown[],
|
||||
>(
|
||||
name: string,
|
||||
loadData: (...args: Args) => Promise<LoadDataResult>,
|
||||
onDataOrThunkOptions?:
|
||||
| AppThunkOptions
|
||||
| OnData<LoadDataResult, DiscardLoadData>,
|
||||
thunkOptions?: AppThunkOptions,
|
||||
): ReturnType<typeof createThunk<Args, void>>;
|
||||
|
||||
// Overload when the `onData` method returns nothing, then the mayload is the `onData` result
|
||||
export function createDataLoadingThunk<
|
||||
LoadDataResult,
|
||||
Args extends readonly unknown[],
|
||||
>(
|
||||
name: string,
|
||||
loadData: (...args: Args) => Promise<LoadDataResult>,
|
||||
onDataOrThunkOptions?: AppThunkOptions | OnData<LoadDataResult, void>,
|
||||
thunkOptions?: AppThunkOptions,
|
||||
): ReturnType<typeof createThunk<Args, LoadDataResult>>;
|
||||
|
||||
// Overload when there is an `onData` method returning something
|
||||
export function createDataLoadingThunk<
|
||||
LoadDataResult,
|
||||
Args extends readonly unknown[],
|
||||
Returned,
|
||||
>(
|
||||
name: string,
|
||||
loadData: (...args: Args) => Promise<LoadDataResult>,
|
||||
onDataOrThunkOptions?: AppThunkOptions | OnData<LoadDataResult, Returned>,
|
||||
thunkOptions?: AppThunkOptions,
|
||||
): ReturnType<typeof createThunk<Args, Returned>>;
|
||||
|
||||
/**
|
||||
* This function creates a Redux Thunk that handles loading data asynchronously (usually from the API), dispatching `pending`, `fullfilled` and `rejected` actions.
|
||||
*
|
||||
* You can run a callback on the `onData` results to either dispatch side effects or modify the payload.
|
||||
*
|
||||
* It is a wrapper around RTK's [`createAsyncThunk`](https://redux-toolkit.js.org/api/createAsyncThunk)
|
||||
* @param name Prefix for the actions types
|
||||
* @param loadData Function that loads the data. It's arguments will become the thunk's arguments
|
||||
* @param onDataOrThunkOptions
|
||||
* Callback called on the results from `loadData`.
|
||||
*
|
||||
* First argument will be the return from `loadData`.
|
||||
*
|
||||
* Second argument is an object with: `dispatch`, `getState` and `discardLoadData`.
|
||||
* It can return:
|
||||
* - `undefined` (or no explicit return), meaning that the `onData` results will be the payload
|
||||
* - `discardLoadData` to discard the `onData` results and return an empty payload
|
||||
* - anything else, which will be the payload
|
||||
*
|
||||
* You can also omit this parameter and pass `thunkOptions` directly
|
||||
* @param maybeThunkOptions
|
||||
* Additional Mastodon specific options for the thunk. Currently supports:
|
||||
* - `skipLoading` to avoid showing the loading bar when the request is in progress
|
||||
* @returns The created thunk
|
||||
*/
|
||||
export function createDataLoadingThunk<
|
||||
LoadDataResult,
|
||||
Args extends readonly unknown[],
|
||||
Returned,
|
||||
>(
|
||||
name: string,
|
||||
loadData: (...args: Args) => Promise<LoadDataResult>,
|
||||
onDataOrThunkOptions?: AppThunkOptions | OnData<LoadDataResult, Returned>,
|
||||
maybeThunkOptions?: AppThunkOptions,
|
||||
) {
|
||||
let onData: OnData<LoadDataResult, Returned> | undefined;
|
||||
let thunkOptions: AppThunkOptions | undefined;
|
||||
|
||||
if (typeof onDataOrThunkOptions === 'function') onData = onDataOrThunkOptions;
|
||||
else if (typeof onDataOrThunkOptions === 'object')
|
||||
thunkOptions = onDataOrThunkOptions;
|
||||
|
||||
if (maybeThunkOptions) {
|
||||
thunkOptions = maybeThunkOptions;
|
||||
}
|
||||
|
||||
return createThunk<Args, Returned>(
|
||||
name,
|
||||
async (arg, { getState, dispatch }) => {
|
||||
const data = await loadData(...arg);
|
||||
|
||||
if (!onData) return data as Returned;
|
||||
|
||||
const result = await onData(data, {
|
||||
dispatch,
|
||||
getState,
|
||||
discardLoadData: discardLoadDataInPayload,
|
||||
});
|
||||
|
||||
// if there is no return in `onData`, we return the `onData` result
|
||||
if (typeof result === 'undefined') return data as Returned;
|
||||
// the user explicitely asked to discard the payload
|
||||
else if (result === discardLoadDataInPayload)
|
||||
return undefined as Returned;
|
||||
else return result;
|
||||
},
|
||||
thunkOptions,
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue