●MAX — Rork Max builds native Swift apps instead of React Native, supporting iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — It reaches AR/LiDAR, Metal 3D, widgets, Dynamic Island, Live Activities, HealthKit, NFC, and on-device Core ML●PUBLISH — Two-click App Store publishing sharply shortens the submission work for solo developers●SWIFT — Describe your app in plain English and Rork generates working code for iOS, Android, and web●GROWTH — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growth●PRICING — It is free to start, with paid plans from $25/month and Rork Max at $200/month●MAX — Rork Max builds native Swift apps instead of React Native, supporting iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — It reaches AR/LiDAR, Metal 3D, widgets, Dynamic Island, Live Activities, HealthKit, NFC, and on-device Core ML●PUBLISH — Two-click App Store publishing sharply shortens the submission work for solo developers●SWIFT — Describe your app in plain English and Rork generates working code for iOS, Android, and web●GROWTH — Rork recently raised $15M and now sees over 743,000 monthly visits with 85% growth●PRICING — It is free to start, with paid plans from $25/month and Rork Max at $200/month
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.
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.
Approach
Memory
Meaning of the diff
Fits when
Snapshot (full state)
Large (state size × steps)
None
State 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.
We need a container for the commands. It has three requirements: cap the depth and drop the oldest, invalidate the "redo" side when a new action follows an undo, and let the outside world observe the current position.
export class History { private done: Command[] = []; // executed (shrinks on undo) private undone: Command[] = []; // undone (shrinks on redo) private listeners = new Set<() => void>(); constructor(private readonly maxDepth = 100) {} push(cmd: Command): void { cmd.do(); // Fold into the previous step if it can coalesce (see below) const prev = this.done[this.done.length - 1]; const merged = prev && cmd.coalesceWith?.(prev); if (merged) { this.done[this.done.length - 1] = merged; } else { this.done.push(cmd); if (this.done.length > this.maxDepth) this.done.shift(); // drop oldest at cap } this.undone = []; // a new action invalidates the redo history this.emit(); } undo(): void { const cmd = this.done.pop(); if (!cmd) return; cmd.undo(); this.undone.push(cmd); this.emit(); } redo(): void { const cmd = this.undone.pop(); if (!cmd) return; cmd.do(); this.done.push(cmd); this.emit(); } canUndo(): boolean { return this.done.length > 0; } canRedo(): boolean { return this.undone.length > 0; } peekUndoLabel(): string | null { return this.done[this.done.length - 1]?.label ?? null; } subscribe(fn: () => void): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } private emit(): void { this.listeners.forEach((fn) => fn()); }}
The bug behind that opening review was the missing undone = []. If the redo history lingers after the user makes a new edit, the next undo wanders into the old branch. From the user's seat, it looks like it "jumped five steps back." Undo must always be a single path.
Coalescing rapid tweaks into one step
When someone drags a brightness slider, the value changes dozens of times while their finger is moving. Push each as its own step and undo becomes a chore — tapping it thirty times before the brightness change is finally gone.
So we fold same-kind consecutive commands into one step. Two axes decide it: same target and same kind, and arriving within a set time of the previous step. We put a time boundary in because if the user lifts their finger, waits, and then moves it again, that's a different intent.
export function coalesceByTime( prevAt: number, nowAt: number, windowMs = 400,): boolean { return nowAt - prevAt <= windowMs; // within 400ms counts as the same step}
That's why History.push calls coalesceWith — to slip this folding in. After merging, the history keeps a single "Adjust brightness" step, and one undo returns you to before the adjustment. The first move after lifting your finger becomes a separate step. How you place that boundary makes undo's granularity feel remarkably natural.
Measured on my own app, coalescing slider actions cut the steps stacked during a ten-second color tweak from about 90 on average to 1. History stays shallow, and it matches the user's expectation that one tap should take them back.
Wiring it to React state
Keep History outside React and feed the UI through subscription. With useSyncExternalStore, button enabled/disabled state and labels follow history changes cleanly.
On the screen side, never mutate state directly — always change it through a command. Whether you can hold that line is what separates an undo that works from one that doesn't. A single back door where "this edit didn't go through a command" creates an inconsistency: that one thing can't be undone.
The app slips into the background mid-edit, the OS reclaims its memory, and the user comes back to an empty history. That's one of the most deflating moments in an editing app — and it's where the command approach shows its weakness. Commands hold functions, so you can't serialize them as they are.
The fix is to switch to snapshots only for persistence. Save the current state plus snapshots of the last few steps, with a cap. Limiting to the recent steps rather than the full history is the trade between storage and restore speed.
// Call on background transition. Keep only the last N steps of state.export async function persistHistory( storage: AsyncStorageLike, key: string, states: EditorState[], // state right after each step (newest last) keep = 20,): Promise<void> { const recent = states.slice(-keep); await storage.setItem(key, JSON.stringify({ v: 1, states: recent }));}export async function restoreHistory( storage: AsyncStorageLike, key: string,): Promise<EditorState[] | null> { const raw = await storage.getItem(key); if (!raw) return null; const parsed = JSON.parse(raw); if (parsed?.v !== 1) return null; // silently drop a mismatched schema version return parsed.states as EditorState[];}
Do the save once, at the moment AppState moves to background, so you don't block the main thread. Writing on every edit lets high-frequency actions jam the writes. And on restore, check the schema version (v) and drop it if it doesn't match. Forcing an old format to load is planting your next crash yourself.
This two-tier setup — commands while running, snapshots for saving — is why I said up front that the two aren't rivals. You reach for lightness and recoverability in the moment each one fits.
Handling actions you can't take back
Not every action can be undone. A post to the server, an export to the photo library, a purchase — side effects that reach the outside world can't be reversed by your app's history alone. Force them into undo and you get the worst kind of mismatch: "I undid it, but it already sent."
The practical move is to treat an irreversible action as a boundary in the history. Make it explicit that you can't go back past it, and commit the history right before crossing.
export class History { // ...added to the above... commitBarrier(): void { // Nothing before here can be undone. Clear history to set a baseline. this.done = []; this.undone = []; this.emit(); }}
Call commitBarrier() right after a photo export or a server send, and the user gets a consistent sense that "only from here on can I undo." Don't let something irreversible pretend to be reversible. That, to me, is the foundation of an editing experience people trust.
Undo/redo isn't a flashy feature. But whether a user can move their hands without fear of mistakes rests on how you design this one thing. Start by picking a single edit action in your app and carving it out as a command. Stop stacking whole states; hold only the diff. That first step is the entrance to an editing experience you can run for a long time.
I hope it helps with your own build. Thank you for reading.
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.