How to use UseReducer with TypeScript
BCiriak Avatar
by BCiriakDEC 02, 2022 | 15 min read

React useReducer with TypeScript

UseReducer and TypeScript is a good combo to tackle more complex component state problems. But how do they work together?


useReducer hook is one of Reacts API features that helps us manage state in React components. It has been added to react in version 16.8 along with other hooks.

Managing state with useReducer

You can think of useReducer as older brother of useState. Older meaning it can handle more complicated things.

Following is a diagram of simple Todo App that we will reconstruct with useReducer.

Simple Todo App with useReducer diagram

On the left, we have mock of the app and on the right is useReducer with its 4 main parts, state, dispatch, action and reducer.

State

Well, state is the container that holds our information. This state can hold much more complex data than useState. Not that useState can't hold complex data, but its use is not suited for such a complex data.

For managing more complex state make sure to look at Context API with useReducer and TypeScript.

Dispatch

Dispatch is a special function given to us by useReducer and it is some kind of messenger between our UI and state. In this example we could have different changes that we would want to do to our state. Like adding a todo or removing todo. Dispatch is the one carrying the message what we want to do with our state. The message would be action in our case.

Action

Action is plain object that specifies what we want to do with our state. Here is an example of adding a todo action:

example.tsx
{
	type: "ADD_TODO",
	todo: "Buy Milk"
}

So dispatch is the messenger and action is the message.

Reducer

Now we come to the last piece of useReducer, the brains. Reducer itself is where all of the logic happens. Reducer is pure function, that looks at action and decides how to change state. It has access to currentState and that is how we update it. But we never mutate state in reducer, we just copy it and return new state.

Back to the Todo App

Looking at the previous picture, we see that there is already some initial state, todos array. If we want to add a Todo to this array, we would click on Add Todo button, which would fire useReducers' dispatch function with appropriate action. In this case "ADD_TODO", and it would be handled by reducer, which would update the state. React would look at the state, see the change and re-render our UI.

Simple Todo App with useReducer diagram 2

Alright, to better understand how useReducer works, especially with TypeScript, let's build this simple app.

Learn more about advantages of TypeScript.

Clean React TypeScript app with Vitejs

I am gonna create barebones React TypeScript app with Vitejs, an amazing tool for creating Front-end applications.

If you don't know Vite, have a look at Create React App With Vite.

To create React app with TypeScript, make sure to use this command: npm create vite@latest my-react-app --template react-ts

First thing I will do, is to remove all the noise from App.tsx and get to this point:

App.tsx
function App() {
  return (
    <div>
      <h1>Todos</h1>
    </div>
  )
}

export default App

I also don't like the dummy styling included with Vite, so I just removed everything from index.css. Now if we run npm run dev, we should be all set.

Adding useReducer

Let's add useReducer to our app. And we will do this in a silly way, so that we can understand it a little better.

To initialise useReducer, we need to supply 2 arguments to this hook. Reducer and initial state. As I mentioned before, reducer is pure function returning state. initialState is object holding our state/data.

App.tsx
import { useReducer } from 'react'

  const initialState = {
    todos: [],
  }

  const reducer = (state: any, action: any) => {
    return state
  }

  function App() {
  const [state, dispatch] = useReducer(reducer, initialState)

    return (
      <div>
        <h1>Todos</h1>
        <pre>{JSON.stringify(state, null, 2)}</pre>
      </div>
    )
}

export default App

First, we imported the useReducer hook itself from 'react'. We created initialState object with one key todos, initialised to an empty array. Underneath is our dummy reducer, doing only one thing right now, returning state. It also has 2 arguments (for now typed as any), first one is the actual state that we have access to in reducer and second is our action.

Inside our App component we initialise the useReducer with our reducer and initialState.

Last little detail is the pre tag, to display our state.

JSON.stringify is function to, well, make string out of JSON. But we can nicely format the outputted string, by supplying 2 more arguments. Second argument is 'replacer' and this can be function, array or null, used in case we want to somehow manipulate the output, e.g. we don't want some of the properties to be shown in the JSON string. And the last one is 'space', meaning how we want to indent and line break characters, for better readability.

Using our useReducer to add a Todo

First of all, we need a button to be able to add a Todo.

App.tsx
<button onClick={() => dispatch({ type: 'addTodo', payload: 'Buy Milk' })}>
  Add Todo
</button>

We are using dispatch function given to us by the hook and supplying it with hardcoded action object for now. Don't worry, we will improve this solution.

The only thing missing is now some logic in reducer that will handle the action and update state accordingly.

