RORK LABJP
MAX — Rork Max builds native Swift apps instead of React Native, supporting iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageNATIVE — It reaches AR/LiDAR, Metal 3D, widgets, Dynamic Island, Live Activities, HealthKit, NFC, and on-device Core MLPUBLISH — Two-click App Store publishing sharply shortens the submission work for solo developersSWIFT — Describe your app in plain English and Rork generates working code for iOS, Android, and webGROWTH — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growthPRICING — It is free to start, with paid plans from $25/month and Rork Max at $200/monthMAX — Rork Max builds native Swift apps instead of React Native, supporting iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageNATIVE — It reaches AR/LiDAR, Metal 3D, widgets, Dynamic Island, Live Activities, HealthKit, NFC, and on-device Core MLPUBLISH — Two-click App Store publishing sharply shortens the submission work for solo developersSWIFT — Describe your app in plain English and Rork generates working code for iOS, Android, and webGROWTH — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growthPRICING — It is free to start, with paid plans from $25/month and Rork Max at $200/month
Articles/Dev Tools
Dev Tools/2026-07-12Advanced

Undo That Jumps to the Wrong Place — Designing Edit History You Can Trust

Snapshotting the whole state on every edit crushes memory after a few dozen steps. Here's an undo/redo built on command history plus snapshots, with coalescing, a depth cap, and cross-session persistence, in working TypeScript.

undo-redoreact-native12expo11architecture12state-management2

Premium Article

A reader was compositing a wallpaper in one of my apps: nudging the background color, dropping in about ten stickers, and then accidentally brushing the clear-all button. They tapped undo in a panic — but instead of landing just before the wipe, they ended up at some half-finished state five steps back. "If undo doesn't work, I'm too scared to touch anything." One short line, and my stomach dropped.

I run several apps with editing surfaces as an indie developer. Over time I stopped thinking of undo/redo as a nice-to-have. It's the floor that decides whether a user feels safe moving their hands at all. And when I actually built it, I learned the hard way that the most naive approach is the one that breaks down later.

This article designs undo/redo for an editing app on React Native (Expo) in a form that holds up in long-term use. Working TypeScript throughout, crossing the three walls you always hit in production: memory pressure, handling fine-grained tweaks, and keeping history alive after a restart.

Why snapshotting the whole state falls apart

The first idea that comes to mind is to copy the entire state on every action and push it onto an array. Undo just restores the previous copy. It's a few dozen lines.

But in an editing app, that simplicity becomes the weak point. Say the wallpaper composite holds crop data, a few dozen layers, and filter parameters. If one state is 80KB and the user makes 100 edits, the history alone carries 8MB. Count each slider tick as a step and you blow past that in a couple of minutes.

There's a second problem: the history never records what changed. You want the button to read "Undo color change," but you can't recover a diff from a full copy.

ApproachMemoryMeaning of the diffFits when
Snapshot (full state)Large (state size × steps)NoneState is small, few actions
Command (action diff)Small (diff size × steps)Present (can be labeled)Editing continues, deep history

So for apps where editing goes on and on, we make the command approach the default. But we don't throw snapshots away. In the cross-session persistence below, snapshots are exactly what save us. The two aren't rivals — they just have different jobs.

Make the command the smallest unit

A command is one unit of an action, holding a matched pair: "do" and "undo." It carries only the diff, so it's light, and you can attach a label. Let's shape it with a type first.

// One step of work. do and undo must be symmetric.
export interface Command {
  readonly label: string;          // a UI-facing name like "Color change"
  do(): void;                      // move forward
  undo(): void;                    // step back one
  // If it can merge with the previous same-kind command, return the merged one
  coalesceWith?(prev: Command): Command | null;
}

As a concrete example, here's a command that moves a layer. Notice that it holds only the diff — the coordinates before and after.

export function moveLayerCommand(
  store: EditorStore,
  layerId: string,
  from: Point,
  to: Point,
): Command {
  return {
    label: "Move layer",
    do: () => store.setLayerPosition(layerId, to),
    undo: () => store.setLayerPosition(layerId, from),
    coalesceWith: (prev) => {
      // Same layer moved in a row: keep the original start, fold into one step
      const p = prev as ReturnType<typeof moveLayerCommand>;
      if (p.label !== "Move layer") return null;
      return moveLayerCommand(store, layerId, (p as any)._from ?? from, to);
    },
  };
}

The point is that a command knows where the state lives but never holds the whole state. It holds only the changed values. That is what keeps memory light.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
Why stacking full-state copies eats memory, and the command diff that keeps it light
Coalescing rapid tweaks into a single step, and where to draw the boundary
Persisting history with a cap so it survives the OS reclaiming your app in the background
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

Dev Tools2026-06-23
The Post You Wrote Offline Shows Up Twice — Designing a Send Outbox That Survives Retries
Persisting a queue and replaying it isn't enough — a lost response turns into a duplicate write. Here's a send outbox with idempotency keys, temp-to-server ID remapping, and poison-message quarantine, in working TypeScript.
Dev Tools2026-05-23
Rork-Specific 'expo start --offline forbidden': Four Causes in Rork's Template Config
When expo start --offline returns 'forbidden' specifically on a Rork-generated project, the cause is usually Rork template config: tsconfigPaths, an un-generated expo-router cache, native prebuild, or a lockfile mismatch. Four Rork-specific fixes; the generic Expo proxy and dependency-validation guide is covered separately.
Dev Tools2026-05-20
Fixing 0x8badf00d Watchdog Kills That Wipe Out Rork Apps at Launch
Your Rork iOS app dies right after launch on real devices. Crashlytics shows exception code 0x8badf00d. Here is the watchdog termination story and the exact steps an indie developer running React Native apps for 50M downloads uses to make it stop.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →