DEV Community

Cover image for Typesafe useReducer with React Context
Simon Kardell
Simon Kardell

Posted on

Typesafe useReducer with React Context

In my opinion useReducer is an excellent option to use instead the regular useState hook for more complex state management. There is also a compelling argument that combining useReducer with React Context provides a state management solution that drastically reduce (😂) the need to include other libraries like redux.

If you're like me and prefer the additional safety that Typescript provides, there is not a lot of information out there on how to use it with useReducer.

If you're new to reducers there is a concise summary at the official documentation.

Example App

In the example we will build the compulsory Todo App.
To do this, we need to be able to add, delete and toggle the completed event of Todo items defined as:

// typings/todo.ts
export interface Todo {
  id: string;
  task: string;
  completed: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Actions

We want the actions to contain information on what type of action we want to do and also to carry an additional payload of data needed to perform the action.

To enable that, we can utilise the following interface as a base.

// typings/action.ts
export interface Action<T, P> {
  type: T;
  payload: P;
}
Enter fullscreen mode Exit fullscreen mode

Defining the actions for our Todo app, then becomes easy as

// context/todo/action.ts
import { Action, Todo } from '~typings';

export type TodoAction =
  | Action<'ADD', Todo>
  | Action<'DELETE', { id: string }>
  | Action<'TOGGLE', { id: string }>;
Enter fullscreen mode Exit fullscreen mode

Reducer function

In the reducer function Typescript will keep track of all all relations between action types and payload, via the union type TodoAction that was defined in the previous section.

// context/todo/reducer.ts
import { TodoAction } from './action';
import { Todo } from '~typings';

export const todoReducer = (state: Todo[], action: TodoAction): Todo[] => {
  switch (action.type) {
    case 'ADD':
      return [...state, action.payload];
    case 'TOGGLE':
      return state.map((todo) => {
        if (todo.id !== action.payload.id) {
          return todo;
        }
        return { ...todo, completed: !todo.completed };
      });
    case 'DELETE':
      return [...state.filter((todo) => todo.id !== action.payload.id)];
    default:
      return state;
  }
};
Enter fullscreen mode Exit fullscreen mode

If you only want to use a plain useReducer adding the following two lines to your component should suffice.

type reducerFunc = (state: Todo[], action: TodoAction) => Todo[];
const [state, dispatch] = useReducer<reducerFunc>(todoReducer, initialContext.todos);
Enter fullscreen mode Exit fullscreen mode

Context

If you instead want use your reducer to manage global state you can wrap it into a Context. This is also what will be used in the examples later on.
Here we create a context with our list of todo items and our dispatch function.

// context/todo/context.tsx
import React, { useContext, useReducer } from 'react';
import { Todo } from '~typings';
import { TodoAction } from './action';
import { todoReducer } from './reducer';

interface TodoContextI {
  todos: Todo[];
  dispatch: (arg: TodoAction) => void;
}

type reducerFunc = (state: Todo[], action: TodoAction) => Todo[];

const initialContext: TodoContextI = {
  todos: [],
  dispatch: () => console.error('Context not initialized')
};

const TodoContext = React.createContext<TodoContextI>(initialContext);

interface Props {
  children?: React.ReactNode;
}

export const TodoProvider: React.FC<Props> = ({ children }) => {
  const [state, dispatch] = useReducer<reducerFunc>(todoReducer, initialContext.todos);
  return <TodoContext.Provider value={{ todos: state, dispatch }}>{children}</TodoContext.Provider>;
};

export const useTodos = (): TodoContextI => useContext(TodoContext);

Enter fullscreen mode Exit fullscreen mode

Don't forget to wrap your components within your Context.Provider

// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { TodoProvider } from '~context/todo';

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <React.StrictMode>
    <TodoProvider>
      <App />
    </TodoProvider>
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

Read State

To read the state, there is no difference on how you would use it with a regular Context.

Here we just call the useTodo function which is just a short hand for useContext(TodoContext) that we declared with with the context. Then we map over the list and pass each Todo into a TodoCompoent.

export const TodoView: React.FC = () => {
  const { todos } = useTodos();
  return (
    <Container>
      {todos.map((todo) => (
        <TodoComponent {...todo} />
      ))}
      <TodoForm />
    </Container>
  );
};
Enter fullscreen mode Exit fullscreen mode

Use Dispatch

To use the dispatch function, we just deconstruct it from the context in similar manner as before. We can then trigger state changes completely typesafe. In the snippet below we trigger TOGGLE and DELETE actions to mutate the state.

// components/TodoComponent.tsx

export const TodoComponent: React.FC<Todo> = ({ task, completed, id }) => {
  const { dispatch } = useTodos();

  const handleCheckBoxClicked = (): void => {
    dispatch({ type: 'TOGGLE', payload: { id } });
  };

  const handleDeleteClicked = (): void => {
    dispatch({ type: 'DELETE', payload: { id } });
  };

  return (
    <TodoContainer done={completed}>
      <p>{task}</p>
      <div>
        <button onClick={onDeleteClick}>Delete</button>
        <input type="checkbox" checked={completed} onChange={handleCheckBoxClicked} />
      </div>
    </TodoContainer>
  );
};
Enter fullscreen mode Exit fullscreen mode

And in the code below we trigger an action to ADD a new todo item to our list.

export const TodoForm: React.FC = () => {
  const [state, setState] = useState('');
  const { dispatch } = useTodos();

  const handleInputChange = (e: ChangeEvent<HTMLInputElement>): void => {
    setState(e.target.value);
  };

  const handleSubmit = (): void => {
    dispatch({ type: 'ADD', payload: { task: state, completed: false, id: uuidv4() } });
    setState('');
  };

  return (
    <Container>
      <input type="text" value={state} onChange={handleInputChange} />
      <button disabled={!state} onClick={handleSubmit}>
        Add
      </button>
    </Container>
  );
};
Enter fullscreen mode Exit fullscreen mode

I hope that this example was of use to you. I think that useReducer is a good option to use when your state becomes large and/or you need to manipulate the state in a lot of different, but it does come with some additional boilerplate. Most of the time i still prefer to keep things simple with and go with useState. "I det enkla bor det vackra" - Ernst Kirchsteiger

If you want to have a look at the source you can find it here.

Take Care 👋

Discussion (1)

Collapse
tikka profile image
tikkasaurus

Awesome! 🙌 Beats Redux IMO