How OpenScreen's Undo/Redo System Tracks Editor State Changes with useEditorHistory
OpenScreen implements a robust undo/redo mechanism using the useEditorHistory hook, which maintains a past-present-future stack of EditorState objects and provides distinct methods for discrete checkpoints (pushState) versus continuous live updates (updateState/commitState).
OpenScreen is an open-source video editor that consolidates all mutable editing properties—including zoom regions, trim spans, crop settings, and annotation layers—into a single immutable EditorState object. The useEditorHistory hook, located in src/hooks/useEditorHistory.ts, wraps this state in a classic command-history pattern, enabling users to navigate through up to 80 previous states while supporting both instantaneous actions and fluid drag interactions.
The History Stack Architecture
The core of OpenScreen's undo/redo system relies on a History interface that explicitly separates time into three distinct arrays:
// src/hooks/useEditorHistory.ts
interface History {
past: EditorState[];
present: EditorState;
future: EditorState[];
}
The hook initializes with an empty past array, an empty future array, and the present set to INITIAL_EDITOR_STATE. A hard limit of MAX_HISTORY = 80 prevents memory bloat by discarding the oldest entries when the stack exceeds this threshold.
Discrete Changes: Creating Checkpoints with pushState
For instantaneous user actions—such as adding a new zoom region, deleting a trim point, or toggling a setting—the UI calls pushState. This method creates an immutable checkpoint by shifting the current present into the past array, clearing the future array (since new actions invalidate redo history), and storing the updated state as the new present.
In src/components/video-editor/VideoEditor.tsx, adding a zoom region demonstrates this pattern:
const handleZoomAdded = useCallback((span: Span) => {
const id = `zoom-${nextZoomIdRef.current++}`;
const newRegion: ZoomRegion = {
id,
startMs: Math.round(span.start),
endMs: Math.round(span.end),
depth: DEFAULT_ZOOM_DEPTH,
focus: { cx: 0.5, cy: 0.5 }
};
// Creates a new checkpoint in history
pushState(prev => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
setSelectedZoomId(id);
}, [pushState]);
The pushState implementation resolves partial updates and triggers withCheckpoint, which handles the array slicing logic to maintain the 80-entry limit:
const pushState = useCallback((update: StateUpdate) => {
setHistory(prev => withCheckpoint(prev, resolve(prev.present, update)));
dirtyRef.current = false;
}, []);
Continuous Updates: Live Previews with updateState
Dragging operations—such as adjusting a zoom focus point or repositioning the webcam overlay—require a different approach. Creating a checkpoint on every pixel movement would flood the history stack, so OpenScreen uses updateState for live previews combined with commitState to mark interaction boundaries.
The hook employs a dirtyRef boolean to track whether the current interaction has already established a checkpoint:
- First call: If
dirtyRef.currentisfalse, the hook creates one checkpoint viawithCheckpoint, then setsdirtyRef.current = true - Subsequent calls: Only the
presentstate mutates; no new history entries are created - Interaction end: UI calls
commitState(), which resetsdirtyRef.currenttofalse
This pattern appears in VideoEditor.tsx when dragging zoom focus points:
// Called continuously during drag
const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => {
updateState(prev => ({
zoomRegions: prev.zoomRegions.map(r =>
r.id === id ? { ...r, focus: clampFocusToDepth(focus, r.depth) } : r,
),
}));
}, [updateState]);
// Called once when drag ends
onZoomFocusDragEnd={commitState}
The updateState implementation checks the dirty flag to determine whether to branch into checkpoint creation or simple present-state mutation:
const updateState = useCallback((update: StateUpdate) => {
const isFirst = !dirtyRef.current;
dirtyRef.current = true;
setHistory(prev => {
const next = resolve(prev.present, update);
return isFirst ? withCheckpoint(prev, next) : { ...prev, present: next };
});
}, []);
Navigating History: Undo and Redo
The undo and redo functions manipulate the three-stack structure to traverse the timeline. The undo operation shifts the most recent entry from past to present, pushing the former present onto future. Conversely, redo pulls from future back to present, pushing the current state onto past.
Both functions reset the dirtyRef to prevent history corruption during active interactions:
const undo = useCallback(() => {
setHistory(prev => {
if (!prev.past.length) return prev;
const previous = prev.past[prev.past.length - 1];
return {
past: prev.past.slice(0, -1),
present: previous,
future: [prev.present, ...prev.future],
};
});
dirtyRef.current = false;
}, []);
const redo = useCallback(() => {
setHistory(prev => {
if (!prev.future.length) return prev;
const [next, ...remaining] = prev.future;
return {
past: [...prev.past, prev.present],
present: next,
future: remaining
};
});
dirtyRef.current = false;
}, []);
The hook exposes canUndo and canRedo booleans derived from stack length checks, enabling UI components to disable buttons when navigation boundaries are reached.
Keyboard Shortcuts and UI Integration
In VideoEditor.tsx, global keyboard listeners bind standard shortcuts to these history functions:
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
const key = e.key.toLowerCase();
if (mod && key === "z" && !e.shiftKey) {
e.preventDefault();
undo();
}
if (mod && (key === "y" || (key === "z" && e.shiftKey))) {
e.preventDefault();
redo();
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [undo, redo]);
Summary
useEditorHistorymanages editor state through a past-present-future stack structure defined insrc/hooks/useEditorHistory.tspushStatecreates immutable checkpoints for discrete actions like adding regions or changing settings, automatically capping history at 80 entries viaMAX_HISTORYupdateStateenables smooth live previews during continuous interactions by mutating only thepresentstate after an initial checkpoint, tracked via thedirtyRefpatterncommitStatemarks the end of continuous interactions, resetting the dirty flag so the next drag operation starts fresh- Undo/redo operations move states between the
pastandfuturearrays while preserving immutability, with keyboard shortcuts handled inVideoEditor.tsx
Frequently Asked Questions
How does OpenScreen prevent memory leaks in the undo history?
The useEditorHistory hook enforces a hard limit of 80 entries through the MAX_HISTORY constant. When withCheckpoint creates a new history entry, it slices the past array to keep only the most recent 79 states plus the current one, discarding older checkpoints automatically to prevent unbounded memory growth.
What is the difference between pushState and updateState in useEditorHistory?
pushState creates a new checkpoint every time it is called, making it suitable for discrete actions like adding a zoom region or deleting an element. updateState optimizes for continuous interactions like dragging by creating a checkpoint only on the first call during an interaction sequence (tracked via dirtyRef), then mutating only the present state for subsequent calls until commitState resets the flag.
How does the dirtyRef pattern work for live previews?
The dirtyRef is a React ref that tracks whether a continuous interaction is in progress. When updateState is first called, dirtyRef.current is false, triggering withCheckpoint to save the current state before applying updates. Subsequent calls see dirtyRef.current as true and skip checkpoint creation. Calling commitState sets the ref back to false, ensuring the next interaction starts with a fresh checkpoint.
Can users redo actions after performing a new edit?
No, performing a new action via pushState or the first call to updateState automatically clears the future array. This follows standard undo/redo semantics where creating new history invalidates the redo timeline, preventing navigation into alternate futures that branch from previous states.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →