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!