DEV Community

Senior Developer
Senior Developer

Posted on

Time Travel in React with Immer: A Step-by-Step Tutorial

Overview

In the ever-evolving landscape of front-end development, the ability to manipulate state effectively is a crucial skill. Imagine having the power to rewind and fast-forward through your application's state changes, pinpointing bugs and gaining a deeper understanding of your code's behavior. Welcome to the world of time travel debugging.
In this in-depth tutorial, we will dive into the realm of time travel debugging in React, leveraging the remarkable capabilities of Immer. Immer is a powerful library that simplifies state management by enabling you to work with immutable data structures in a mutable-like manner. But it doesn't stop there – Immer's magic truly shines when combined with time travel debugging, offering developers a profound way to visualize and debug state changes over time.
Throughout this tutorial, we will guide you step-by-step on how to integrate Immer into your React application and unlock the captivating potential of time travel debugging. We will cover essential concepts such as immutability, state transitions, and the magic of Immer's produce function. As we progress, you will witness firsthand how to set up your development environment for time travel debugging, manipulate and navigate state snapshots, and even replay past state sequences.

The demo

The upcoming demo will feature a compact app where users can create, resize, and move boxes within an interactive canvas. Notably, the app will incorporate an undo-redo feature, allowing users to easily navigate through their actions. This seamless blend of functionalities offers users the freedom to experiment while ensuring they can effortlessly backtrack or redo their steps. This engaging experience will spotlight the potential of modern front-end development by showcasing a seemingly simple concept turned into a powerful application.

Set up

Actually we only need Immer as a compulsory library for this demo, but I also install theme-ui and react-resizable to speed up the development time.

The Reducer

First thing we need is a reducer so we can listen to actions and return the desired results:

import  { produceWithPatches, applyPatches } from "immer";

export const boxAction = (draft, action) => {
  const { width, height, id, color, position } = action;
  let box = draft.boxes[draft.selectBox];

  switch (action.type) {
    case "ADD_BOX":
      draft.boxes[id] = {
        id,
        width,
        height,
        color,
        position,
      };
      break;
    case "SELECT_BOX":
      draft.selectBox = id
      break;
    case "MOVE_BOX":
      if (!box) return;
      box.position = position;
      break;
    case "RESIZE_BOX":
      if (!box) return;
      box.width = width;
      box.height = height;
      box.position = position;
      break;
    case "DELETE":
      delete draft.boxes[draft.selectBox];
      break;
    case "APPLY_PATCHES":
      return applyPatches(draft, action.patches);
  }
};
Enter fullscreen mode Exit fullscreen mode

We can start looking at each action:

  1. ADD_BOX : we add a new box to the store
  2. SELECT_BOX: we select a box ( to delete, resize or move )
  3. MOVE_BOX: we move a box to the new position
  4. RESIZE_BOX: we resize the box
  5. DELETE: we delete the selected box
  6. APPLY_PATCHES: we use Immer’s applyPatches function for this. During the run of a producer, Immer can record all the patches that would replay the changes made by the reducer. This function allows us to patch the state Then we will create a producer using Immer’s produceWithPatches and create a initial state:
export const patchGeneratingBoxesReducer = produceWithPatches(boxAction);

export function getInitialState() {
  return {
    boxes: {},
  };
}
Enter fullscreen mode Exit fullscreen mode

The dispatch function

Here’s the thing, we need a function that will be called every time we emit an action, and this function should be implemented with a stack, since we will put every “patch” into the stack

 const undoStack = useRef([]);
  const undoStackPointer = useRef(-1);

  const dispatch = useCallback((action, undoable = true) => {
    setState((currentState) => {
      const [nextState, patches, inversePatches] = patchGeneratingBoxesReducer(
        currentState,
        action
      );
      if (undoable) {
        const pointer = ++undoStackPointer.current;
        undoStack.current.length = pointer;
        undoStack.current[pointer] = { patches, inversePatches };  
      }
      return nextState;
    });
  }, []);
Enter fullscreen mode Exit fullscreen mode

Don’t worry, I will explain in details:
The undoStack and the undoStackPointer: undoStack is a reference to an array that will store the history of state changes (patches) for undoable actions. undoStackPointer is a reference to a number that keeps track of the current position in the undo stack.
The undoable parameter: A boolean flag indicating whether the action should be considered undoable
Managing undo history: If the action is marked as undoable (undoable is true), the code adds the patches and inverse patches to the undoStack for potential undo operations.
undoStackPointer is incremented to point to the current position in the stack.
The previous undoable actions beyond the pointer are removed from the stack to maintain a linear history
Overall, this code manages a history of state changes in an undo stack, allowing users to perform undoable actions on a set of "boxes" while maintaining the ability to revert those changes. The dispatch function updates the state and also manages the undo stack accordingly.

The Buttons

We would have 4 buttons in this application: Create - Delete - Undo - Redo. Let’s go into each of them:
Create Button:

const createButton = () => {
    const width = Math.floor(Math.random() * (300 - 100 + 1) + 100)
    const height = Math.floor(Math.random() * (300 - 100 + 1) + 100)
    dispatch({
      type: "ADD_BOX",
      width: width,
      height: height,
      id: uuidv4(),
      color:
        `#` +
        Math.floor(16777215 * Math.random()).toString(16),
      position: {
        x: window.innerWidth * 0.8 / 2 - width / 2,
        y: window.innerHeight / 2 - height / 2,
      }
    });
  };
Enter fullscreen mode Exit fullscreen mode

When crafting a fresh box, you'll observe that I've introduced randomness to its dimensions encompassing width and height, as well as imbuing it with a distinctive hue and a one-of-a-kind identifier. This newly generated box is thoughtfully positioned at the screen's center by skillfully manipulating the x-axis and y-axis coordinates. Ultimately, the culmination of these steps is manifested through the invocation of the aforesaid "dispatch" function, effectively bringing the envisioned creation to life.
Delete Button:

const deleteButton = () => {
    dispatch({
      type: "DELETE",
    });
    dispatch({
      type: "SELECT_BOX",
      id: null
    }, false)
  }
Enter fullscreen mode Exit fullscreen mode

There isn't a great deal to elaborate on in this context; we simply initiate a dispatch action to delete, subsequently ensuring that the box is unselected.
Undo and Redo buttons

const undoButton = () => {
    if (undoStackPointer.current < 0) return;
    const patches = undoStack.current[undoStackPointer.current].inversePatches;
    dispatch({ type: "APPLY_PATCHES", patches }, false);
    undoStackPointer.current--;
    dispatch({
      type: "SELECT_BOX",
      id: null
    }, false)
  };

  const redoButton = () => {
    if (undoStackPointer.current === undoStack.current.length - 1) return;
    undoStackPointer.current++;
    const patches = undoStack.current[undoStackPointer.current].patches;
    dispatch({ type: "APPLY_PATCHES", patches }, false);
    dispatch({
      type: "SELECT_BOX",
      id: null
    }, false)
  };
Enter fullscreen mode Exit fullscreen mode

I put these 2 functions together so you can see the contrasts. We won’t allow the users to be undoed if they are at the bottom of the stack, and won’t allow the users to redo if they are on top of the stack. Then we get the patches and apply it using the “APPLY_PATCHES” action. Remember to unselect the box to avoid bugs

The Results

Image description

Conclusion

By the end of this tutorial, you will not only have a firm grasp of how to implement time travel with Immer in React, but you will also possess a powerful debugging technique that can drastically improve your development workflow. Join us on this journey to unravel the secrets of time travel and revolutionize the way you approach debugging in React applications.

Top comments (0)