Borislav Hadzhiev
Last updated: Apr 21, 2021
Check out my new book
There are a couple of things that make typing reducer functions in react a little less error prone and give you some nice type checking and auto complete capabilities.
Among them are:
Let's look at a simple copy-paste style example in a single file called
App.tsx
and go over some of the key points:
import {useEffect, useReducer} from 'react'; type UserAttrs = { gender?: string; name?: string; country?: string; email?: string; }; type UserState = { loading: boolean; error: string | null; data: UserAttrs; }; enum ActionType { FETCH_USER = 'fetch_user', FETCH_USER_SUCCESS = 'fetch_user_success', FETCH_USER_ERROR = 'fetch_user_error', } type Action = | {type: ActionType.FETCH_USER} | { type: ActionType.FETCH_USER_SUCCESS; payload: Required<UserAttrs>; } | {type: ActionType.FETCH_USER_ERROR; payload: string}; function reducer(_state: UserState, action: Action): UserState { switch (action.type) { case ActionType.FETCH_USER: { return {loading: true, error: null, data: {}}; } case ActionType.FETCH_USER_SUCCESS: { return {loading: false, error: null, data: action.payload}; } case ActionType.FETCH_USER_ERROR: { return {loading: false, error: action.payload, data: {}}; } default: { throw new Error(`Unhandled action type - ${JSON.stringify(action)}`); } } } function App() { const [{loading, error, data}, dispatch] = useReducer(reducer, { loading: false, error: null, data: {}, }); useEffect(() => { const fetchUser = async () => { dispatch({type: ActionType.FETCH_USER}); try { const res = await fetch('https://randomuser.me/api/'); const parsed = (await res.json()) as { results: [ { gender: string; name: {first: string}; location: {country: string}; email: string; }, ]; }; const user = parsed.results[0]; dispatch({ type: ActionType.FETCH_USER_SUCCESS, payload: { gender: user.gender, name: user.name.first, country: user.location.country, email: user.email, }, }); } catch (error) { if (error instanceof Error) { dispatch({type: ActionType.FETCH_USER_ERROR, payload: error.message}); } else { dispatch({ type: ActionType.FETCH_USER_ERROR, payload: 'Something went wrong', }); } } }; fetchUser(); }, []); return ( <div> {loading && <h3>Loading...</h3>} {error && <h1 style={{color: 'red'}}>{error}</h1>} <h1> <pre>{JSON.stringify(data, null, 2)}</pre> </h1> </div> ); } export default App;
The first thing to look at is using an enum
for the ActionType
variables.
You don't want to be duplicating action type strings at the case
statements, nor in the dispatch
calls, you could easily mistype an action and
you don't get the nice autocomplete from typescript.
For our Action
type we use a union
. Typing the action
as a union allows us
to use the case
statement like a type guard
. In other words - if the action
type is equal to ActionType.FETCH_USER_ERROR
- you must provide a payload
parameter of type string
, otherwise typescript will error out.
In the default
case of our reducer we simply throw an error, the default
case is almost never what you want - it better to be explicit - if we
dispatched an action which wasn't handled we should know about it - it's
strange an not intuitive.
When dispatching
actions always use the ActionType
enum for autocompletion
and to avoid typos.