App.tsx
const reducer = (state: any, action: any) => {
  switch (action.type) {
    case 'addTodo':
      let newTodos = [...state.todos, action.payload]
      return { ...state, todos: newTodos }
    default:
      throw new Error(\`Unhandled action type: \${action.type}\`)
  }
}

For those of you familiar with Redux, this syntax in reducer should be very simple to understand. switch statement is perfect for this situation, but you could very well use if else statement to make this logic work.

Right now we have 2 options how the switch can go. Default state will throw error if the action.type will be unknown, and addTodo will add new todo to our state. Remember, we don't want to mutate the state, that is why we first copy the todos from current state by destructuring it ...state.todos and then we simply add our new todo from action.payload to that new array.

Last thing to do, is to copy our whole state, again by destructuring the state object and overwriting the todos with our newTodos array.

We should now be able to add our hardcoded todo to the state, by clicking the Add Todo button.

Improving useReducer with typing

Looking at our App, besides those two any parameters in the reducer function, it is just regular old JavaScript component. It would be shame not to use the power of TypeScript, so let's get into it.

Good place to start is our state. Let's give our state proper types so that we can get some benefits of TypeScript:

App.tsx
type Todo = {
  id: number
  description: string
}

type State = {
  todos: Todo[]
}

const initialState: State = {
  todos: [],
}

Here we created 2 simple types, Todo and State. Now we know exact structure of our todo list. Every todo needs to have an id and a description, and our todos array is array of those todos.

In a same way we can add types to the reducer function.

App.tsx
const reducer = (
  state: State,
  action: { type: string; payload: Todo }
): State => {
  switch (action.type) {
    case 'addTodo':
      let newTodos = [...state.todos, action.payload]
      return { ...state, todos: newTodos }
    default:
      throw new Error(\`Unhandled action type: \${action.type}\`)
  }
}

Pretty simple, we just got rid of those any types, which by the way are never a good sign. You should never use those. Also, check out the return type of the reducer. It always returns State.

With this change, we also need to update the dispatch function call in our onClick. It is just a simple change of switching the payload to the correct type:

App.tsx
<button
  onClick={() =>
    dispatch({
      type: 'addTodo',
      payload: { id: 123, description: 'Buy Milk!' },
    })
  }
>
  Add Todo
</button>

Awesome, with these changes we know what to expect in our action, and in our state.

Adding more data to the state

Having hardcoded value as a Todo is truly not very useful. Let's change that by adding an input field, and handle all of it with our useReducer. But before we do that, we can improve our reducer by removing plain strings from our cases.

It is great practice to extract usages of plain strings into constants, this way we will not make simple typo and introduce bug.

App.tsx
...
const ADD_TODO = 'addTodo'
...
switch (action.type) {
	case ADD_TODO:
...

As we will add more types of actions, we will just add corresponding constants.

Back to the input for our todo.

App.tsx
<input
  type="text"
  value={state.newTodoValue}
  onChange={(e) =>
    dispatch({
      type: UPDATE_VALUE,
      payload: { description: e.target.value },
    })
  }
/>

This is how the input looks like. We are handling its value with our state, which we will add to initialState in just a second. Another thing is, the UPDATE_VALUE. This means we will need to also update our reducer to handle this action case.

App.tsx
...
const ADD_TODO = 'addTodo'
const UPDATE_VALUE = 'updateValue'
...
type State = {
	todos: Todo[]
	newTodoValue: string
}

const initialState: State = {
	todos: [],
	newTodoValue: '',
}
...

This should be pretty straight forward, we just took advantage or useReducer and added another piece of state, which will handle the input value. And we just initialised it to an empty string.

Onto the reducer:

App.tsx
const reducer = (
  state: State,
  action: { type: string; payload: Todo }
): State => {
  switch (action.type) {
    case ADD_TODO:
      let newTodos = [...state.todos, action.payload]
      return { ...state, todos: newTodos, newTodoValue: '' }
    case UPDATE_VALUE:
      return { ...state, newTodoValue: action.payload.description }
    default:
      throw new Error(\`Unhandled action type: \${action.type}\`)
  }
}

Notice that we added the newTodoValue to our ADD_TODO case and set it to an empty string. This is just to clear the input after we added the todo.

The UPDATE_VALUE case is just updating the input field and the description of new todo.

One last change we need to do, is the dispatch action in our button since we want to have different IDs for our todos and more importantly, we want the description to be the value of the input.

App.tsx
<button
  onClick={() =>
    dispatch({
      type: ADD_TODO,
      payload: {
        id: state.todos.length + 1,
        description: state.newTodoValue,
      },
    })
  }
>
  Add Todo
</button>

Phew, that was quite a few small tweaks. But now we should have fully working Todo App where we manage state with the use of useReducer hook and TypeScript.

Here is the full App component if you want to just glance through it all at once:

App.tsx
import { useReducer } from 'react'

const ADD_TODO = 'addTodo'
const UPDATE_VALUE = 'updateValue'

type Todo = {
  id?: number
  description: string
}

type State = {
  todos: Todo[]
  newTodoValue: string
}

const initialState: State = {
  todos: [],
  newTodoValue: '',
}
const reducer = (
  state: State,
  action: { type: string; payload: Todo }
): State => {
  switch (action.type) {
    case ADD_TODO:
      let newTodos = [...state.todos, action.payload]
      return { ...state, todos: newTodos, newTodoValue: '' }
    case UPDATE_VALUE:
      return { ...state, newTodoValue: action.payload.description }
    default:
      throw new Error(\`Unhandled action type: \${action.type}\`)
  }
}

function App() {
  const [state, dispatch] = useReducer(reducer, initialState)

  return (
    <div>
      <h1>Todos</h1>
      <input
        type="text"
        value={state.newTodoValue}
        onChange={(e) =>
          dispatch({
            type: UPDATE_VALUE,
            payload: { description: e.target.value },
          })
        }
      />
      <button
        onClick={() =>
          dispatch({
            type: ADD_TODO,
            payload: {
              id: state.todos.length + 1,
              description: state.newTodoValue,
            },
          })
        }
      >
        Add Todo
      </button>
      <pre>{JSON.stringify(state, null, 2)}</pre>
    </div>
  )
}

export default App

I hope you liked this more in depth article. If you did leave a reaction or a comment down below.

Keep learning and see you in the next one!

Connect with me on social media ✨

Join my newsletter, to receive JavaScript, TypeScript, React.js and more news, tips and other goodies right into your mail box 📥. You can unsubscribe at any time.

©2024 JSTopics. All rights reserved.