TypeScript with Strong Typed Action when using useReducer of React Hooks
Posted on: 2019-03-05
The technic I will demonstrate in this article comes from what I was using with the traditional React and Redux. Now, with React Hooks we do not need to use Redux anymore but the same principles exist with the hook useReducer. I have witnessed many people struggling using TypeScript and Redux because they are passing strings around instead of a string literal. Often, the struggle I hear is around the boilerplate required as well as it is not strongly typed. Let's try to make it as simple as possible and strongly typed in term of action name and action payload
Code
If you want to play around with what I will present, you can jump into the code sandbox available under this paragraph. However, keep in mind that you will not see the benefit of having Intellisense showing available actions and the security of working in VsCode where it will warn you if you are passing something of the wrong type -- this is a limitation of the online sandbox tool. The demonstration has many files, I suggest you click "Open in Editor" and click the hamburger menu to navigate between files.
Configuring the Actions
The first step is to get some types. Once you have that code in your project, you will be good to go without touching it. I am posting the code and will explain what it does.
/**
* Create an action that has a strongly typed string literal name with a strongly typed payload
*/
export function createActionPayload<TypeAction, TypePayload>(
actionType: TypeAction
): (payload: TypePayload) => ActionsWithPayload<TypeAction, TypePayload> {
return (p: TypePayload): ActionsWithPayload<TypeAction, TypePayload> => {
return {
payload: p,
type: actionType,
};
};
}
/**
* Create an action with no payload
*/
export function createAction<TypeAction>(
actionType: TypeAction
): () => ActionsWithoutPayload<TypeAction> {
return (): ActionsWithoutPayload<TypeAction> => {
return {
type: actionType,
};
};
}
/**
* Create an action with a payload
*/
export interface ActionsWithPayload<TypeAction, TypePayload> {
type: TypeAction;
payload: TypePayload;
}
/**
* Create an action that does not have a payload
*/
export interface ActionsWithoutPayload<TypeAction> {
type: TypeAction;
}
/**
* A very general type that means to be "an object with a many field created with createActionPayload and createAction
*/
interface ActionCreatorsMapObject {
[key: string]: (
...args: any[]
) => ActionsWithPayload<any, any> | ActionsWithoutPayload<any>;
}
/**
* Use this Type to merge several action object that has field created with createActionPayload or createAction
* E.g. type ReducerWithActionFromTwoObjects = ActionsUnion<typeof ActionsObject1 & typeof ActionsObject2>;
*/
export type ActionsUnion<A extends ActionCreatorsMapObject> = ReturnType<
A[keyof A]
>;
The first function uses a type named ActionsWithPayload. The function must be used when you create a new action that carry a payload. It will take two generics type. The former is the string literal of your type: a unique identifier for your action. The latter is the type of the payload. For example, you can set a field of an entity, like the name of a person, by using the following code.
setName: createActionPayload<typeof ACTION_SET_NAME, string>(ACTION_SET_NAME);
The setName is the strongly typed function of the action that you use to dispatch later on. It is linked to the specific string literal ACTION_SET_NAME (unique identifier) and the payload that can be passed to the action is only a string. You invoke the action by calling that function with the payload that you desire:
dispatch(setName("MyNewNameHere"));
The return of the function createActionPayload is an object with the payload and the type -- both strongly typed. It allows to have in your reducer a comparison on the type which is unique because it is not a string -- but a string literal type. To accomplish this feat, you must define one type per action. That is why, in your reducer file (or the file you want to store all your actions) you must defined a one line per action
export const ACTION_INCREASE_COUNT = "ACTION_INCREASE_COUNT";
export const ACTION_SET_NAME = "ACTION_SET_NAME";
The createActionPayload has also a sibling function createAction that does not take any payload. It works in a similar fashion which is that the type of the action is a unique string literal, but that time without a payload.
To recap what we need to far: first, we need to create a constant that is a string literal that will be used as a unique identifier of the action. Second, we need to create a function that has a payload strongly typed by the action's type. To tidy up everything, I usually group all common action in a single object.
export const AppActions = {
increaseCount: createAction<typeof ACTION_INCREASE_COUNT>(
ACTION_INCREASE_COUNT
),
setName: createActionPayload<typeof ACTION_SET_NAME, string>(ACTION_SET_NAME),
};
It changes slightly how to invoke the action which is clearer. It "namespaces" the action.
dispatch(setName("MyNewNameHere")); // Before
dispatch(AppActions.setName("MyNewNameHere")); // After
Reducer
The reducer is exactly like with Redux, it is a function that takes a state and an action. However, our actions will be constrained to the group of action we allow for the reducer.
export function appReducer(
state: AppReducerState,
action: AcceptedActions
): AppReducerState {
// ...
}
Because we have a strongly typed function per action, we can now define a collection of allowed actions. It helps when you have several reducers in an application to limit the scope of what is expected. If you are tidying the action in an object as proposed, you can do:
export type AcceptedActions = ActionsUnion<typeof AppActions>;
The AcceptedActions type is from the type you add in the ActionsUnion. It means you can add several group of action if you desire with the following syntax.
ActionsUnion<typeof ActionsObject1 & typeof ActionsObject2>;
Here is the complete reducer, like any reducer that you are used to code. However, the switch case only accept cases with the name that are from the AcceptedState which clarify what is possible or not to reduce.
export function appReducer(
state: AppReducerState,
action: AcceptedActions
): AppReducerState {
switch (action.type) {
case ACTION_INCREASE_COUNT:
return {
...state,
clickCount: state.clickCount + 1,
};
case ACTION_SET_NAME:
return {
...state,
activeEntity: { ...state.activeEntity, ...{ name: action.payload } },
};
}
return state;
}
React Hooks useReducer
The last piece of the puzzle is how to consume the data from the reducer's state and how to mutate the value. The useReducer function is a React Hooks that take the reducer function and the initial state as parameter. It returns the state and the dispatcher funciton.
const [state, dispatch] = useReducer(appReducer, appState);
To read the value of the state, it is a matter to use the variable state. Because the state is strongly typed, you will get your IDE support as well as static validation from TypeScript. Same for the invocation.
<button
onClick={() => {
dispatch(AppActions.increaseCount());
}}
>{`Increase State Count ${state.clickCount}`}</button>
The dispatch requires a function. We reuse the tidy object to select the action. The object is strongly typed, hence when typing you get a nice autocompletion of what action can be dispatched. You avoid any potential mistake of using a variable that is not supported by the reducer. In that example, there is no payload, but the following example shows a strongly typed action with a strongly typed argument.
<button
onClick={() => {
dispatch(AppActions.setName("test"));
}}
>
Conclusion
With about 50 lines of TypeScript, you get few utility functions and types to handle React Hooks's reducer with TypeScript in a pleasant way. As a developer, a new action is a matter of adding a line to uniquely identifier the action, and to define a one-line function to associate the action to a payload type. The reducer is strongly typed, the dispatch of action is also strongly typed. The solution scales well with multiple reducers, it works well with clarifying what can be dispatched and ensure consistency within the application by having a design that group actions of a similar domain together.