Find out our product. Here!

React Reducer with Algebraic Data Types (ADTs) and TypeScript

Algebraic Data Types (ADTs) are types that are composed of other types, typically represented as type variants or unions or product types

React Reducer with Algebraic Data Types (ADTs) and TypeScript

In modern React applications, managing state effectively is crucial, especially as the complexity of your application grows. One of the most powerful tools in your state management toolkit is the useReducer hook, which, when combined with TypeScript and Algebraic Data Types (ADTs), can give you both flexibility and type safety.

In this post, we'll explore how to implement a React Reducer using ADTs and TypeScript for a Pomodoro Timer example. By the end, you'll understand how to handle complex state transitions confidently and avoid runtime errors using TypeScript's type system.

What Are Algebraic Data Types (ADTs)?

Algebraic Data Types (ADTs) are types that are composed of other types, typically represented as unions or product types. In TypeScript, these are commonly known as Union Types.

For example, in a timer app, the timer can either be in the Work or Play phase. These are two possible states, making them a union type.

type Work = { type: "Work" };
type Play = { type: "Play" };
type Phase = Work | Play;

In this case, Phase is an ADT that can represent either a "Work" phase or a "Play" phase. This makes the state transitions explicit and type-safe.

Why Use TypeScript and ADTs with Reducers?

React's useReducer hook is an excellent tool for managing complex state. However, if your action types and state transitions aren't well-defined, you risk introducing bugs that are hard to track down. By using TypeScript and ADTs, we can make sure that every state transition is explicitly handled, reducing the risk of runtime errors and making our code more predictable.

Key Benefits of Using ADTs in TypeScript with Reducers:

  • Type Safety: TypeScript ensures that only valid state transitions occur.
  • Exhaustiveness Checking: The TypeScript compiler will alert you if you forget to handle a possible state transition.
  • Maintainability: By clearly defining your states and actions, you make your code easier to extend and maintain.

Building a Pomodoro Timer with ADTs and useReducer

Let's dive into a concrete example: building a Pomodoro Timer using a reducer with ADTs and TypeScript.

Defining the State and Actions

We'll start by defining our state and the possible actions that can be dispatched to our reducer.

type Work = { type: "Work" };
type Play = { type: "Play" };
type Phase = Work | Play;

type State = {
  seconds: number;
  isTicking: boolean;
  workTime: number;
  playTime: number;
  currentPhase: Phase;
};

type Start = { type: "Start" };
type Stop = { type: "Stop" };
type Reset = { type: "Reset" };
type Tick = { type: "Tick" };
type TogglePhase = { type: "TogglePhase" };
type SetTime = {
  type: "SetTime";
  payload: {
    phase: Phase;
    time: number;
  };
};

type Action = Start | Stop | Reset | Tick | TogglePhase | SetTime;

Reducer Functions

We can now define the behavior of each action in the form of pure functions that take in the current state and return a new state based on the dispatched action.

Handling the Start Action

const start = (state: State): State => ({ ...state, isTicking: true });

Handling the Stop Action

const stops = (state: State): State => ({ ...state, isTicking: false });

Handling the Tick Action

const tick = (state: State): State =>
  state.isTicking && state.seconds > 0
    ? { ...state, seconds: state.seconds - 1 }
    : state;

Handling Phase Toggles

const togglePhase = (state: State): State => {
  const nextPhase: Phase =
    state.currentPhase.type === "Work" ? { type: "Play" } : { type: "Work" };
  const seconds =
    nextPhase.type === "Work" ? state.workTime * 60 : state.playTime * 60;
  return { ...state, currentPhase: nextPhase, seconds, isTicking: true };
};

Using the Exhaustive Helper

To ensure we handle all possible cases and catch unhandled transitions, we can use the exhaustive check helper:

const exhaustive = (x: never): never => {
  throw new Error("Unhandled action type: " + JSON.stringify(x));
};

The Complete Reducer Function

Now, let's bring everything together in our reducer function:

const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "Start":
      return start(state);
    case "Stop":
      return stops(state);
    case "Tick":
      return tick(state);
    case "Reset":
      return reset(state);
    case "TogglePhase":
      return togglePhase(state);
    case "SetTime":
      return setTime(state, action);
    default:
      return exhaustive(action);
  }
};

Initial State

Finally, we define the initial state of the app:

const initialState: State = {
  seconds: 0,
  isTicking: false,
  workTime: 25,
  playTime: 5,
  currentPhase: { type: "Work" },
};

Conclusion

By using Algebraic Data Types (ADTs) and TypeScript with React's useReducer hook, we ensure that our state transitions are explicit, predictable, and maintainable. This pattern is especially useful in more complex applications where the state can take on multiple forms.

TypeScript's exhaustive checking and the explicit nature of ADTs help prevent runtime errors, making your code robust and easier to reason about.

If you're working on a complex state-driven React application, consider adopting ADTs with TypeScript to bring more type safety and structure to your state management!

Further Reading

Read Also :
Holla, we share any interesting view for perspective and education sharing❤️

Post a Comment

© elgharuty. All rights reserved. Developed by Jago Desain