Commands
Every undoable mutation is a Command. This page covers the pattern, the built-in factories, batching, and authoring your own.
The SDK uses the command pattern for every state mutation that should be undoable. A Command is
a plain object that knows how to apply a change and how to reverse it. The store's executeCommand
runs it, pushes it onto the undo stack (capped at 100), and clears redo.
The shape
import type { StudioState } from '@studio-dev/vsdk/core'
import type { Draft } from 'immer'
interface Command {
id: string // auto-generated cmd_…
type: string // e.g. 'ADD_ITEM', 'MOVE_ITEM'
label: string // human-readable, used in events
timestamp: number
execute: (draft: Draft<StudioState>) => void // apply the change
undo: (draft: Draft<StudioState>) => void // reverse it
}Both execute and undo receive an Immer draft of the entire state. Mutate it directly — Immer
captures the patches and produces the new immutable state internally.
How it flows
store.addItem(item)
↓
createAddItemCommand(item) → { command, itemId }
↓
store.executeCommand(command)
↓
└── (inside Immer) command.execute(draft)
└── undoStack.push(command)
└── redoStack = []
└── project.isDirty = true
└── (if isBatching) batchCommands.push(command) instead of undoStack
↓
eventBus.emit('history:push', { commandType: command.type })Built-in store actions (addItem, moveItem, updateItemProperties, …) build the command, call
executeCommand, and emit a domain event (item:added, item:moved, …) on top.
Undo / redo
const state = storeApi.getState()
state.undo() // pop undo, run cmd.undo(), push to redo, emit 'history:undo'
state.redo() // pop redo, run cmd.execute(), push to undo, emit 'history:redo'The store also exposes canUndo and canRedo booleans for UI:
const canUndo = useStudioStore((s) => s.history.canUndo)
const undo = useStudioStore((s) => s.undo)
<Button disabled={!canUndo} onClick={() => undo()}>Undo</Button>Built-in hotkeys handle ⌘Z / ⌘⇧Z for you (useHotkeys()).
Batching
When a single user action mutates state multiple times, wrap the calls in a batch so one ⌘Z reverses the whole sequence:
const state = storeApi.getState()
state.startBatch()
const idA = state.addItem({ ... })
const idB = state.addItem({ ... })
state.moveItem(idA, 'trk_main', 30)
state.endBatch('Add two clips')endBatch(label) wraps the buffered commands as one BatchCommand (type: 'BATCH') and pushes it to
the undo stack as a single entry.
If something throws mid-batch, call state.cancelBatch() in your catch. It plays all buffered
undos in reverse to roll back partial state, then exits batch mode:
try {
state.startBatch()
const id = state.addItem({ ... })
doSomethingThatMayThrow(id)
state.endBatch('Risky op')
} catch (err) {
state.cancelBatch()
throw err
}Built-in command factories
Most of the time you call the store action (e.g. state.addItem), and the action constructs the
command for you. But the factories are exported separately too — useful when authoring a custom
action that needs to push a command directly.
import {
createAddItemCommand,
createRemoveItemCommand,
createMoveItemCommand,
createTrimItemStartCommand,
createTrimItemEndCommand,
createSplitItemCommand,
createDuplicateItemCommand,
createRippleDeleteCommand,
createRippleInsertCommand,
createNudgeCommand,
createUpdateItemPropertiesCommand,
createAddTrackCommand,
createRemoveTrackCommand,
createSetTrackOrderCommand,
createSetTrackFlagsCommand,
} from '@studio-dev/vsdk/core'Each factory returns either a Command or { command, …extras } (e.g. createAddItemCommand also
returns the generated itemId so the caller can return it from the action).
Authoring custom commands
For plugins that need to mutate state in a way no built-in action covers, build your own with
createCommand:
import { createCommand } from '@studio-dev/vsdk/core'
import { useStudioStoreApi } from '@studio-dev/vsdk'
function applyMyEffect(itemId: string) {
const storeApi = useStudioStoreApi()
const state = storeApi.getState()
const item = state.timeline.items[itemId]
if (!item) return
// Capture the value we'll restore on undo BEFORE we mutate.
const oldStyle = item.style ?? {}
const command = createCommand(
'MY_EFFECT_APPLY', // command type — show up in history events
'Apply my effect', // human-readable label
(draft) => {
const target = draft.timeline.items[itemId]
if (!target) return
target.style = { ...target.style, myEffect: { intensity: 0.8 } }
},
(draft) => {
const target = draft.timeline.items[itemId]
if (!target) return
target.style = oldStyle
},
)
state.executeCommand(command)
}Guidelines
- Capture old values before mutation, by reference closure — don't read them inside
undofrom the draft, because by then the change is already applied. Save them outside the command. - Always handle the "item is gone" case — between
executeandundosomething else may have removed the item. Guard withif (!target) return. - Type your command type strings — pick a stable identifier; consumers and plugins listen to
history:pushevents and may filter bycommandType. - Don't emit events from inside the command — emit them from the calling action (the way built-in actions do). Otherwise events fire on redo too, which is rarely what you want.
- One command = one logical user action. If it requires multiple mutations, fold them into one
execute. Or usestartBatch/endBatchif you're composing existing actions.
BatchCommand
import { createBatchCommand } from '@studio-dev/vsdk/core'
interface BatchCommand extends Command {
type: 'BATCH'
commands: Command[]
}You rarely build these by hand — endBatch constructs one automatically. But the type is exported
for advanced use (e.g. recording a macro and replaying it).
Hooks and patterns
A "save state" button for debugging
function HistoryDebug() {
const undoStack = useStudioStore((s) => s.history.undoStack)
return (
<ol>
{undoStack.map((cmd) => <li key={cmd.id}>{cmd.label}</li>)}
</ol>
)
}React to commands of a specific type
useEffect(() => {
const eventBus = storeApi.getState().eventBus
const handler = ({ commandType }: { commandType: string }) => {
if (commandType === 'MY_EFFECT_APPLY') trackAnalytics('effect-applied')
}
eventBus.on('history:push', handler)
return () => eventBus.off('history:push', handler)
}, [storeApi])Disable undo briefly (rare)
The store has no "skip history" mode. If you have a mutation that shouldn't be undoable (e.g. an
auto-cleanup), use setState directly:
storeApi.setState((draft) => {
delete draft.timeline.items[idToCleanup]
})This bypasses executeCommand entirely — nothing pushed to undo, no events emitted. Use sparingly;
the user has no way to recover from it.