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);
}
};
We can start looking at each action:
-
ADD_BOX
: we add a new box to the store -
SELECT_BOX
: we select a box ( to delete, resize or move ) -
MOVE_BOX
: we move a box to the new position -
RESIZE_BOX
: we resize the box -
DELETE
: we delete the selected box -
APPLY_PATCHES
: we use Immer’sapplyPatches
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: {},
};
}
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;
});
}, []);
Don’t worry, I will explain in details:
- The
undoStack
and theundoStackPointer
: 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,
}
});
};
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)
}
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)
};
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
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.
Source code: https://github.com/superdev163/immer-square
Live demo: https://immer-square.web.app/
Top comments (0)