# How OpenScreen's Undo/Redo System Tracks Editor State Changes with useEditorHistory

> Discover how OpenScreen's undo/redo system tracks editor state changes using useEditorHistory. Learn about its past-present-future stack and state update methods.

- Repository: [Sid/openscreen](https://github.com/siddharthvaddem/openscreen)
- Tags: internals
- Published: 2026-04-03

---

**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`](https://github.com/siddharthvaddem/openscreen/blob/main/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:

```typescript
// 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`](https://github.com/siddharthvaddem/openscreen/blob/main/src/components/video-editor/VideoEditor.tsx), adding a zoom region demonstrates this pattern:

```typescript
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:

```typescript
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:

1. **First call**: If `dirtyRef.current` is `false`, the hook creates one checkpoint via `withCheckpoint`, then sets `dirtyRef.current = true`
2. **Subsequent calls**: Only the `present` state mutates; no new history entries are created
3. **Interaction end**: UI calls `commitState()`, which resets `dirtyRef.current` to `false`

This pattern appears in [`VideoEditor.tsx`](https://github.com/siddharthvaddem/openscreen/blob/main/VideoEditor.tsx) when dragging zoom focus points:

```typescript
// 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:

```typescript
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:

```typescript
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`](https://github.com/siddharthvaddem/openscreen/blob/main/VideoEditor.tsx), global keyboard listeners bind standard shortcuts to these history functions:

```typescript
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

- **`useEditorHistory`** manages editor state through a **past-present-future** stack structure defined in [`src/hooks/useEditorHistory.ts`](https://github.com/siddharthvaddem/openscreen/blob/main/src/hooks/useEditorHistory.ts)
- **`pushState`** creates immutable checkpoints for discrete actions like adding regions or changing settings, automatically capping history at **80 entries** via `MAX_HISTORY`
- **`updateState`** enables smooth live previews during continuous interactions by mutating only the `present` state after an initial checkpoint, tracked via the **`dirtyRef`** pattern
- **`commitState`** marks the end of continuous interactions, resetting the dirty flag so the next drag operation starts fresh
- **Undo/redo** operations move states between the `past` and `future` arrays while preserving immutability, with keyboard shortcuts handled in [`VideoEditor.tsx`](https://github.com/siddharthvaddem/openscreen/blob/main/VideoEditor.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.