npm install redux-thunk-routine
or
yarn add redux-thunk-routine
I use redux
and redux-thunk
in my day-to-day work, and they do really great job to help manage the states of my application.
However, I find that I have to write a lot of boilerplate code for each asynchronous action, and it is even more tedious to add static type checking (i.e. use TypeScript).
That's why I created redux-thunk-routine
, a small libary to reduce the boilerplate and improve the support of static typing.
- This libary can save your time (less boilerplate and better static typing)
- It can also generalize the flow of dispatching actions (example: Global Loading & Error State)
- There is no harm to your existing code when you add this library
- It comes with drop-in replacement to your hand-writen code as well as helpers to simplify more
- Test Coverage is 100%
So what is a routine
? Let's explain it with an example.
Imagine that we are creating an asynchronous action to fetch data using API, we might write the typical code like below:
// 1. Define Constants
// These constants are used as action types
const FETCH_DATA_REQUEST = 'FETCH_DATA/REQUEST';
const FETCH_DATA_SUCCESS = 'FETCH_DATA/SUCCESS';
const FETCH_DATA_FAILURE = 'FETCH_DATA/FAILURE';
// 2. Define Synchronous Action Creators
// We are creating Flux Standard Actions
const fetchDataRequest = (payload?: any) => ({
type: FETCH_DATA_REQUEST,
payload
});
const fetchDataSuccess = (payload: DataType) => ({
type: FETCH_DATA_SUCCESS,
payload
});
const fetchDataFailure = (payload: Error) => ({
type: FETCH_DATA_FAILURE,
payload,
error: true
});
// Note: There is a simplied version below to define action creators using `redux-action` library.
// But we still have to define the types manually for each of them
const fetchDataRequest: (payload?: any) => Action<any> = createAction(FETCH_DATA_REQUEST);
const fetchDataSuccess: (payload: DataType) => Action<DataType> = createAction(FETCH_DATA_SUCCESS);
const fetchDataFailure: (payload: Error) => Action<Error> = createAction(FETCH_DATA_FAILURE);
// 3. Define thunk action creator
// The outer function is called "Thunk Action Creator"
const fetchData = (id: number) => {
// The inner function is called "Thunk Action"
return async (dispatch: Dispatch) => {
dispatch(fetchDataRequest(id));
try {
const data = await api.fetchData(id);
return dispatch(fetchDataSuccess(data));
} catch (e) {
dispatch(fetchDataFailure(e));
throw e;
}
};
};
// 4. Handle actions in reducers
// Define Types for actions
type FetchDataRequestAction = {
type: typeof FETCH_DATA_REQUEST;
payload: any;
};
type FetchDataSuccessAction = {
type: typeof FETCH_DATA_SUCCESS;
payload: DataType;
};
type FetchDataFailureAction = {
type: typeof FETCH_DATA_FAILURE;
payload: Error;
error: boolean;
};
// Make a union type
type ValidAction = FetchDataRequestAction | FetchDataSuccessAction | FetchDataFailureAction;
// In each condition branch, the type of action will be inferred
const reducer = (state: State = {}, action: ValidAction): State => {
// When receive REQUEST action, switch on the loading flag and clear the error
if (action.type === FETCH_DATA_REQUEST) {
// action: FetchDataRequestAction
return {
...state,
isFetching: true,
error: null
};
}
// When receive SUCCESS action, store the data, switch off the loading flag, and clear the error
if (action.type === FETCH_DATA_SUCCESS) {
// action: FetchDataSuccessAction
const data = action.payload;
return {
...state,
isFetching: false,
data: action.payload,
error: null
};
}
// When receive FAILURE action, store the error and switch off the loading flag
if (action.type === FETCH_DATA_FAILURE) {
// action: FetchDataFailureAction
const error = action.payload;
return {
...state,
isFetching: false,
error: action.payload
};
}
};
// 5. Dispatch the thunk action to start the asynchronous journey
store.dispatch(fetchData(id));
Looking at the example, we see that for every asynchronous action:
- We have to define 3 constants:
REQUEST
,SUCCESS
,FAILURE
- We have to define 3 synchronous action creators:
request
,success
,failure
- We have to write same logic flow of dispatching actions: request -> side effects -> success/failure
- We have to write a lot of type definitions to make the static type checking works
What if I tell you that there is a smart thing called routine
to wipe these repetitive work out?
If we rewrite the previous example using routine
, we can have a minimum example like below:
// 1. Define a routine
const fetchDataRoutine = createThunkRoutine<DataType>('FETCH_DATA');
// 2. Get the thunk action creator
const fetchData = getThunkActionCreator(fetchDataRoutine, async (id: number) => {
return await api.fetchData(id);
});
// 3. Write the reducer
const reducer = (state: State = {}, action: Action<any>): State => {
// We only focus on SUCCESS action here
// We will discuss how to deal with REQUEST and ERROR actions later (using a more elegant way)
if (fetchDataRoutine.isSuccessAction(action)) {
const data = action.payload;
return {
...state,
data
};
}
};
// 4. Dispatch the thunk action
store.dispatch(fetchData(id));
We only need to provide 2 basic information: type of success payload and a string as routine type.
const fetchDataRoutine = createThunkRoutine<DataType>('FETCH_DATA');
Option: we can explicitly decalre the error type if need
const fetchDataRoutine: createThunkRoutine<DataType, Error>('FETCH_DATA')
When define a routine
, we have the following things automatically generated for us:
- 3 action types:
routine.REQUEST
routine.SUCCESS
routine.FAILURE
- 3 synchronous action creators:
routine.request()
routine.success()
routine.failure()
- 3 methods to match actions (also as type guards):
routine.isRequestAction()
routine.isSuccessAction()
routine.isFailureAction()
When use helper function, we just need to provide the routine and the async function to create success payload:
const fetchData = getThunkActionCreator(fetchDataRoutine, async (id: number) => {
return await api.fetchData(id);
});
Note: if you have multiple arguments to pass here, you can pack them as an object. (e.g., {arg1: 1, arg2: 'B'}). It sounds like "named arguments" in some other languages, and it is easier to infer type signature of the generated Thunk Action in this way.
It is equal to use the synchronous action creators manually:
const fetchData = (id: number) => async (dispatch: Dispatch) => {
dispatch(fetchDataRoutine.request(id));
try {
const data = await api.fetchData(id);
return dispatch(fetchDataRoutine.success(data));
} catch (e) {
dispatch(fetchDataRoutine.failure(e));
throw e;
}
};
Obviously, we write much less code using the helper function, but you can always fallback when you have such needs.
Apart from that, the helper function getThunkActionCreator
also provides an optional 3rd parameter which allows us to provide more options to overwrite how the payload for request action and failure action are generated:
const fetchData = getThunkActionCreator(
fetchDataRoutine,
async (id: number) => {
return await api.fetchData(id);
},
{
// [Optional] We can overwrite how we create request payload.
// Usually we just use this payload to do logging
getRequestPayload: async (id: number) => {
return {
overwrittenPayload: id
};
},
// [Optional] We can overwrite how we create failure payload.
getFailurePayload: async (e: Error) => {
return new Error('Overwritten Error!');
}
}
);
We can easily get typed action payload by using type guard match functions (recommend):
const reducer = (state: State = initState, action: Action<any>): State => {
// .isSuccessAction() is a type guard
if (fetchDataRoutine.isSuccessAction(action)) {
// action is typed as Action<DataType>, so payload is DataType
const payload = action.payload;
// ...
}
// .isFailureAction() is a type guard
if (fetchDataRoutine.isFailureAction(action)) {
// action is typed as Action<Error>, so error is Error
const error = action.payload;
// ...
}
// .isRequestAction()
if (fetchDataRoutine.isRequestAction(action)) {
// ...
}
};
Or we can use the getXXX
functions:
const reducer = (state: State = initState, action: Action<any>): State => {
switch (action.type) {
case fetchDataRoutine.SUCCESS: {
// payload is typed as Action<DataType>
const payload = fetchDataRoutine.getSuccessPayload(action);
// ...
break;
}
case fetchDataRoutine.FAILURE: {
// error is typed as Error
const error = fetchDataRoutine.getFailurePayload(action);
// ...
break;
}
}
};
By using redux-thunk-routine
, the type of payloads are already checked when dispatching, so it is safe to put Action<any>
when define the reducer:
const reducer = (state: State = initState, action: Action<any>): State => {
// The reducer logic goes here...
};
If needed, strictly adding types of actions is also possible:
type ValidAction =
| ReturnType<typeof fetchDataRoutine.request>
| ReturnType<typeof fetchDataRoutine.success>
| ReturnType<typeof fetchDataRoutine.failure>;
const reducer = (state: State = initState, action: ValidAction): State => {
// The reducer logic goes here...
};
Well, there is a debate of using getState
in action creators and it was indexed in this blog.
IMO, it is better to let the component to select all required states before dispatching a thunk action. Because the flow looks simpler in this way and it is easier to test.
According to that, there is no getState
paramater passed to the executor function when using getThunkActionCreator
.
However, if you need to call getState
in some cases, you can do it by defining another thunk action creator to dispatch the thunk action created by routine
:
// Imagine we defined a thunk action creator from routine
const fetchData = getThunkActionCreator(fetchDataRoutine, async (id: number) => {
return await api.fetchData(id);
});
// We could write another thunk action creator to access the existing state
const fetchDataWithIdFromState = () => (dispatch: Dispatch, getState: () => RootState) => {
const id = getState().somewhere.id;
return dispatch(fetchData(id));
};
Since version 1.1.0
, if you use getThunkActionCreator
to create your ThunkAction
, then the Promise
returned by the ThunkAction
can be aborted.
The code below is a simple example to show how to abort the execution of ThunkAction
:
// `fetchData` is the thunk action creator
const promise = dispatch(fetchData(id));
// Abort with default reason ('Aborted')
promise.abort();
// Abort with reason
promise.abort('I abort it');
When aborted, the Promise
will be rejected with an AbortError
, and the AbortError
will be dispatched as the payload of failure action.
Under the ground, it is using AbortablePromise
from simple-abortable-promise.
If your getSuccessPayload
function returns an AbortablePromise
, then that Promise
will be aborted as well when the ThunkAction
is aborted. Otherwise, the logic of getSuccessPayload
will still be executed, and the result will be ignored.
When using routine
, we are enforced to follow a common pattern to name our action types, and we are sure that each routine has the same flow of dispatching actions. That gives us a huge advantage if we want to pull out repetitive logic in reducers.
For example, we can implement a global loading reducer and a global error reducer based on regular expression to match action types, then remove the branches of dealing "REQUEST" and "FAILURE" actions in other reducers.
Quick example here:
// We start with defining the LoadingState
// The shape of LoadingState is a hashmap-like object
// Example: {
// FETCH_BLOG: true,
// FETCH_USER: false,
// }
//
export type LoadingState = Record<string, boolean>;
// Then we write the reducer to handle the logic of changing the global loading state
export default (state: LoadingState = {}, action: Action<any>): LoadingState => {
const { type } = action;
const matches = /(.*)\/(REQUEST|SUCCESS|FAILURE)/.exec(type);
// Ignore non-routine actions:
// A routine action should have one of three suffixes:
// ['/REQUEST', '/SUCCESS', '/FAILURE']
if (!matches) return state;
const [, routineType, status] = matches;
return {
...state,
// Set loading state to true only when the status is "REQUEST"
// Otherwise set the loading state to false
[routineType]: status === 'REQUEST'
};
};
// Then we can write some selectors to use it
// Select whether any routine is loading
export const isLoadingAnyRoutine = (state: RootState) => {
return Object.values(state.ui.loading).some(Boolean);
};
// Select whether a given routine is loading
export const isLoadingRoutine = (routineType: string) => (state: RootState) => {
return Boolean(state.ui.loading[routineType]);
};
// Select whether any of a given set of routines is loading
export const isLoadingAnyRoutineOf = (routineTypes: string[]) => (state: RootState) => {
return routineTypes.some(routineType => Boolean(state.ui.loading[routineType]));
};
There are more details in this blog.
Yes, the library itself is written in TypeScript and compiled to ES6 with a .d.ts
file for type definition.
That is, it still can reduce boilerplate when use in a plain JavaScript project.
Sure, this library is a very thin abstraction layer built on top of redux-thunk
, and there is no conflict to the foundation library. That is, it is totally harmless to add this library to your current project.
When introducing new library, I would suggest to start using it for new features only to test if it fits your needs before considering to migrate existing codebase. Once you have some experience with it, the migration path should be clear.
Of course. As discussed in this issue, it is easy to create a subclass of ReduxThunkRoutine
.
For example, the code below demonstrates how to add TRUNCATE
as part of your routine.
// imports
import { ReduxThunkRoutine } from 'redux-thunk-routine';
import { Action, createAction } from 'redux-actions';
// Create the subclass
export class MyThunkRoutine<TPayload, TError extends Error = Error> extends ReduxThunkRoutine<TPayload, TError> {
// Allow access to the action name
readonly TRUNCATE: string;
// Constructor
constructor(routineType: string) {
super(routineType);
this.TRUNCATE = `${this.routineType}/TRUNCATE`;
}
// Extend the routine: action creator
truncate = (): Action<any> => {
const actionCreator = createAction(this.TRUNCATE);
return actionCreator();
};
// Extend the routine: helper match function
isTruncateAction = (action: Action<any>): action is Action<any> => {
return action.type === this.TRUNCATE;
};
}
Then we can use it like this:
// Create routine
const routine = new MyThunkRoutine<DataType>('FETCH_DATA');
// Dispatch TRUNCATE action
dispatch(routine.truncate());
// Access the action name directly in reducer
switch (action.type) {
//...
case routine.TRUNCATE:
// Do stuff
break;
}
// Use match helper function in reducer
if (routine.isTruncateAction(action)) {
// Do stuff
return state;
}
This library uses redux-actions to create Flux Standard Actions
This library is inspired by redux-saga-routines.