DEV Community

Cover image for Generating strongly typed reducer actions for React
Matt Priour
Matt Priour

Posted on

Generating strongly typed reducer actions for React

Using reducers in the React Context api leaves a lot of room for error, even when using TypeScript. A reducer takes a single argument and returns a state. The common format for the argument is an object with an action property and a payload or value property. The action's value is generally a string such as setTitle. In the case of action:'setTitle', the value would then be the title you want to set on the state object. The problems with this approach are:

  1. You have to look back at the reducer to see what the correct spelling and valid values of action are.
  2. There is no type checking of the values that are linked with the action.

This article assumes that you have a basic familiarity with React, useReducer, and TypeScript.

If you just want to skip to the code, you can see the final working version on this TS Playground

The optimal solution would allow us to define the state object and its valid values, then TypeScript would provide code completion and type validation for the actions and values passed to the reducer function. A less optimal solution would be to do something similar to Redux and create a series of action functions that are strongly typed and wrap calls to the reducer. Then in your code you only use action functions and never call the reducer directly. However, TypeScript's mapped types can make that extra layer unnecessary.

Let's take a simple state type

type ItemState = {
    id: string,
    title: string,
    description?: string,
    quantity: number
}
Enter fullscreen mode Exit fullscreen mode

We want to create a reducer that knows that setId and setQuantity are valid actions but setID and setQty are not. It should also type check so that the value paired with setQuantity is a number and not a Date or string.

The first step is to create a generic utility type called Setters that takes another type and produces "setPropertyName" style function names for each property on that type.

type Setters<T> = {
    [P in keyof T as `set${Capitalize<string & P>}`]-?: T[P]
}
Enter fullscreen mode Exit fullscreen mode

Capitalize is a built in string modifier that capitalizes the passed string. The -? means that we remove the optional attribute of any property. T[P] gives us the type of each property of the passed in generic type.

We then use the Setters utility to generate a setters type for our state type.

type ItemSetters = Setters<ItemState>
/* 
This is equivalent to writing out:
type ItemSetters = {
    setId: string,
    setTitle: string,
    setDescription: string,
    setQuantity: number
}
*/
Enter fullscreen mode Exit fullscreen mode

Now let's use another mapped type to create a map of action objects with valid action names and value types.

type ActionsMap = {
    [S in keyof ItemSetters]: {
        action: S,
        value: ItemSetters[S]
    }
}
/* 
This results in:
type ActionsMap = {
    setId: {
        action: 'setId',
        value: string
    }, ...
    setQuantity: {
        action: 'setQuantity',
        value: number
    }
}
*/
Enter fullscreen mode Exit fullscreen mode

Now we need to extract the action/value objects out of ActionsMap and use the union of those objects as our action type in our reducer.

type ItemActions = ActionsMap[keyof ActionsMap]

const itemReducer = (state: ItemState, action: ItemActions) : ItemState => { return state }
Enter fullscreen mode Exit fullscreen mode

The itemReducer doesn't actually do anything yet. We need to fill it out with the reducer logic. What you can't see here in the code sample is the ease of doing this. With the strongly typed ItemActions we will have code completion on the switch case statements we will write and type validation when we use the reducer.

Here is the filled out reducer function:

const itemReducer = (state: ItemState, action: ItemActions) : ItemState => {
    switch(action.action) {
        case 'setId':
            return {...state, id: action.value};
        case 'setTitle':
            return {...state, title: action.value};
        case 'setDescription':
            return {...state, description: action.value};
        case 'setQuantity':
            return {...state, quantity: action.value};
        default:
            console.error(`Action of ${action.action} is not supported`);
     }
     return state;
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately our default statement has an error:

Property 'action' does not exist on type 'never'.

That error occurs because we covered all possible valid cases. In order to account for a possible error, we can add a dummy action type. ItemActions becomes:

type ItemActions = 
    ActionsMap[keyof ActionsMap] | {action: 'other'}
Enter fullscreen mode Exit fullscreen mode

Using the mapped types with string template literals approach really shows its power when you need to add another property to the state. For example, let's add a boolean 'backordered' property. We just have to add 3 total lines of code.

type ItemState = {
    ...
    quantity: number,
    backordered: boolean
}

...

    switch(action.action) {
    ...
    case 'setQuantity':
        return {...state, quantity: action.value};
    case 'setBackordered':
        return {...state, backordered: action.value};
    ...
Enter fullscreen mode Exit fullscreen mode

Even better than the developer experience when adding or removing properties from state, is the experience of using the reducer. While it can't easily be shown in code snippets, the auto-complete and type validation is a game changer. No longer do you have to look back at the reducer to determine the correct spelling and what exactly type of value it is expecting. For example:

/* GOOD */
...
    dispatch({
        action: 'setQuantity',
        value: 5
    })
...

/* ERROR */
...
    dispatch({
        action: 'setQuantity',
        value: 'none'
   })
...
Enter fullscreen mode Exit fullscreen mode

Hope this approach helps you when creating and using reducers. Once I figured it out, it has saved me plenty of development time and frustration.

Here is the final TypeScript annotated reducer:

type ItemState = {
    id: string,
    title: string,
    description?: string,
    quantity: number,
    backordered: boolean,
}

type Setters<T> = {
    [P in keyof T as `set${Capitalize<string & P>}`]-?: T[P]
}

type ItemSetters = Setters<ItemState>

type ActionsMap = {
    [S in keyof ItemSetters]: {
        action: S,
        value: ItemSetters[S]
    }
}

type ItemActions = 
    ActionsMap[keyof ActionsMap] | {action: 'other'}

const itemReducer = (state: ItemState, action: ItemActions) : ItemState => {
    switch(action.action) {
        case 'setId':
            return {...state, id: action.value};
        case 'setTitle':
            return {...state, title: action.value};
        case 'setDescription':
            return {...state, description: action.value};
        case 'setQuantity':
            return {...state, quantity: action.value};
        case 'setBackordered':
            return {...state, backordered: action.value};
        default:
            console.error(`Action of ${action.action} is not supported`);
     }
     return state;
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)