The fastest way to make an editor unmaintainable is to scatter its mutations. Project data in one store, history in a class, the current frame in a hook, and a dozen components all writing wherever is convenient. Three sources of truth, one nominal truth, and infinite bugs. The TimelineEngine exists to make that impossible.
Every change goes through commit()
There is exactly one place project state changes: TimelineEngine.commit(). Visitors — add, remove, update, split, clone — apply Immer drafts. commit records history, fires events, and swaps the project reference. There are no side-channel writes. A component cannot reach in and mutate a clip; it calls an engine method, and that method funnels through commit like everything else.
// A visitor describes the change as an Immer draft mutation...
engine.addClip({ trackId, type: 'video', startFrame: 0, durationFrames: 90 })
// ...and commit() does the rest, atomically:
// 1. produce() the next immutable project (structural sharing)
// 2. push a history entry
// 3. emit('change', project) and emit('history:change')Why Immer: structural sharing for free
Immer lets visitors write code that looks like mutation — draft.clips[trackId].push(clip) — while producing a new immutable project under the hood. Unchanged subtrees are shared by reference between versions. That matters for two reasons: history snapshots are cheap (they share everything that did not change), and React consumers can use reference equality to skip re-renders for the parts of the tree that did not move.
Because each commit produces a structurally-shared snapshot rather than a deep copy, the history and redo stacks do not bloat memory — two adjacent versions differ only by the nodes that actually changed.
The three-ring state model
The engine is Ring 0 — the immutable source of truth, owned by classes. Ring 1 is the reactive mirror: Zustand stores (useTracksStore, usePlaybackStore, useMediaLibraryStore) that sync from Ring 0 when the engine emits. Ring 2 is throwaway UI state — selection, drag handles, panel toggles. The rule is one-directional: outer rings read inner rings, never the reverse.
- Ring 0 owns history, batching, and events. Replays are deterministic.
- Ring 1 is the React boundary. Components subscribe with granular selectors; one engine event triggers one sync().
- Ring 2 is transient. Selection and drag state never pollute the project history.
The forbidden patterns fall straight out of this: components must not write to Ring 0, Ring 0 must not read Ring 1 or Ring 2, and engine state must never live in useState or useRef instead of the engine.
Batching: many edits, one undo
Some user actions are logically one change but mechanically several mutations — dropping a video that has audio adds both a video clip and an audio clip. Those should undo together. engine.batch() wraps multiple mutations into a single commit and a single history entry:
engine.batch(() => {
engine.addClip({ trackId, type: 'video', ...videoOpts })
engine.addClip({ trackId: audioTrackId, type: 'audio', ...audioOpts })
}, 'Add video + audio') // one undo entryInvariants live inside the engine
Because every mutation funnels through one place, the engine is also the one place to enforce the data-model invariants: clips within a track are always sorted by startFrame and never overlap; startFrame >= 0 and durationFrames >= 1; for media clips the trim window stays within the source. moveClip and trimClip enforce these as part of their commit. There is no other code path that could violate them, because there is no other code path at all.
A single source of truth is not a diagram you draw. It is a mutation funnel you enforce — one commit(), no back doors.
Building something with browser-native video?
Try the SDK, read the docs, or join the conversation.
