Refactor Redux Reducers Like a Pro With TypeScript

Refactor Redux Reducers Like a Pro With TypeScript

TypeScript is a perfect match for React reducers because it has an intelligent type system, making refactoring much easier than it is with plain JavaScript. The topic of reducers is also an easy way to start learning and enjoying TypeScript because reducers' function signature is very simple: (state, action) => state.

First reducer using TypeScript

I am going to use an inventory system as our context. Let's start with a reducer in plain JavaScript:

function inventoryApp(state, action) {
  return state
}

The reducer doesn't react to any actions yet, and the given state will therefore be returned regardless of the function input.

Regardless of whether you are using TypeScript, thinking about the data model is usually a good place to start, state shape is also used to refer data model of the store state. I use TypeScript to model the state.

interface InventoryAppState {
  items: ReadonlyArray<{ id: number, name: string }>,
  selectedInventoryItem: number
}

First, I defined the state with interface.

In languages like C# and Java, interfaces are commonly used to define contracts for classes. These contracts include information on which methods, properties, events or indexers class should implement in order to satisfy the requirements of the interface.

This thinking applies also to the TypeScript, but you can see also a lot usage on defining the shape of the data, like our application state.

items (on the second line) is defined as ReadonlyArray. TypeScript has built-in support for read-only arrays. You should not mutate data inside a reducer, and ReadonlyArray will keep you safe from all mutating operations. No more remembering whether it was the slice or splice array method that returned a new array!

The read-only array uses generics, and you can tell by the angle brackets. Generics are outside the scope of this blog post, but you can read more here.

To use the InventoryAppState interface, I need to add a type to the state and the return value.

function inventoryApp(state:InventoryAppState, action):InventoryAppState {
  return state
}

Small changes, but I can already see the benefits in my editor in the form of fast and accurate auto-completion.

Visual Studio Code with React reducer source code. The editor showing benefits of the type system.

How about the parameter action? By adding type name after the action, my editor suggest adding an import statement where the interface is defined. Handy!

Adding right import statement from Visual Studio Code

The Action contains type, the only mandatory field for Redux actions. Flux Standard Actions is an unofficial specification for remedying this too dynamic nature of the action definition. If you want the whole action to be type-safe, then read my blog post Type-safe Flux Standard Actions (FSA) in React Using TypeScript FSA.

Making Changes To The State

Things change when the project goes forward. Let's assume that things have become more clear and I now know the terminology in this domain more precisely.

Items were too generic; I now know that inventory is all about parts, and I can change the generic term "items" to "parts."

const inventoryApp = (state: InventoryAppState, action: Action): InventoryAppState => {
  if (isType(action, setSelectedPart)) {
    return {
      ...state,
      selectedPart: action.payload
    }
  }

  if (isType(action, removePart)) {
    return {
      ...state,
      parts: state.parts.filter(item => item.id !== action.payload)
    }
  }
  ... more actions related to the list ...

  return state
}

export default inventoryApp

Refactoring names is an easy task when the system is being typed properly with TypeScript. In Visual Studio Code, I hit F2 and type the new name, causing all occurrences (in all files) to update.

Refactoring safely with TypeScript

It is time to refactor handling of the parts to a separate reducer.

Move Code to Separate Reducer

Some of the actions that are handled inside a single reducer are getting out of hand. I need to refactor the code to the way it was before it became an intractable mess. Before changing those actions, I'll extract one more interface.

interface Part { 
  id: number, 
  name: string 
}

interface InventoryAppState {
  parts: ReadonlyArray<Part>,
  selectedPart: number
}

I can now reuse the Part definition.

Back to splitting code between multiple reducers.

const rootReducer = (state: InventoryAppState, action: Action): InventoryAppState => {
  if (isType(action, setSelectedInventoryItem)) {
    selectedPart: action.payload
  }

  return state
}

const partsReducer = (parts:ReadonlyArray<Part>, action:Action):ReadonlyArray<Part> => {
  if (isType(action, removeInventoryItem)) {
    return parts.filter(item => item.id !== action.payload)
  }

  return parts
}

export default (state:InventoryAppState, action:Action) => ({
  ...rootReducer(state, action),
  parts: partsReducer(state.parts, action)
})

Source code changes might take few iterations to digest.

Side note: I would normally move each reducer to a separate file, but a single file is easier to read in this example.

There are now two reducers (three if you count the one that glues them all together): the root reducer and the parts reducer. Look at the signature of the parts reducer, which takes and returns parts. I don't need to worry about the rest of the state; simplifying the code when creating new parts-related actions.

Before refactoring:

return {
  ...state,
  parts: state.parts.filter(item => item.id !== action.payload)
}

After refactoring:

return parts.filter(item => item.id !== action.payload)

We can glue the reducers back together with this function:

export default (state:InventoryAppState, action:Action) => ({  
  ...rootReducer(state, action),
  parts: partsReducer(state.parts, action)
})

It contains many new ECMAScript features. If you're unfamiliar with them, you can paste the source code into Babel REPL and you'll see the ES3 implementation. Babel is a tool for expressing new language features in older syntax (transpiling).

Conclusion

Reducers are a great way to start using TypeScript's type system:

  • Function signatures are simple ((state, action) => state).
  • Few third party libraries are involved (the middleware is set up elsewhere).
  • Type definitions in a store state have a large impact on the application and require little effort.

The next step might be throwing away the React component propTypes (runtime warnings) and replacing it with proper typing (compile-time errors).

I hope this blog post has been useful. Share it if you liked it!

Discuss on Hacker News