Undo/Redo with Zustand & React Query (Part 2)

Imagine rewinding your app's state like a video tape, no bloat. Part 2 unveils function-based undo/redo magic with Zustand and React Query.

Function-Powered Time Travel: Zustand Undo/Redo Meets React Query — theAIcatchup

Key Takeaways

  • Function pairs beat snapshots for Zustand + React Query: lighter, precise.
  • Grouping bundles multi-mutations into one undo—UX perfection.
  • Prevents no-op history bloat; scales to AI-driven, collaborative apps.

State time machines exist.

Picture this: you’re deep in a React app, todos flying, server syncs humming via React Query. One drag, one edit—bam, mess up? Cmd+Z. No tears, no reloads. That’s the undo/redo dream with Zustand and React Query we’re unpacking today, Part 2 of our snapshot saga.

Last time? Arrays of past, present, future states. Slick on paper. But in my own project—dragging tasks across a board, querying servers non-stop—it bloated fast. React Query’s caching wizardry already tracks data; why duplicate with fat snapshots?

Here’s the pivot. Snapshots? Out. Instructions? In. Inspired by Rocicorp’s undo lib, we store pairs of functions: undo this, redo that. Lightweight. Precise. Like Git diffs, not full clones—my unique twist: this echoes version control’s soul, predicting it’ll power collaborative UIs where multiple users rewind shared chaos without exploding storage.

Instead of storing what the state was, what if we stored how to restore it?

Boom. Entries array holds these pairs, index tracks your spot. Undo? Fire the current entry’s undo fn, slide index back. Redo? Forward, execute redo. Grouping? Bundle multi-step drags into one Cmd+Z swoop.

The UndoManager Beast

Core class. Simple, async-ready for server pings.

type Entry = {
  groupId?: number;
  undo: () => Promise<void>;
  redo: () => Promise<void>;
};

class UndoManager {
  // ... state with entries[], index
}

canUndo, canRedo—booleans for UI buttons. add() slices future history (no branching timelines yet), pushes new pair. Undo decrements index, runs entry.undo(). Redo increments, entry.redo(). Grouping via startGroup()/endGroup() tags entries same ID—undo recurses the batch.

But how to generate those fns? Snapshots sneak back, sorta. Wrap mutations:

const runWithSnapshotUndo = async (execute) => {
  const beforeSnapshot = await getSnapshot();
  const result = await execute();
  const afterSnapshot = await getSnapshot();
  if (areSnapshotsEqual(beforeSnapshot, afterSnapshot)) return result;
  await undoManager.add({
    undo: () => applySnapshot(beforeSnapshot),
    redo: () => applySnapshot(afterSnapshot),
  });
  return result;
};

Genius guard: areSnapshotsEqual skips no-ops. Click save on unchanged todo? No dummy history entry. Users won’t Cmd+Z into confusion.

Your mutation? addTodoWithUndo(payload) = runWithSnapshotUndo(() => addTodoMutation.mutateAsync(payload)). Boom—undo baked in.

Why Snapshots Lite Beats Redux Bloat?

Redux? Undo middleware exists, but it’s opinionated, boilerplate-heavy. Zustand? Tiny store, hooks galore. React Query? Mutations cached, optimistic updates native. Pair ‘em: undo without Zustand duplicating Query’s data dance.

In my project—Kanban-ish board—drags hit three mutations: reposition A, shift B/C. Naive snapshots? Triple redundancy. Functions? One group:

undoManager.startGroup();
await updateTaskA();
await shiftTaskB();
await shiftTaskC();
undoManager.endGroup();

One undo nukes the lot. Feels atomic, like users expect.

But wait—server state? React Query’s mutateAsync awaits, so undo fns re-apply snapshots via applySnapshot, maybe triggering Query invalidations. Hybrid heaven: client optimism + server truth, rewindable.

Grouping: The Multi-Step Savior

Drag-drop hell without it? Three undos for one gesture—users rage-quit. groupId links siblings. Undo peeks next entry’s group, chains undos recursively. Same for redo. Elegant recursion, no infinite loops if you index right.

Tradeoff? Functions capture closures—stale state risk if not snapshotted fresh. Mitigate with deep equality checks, or immer drafts for immutable snapshots. My prediction: as React apps go real-time (think Figma clones), this pattern hits 1.0 in Zustand middleware.

The areSnapshotsEqual check might seem unnecessary, but it prevents a common bug: adding no-op entries to history.

Spot on. No-ops poison UX.

Real-World Gotchas (And Fixes)

Async hell. Mutations race? Sequence ‘em in runWithSnapshotUndo. Snapshots from Zustand? Serialize Query cache slices—tricky, but queryClient.getQueryData flattens nicely.

Performance? Function storage light till 100 steps, then prune old entries. UI: Disable buttons via canUndo/canRedo in Zustand slice.

Test it. Fork a todo app, wire this. Feels like macOS Finder—intuitive time travel.

And here’s the futurist fire: AI agents incoming. They’ll mutate UIs programmatically. Undo? Essential. This stack scales to agent swarms rewinding experiments. Platform shift? Apps become reversible sandboxes.

Short history lesson—Photoshop’s 1990s layers echoed this; now frontend catches up.

Why Does Undo/Redo Matter for React Devs?

Skeptical? “My app’s simple.” Today. Tomorrow? Users demand polish. No more “oops, improvise reload.”

Prod apps shippable faster—beta test wild edits, rewind. Accessibility win: keyboard warriors Cmd+Z freely.

Corporate spin check: Tanstack docs hype Query mutations, but no undo mention. Devs hack it themselves— this fills the gap.

Will This Break My Stack?

Nope. Zustand store exposes useUndoStore:

const { canUndo, undo, redo } = useUndoStore();

Buttons: <button disabled={!canUndo} onClick={undo}>↶</button>.

Scale to ngrx? Portable pattern—functions universal.

Exhausted? Not yet. Dive into code repo (link Part 1), tweak for your Query keys.

This isn’t hype. It’s the undo era dawning—grab it.


🧬 Related Insights

Frequently Asked Questions

How to implement undo/redo with Zustand and React Query? Wrap mutations in runWithSnapshotUndo, using before/after snapshots for fn pairs. Group multi-steps.

Does Zustand undo work with server state? Yes—React Query mutations await, snapshots capture post-sync state for accurate rewinds.

What’s better: snapshots or function pairs? Functions for Query stacks—lighter, no redundancy. Snapshots shine in pure client flows.

Elena Vasquez
Written by

Senior editor and generalist covering the biggest stories with a sharp, skeptical eye.

Frequently asked questions

How to implement undo/redo with Zustand and React Query?
Wrap mutations in `runWithSnapshotUndo`, using before/after snapshots for fn pairs. Group multi-steps.
Does Zustand undo work with server state?
Yes—React Query mutations await, snapshots capture post-sync state for accurate rewinds.
What's better: snapshots or function pairs?
Functions for Query stacks—lighter, no redundancy. Snapshots shine in pure client flows.

Worth sharing?

Get the best AI stories of the week in your inbox — no noise, no spam.

Originally reported by Dev.to

Stay in the loop

The week's most important stories from theAIcatchup, delivered once a week.