Store
The Zustand + Immer store at the heart of the SDK — the full state shape, every action it exposes, and the two hooks you'll use most.
There is one Zustand store per provider. It holds the entire runtime state of the editor, exposes every mutation as a method, and emits typed events on the bus when state changes. Everything in the editor — built-in surfaces, plugin panels, your own components — reads from and writes to this one store.
Two hooks
You access the store through two hooks. The first hook is reactive (subscribes to changes); the second is imperative (no subscription).
useStudioStore(selector) — read state reactively
import { useStudioStore } from '@studio-dev/vsdk'
function ItemCount() {
const count = useStudioStore((s) => Object.keys(s.timeline.items).length)
return <span>{count} items</span>
}The selector function runs on every state change. The component re-renders only when the selected
value changes (Zustand uses === by default). Pick narrow selectors so you don't re-render on
unrelated state.
Avoid useStudioStore((s) => s) — it re-renders on every state change, including every frame
during playback. Always project to the smallest piece of state you need.
useStudioStoreApi() — get the store handle without subscribing
import { useStudioStoreApi } from '@studio-dev/vsdk'
function AddButton() {
const storeApi = useStudioStoreApi()
return (
<button onClick={() => storeApi.getState().addItem({ /* ... */ })}>
Add
</button>
)
}useStudioStoreApi() returns a stable reference to the Zustand store instance. Use it in:
- Event handlers — you don't need the component to re-render when state changes.
- Effects — when you want to read or subscribe imperatively.
- Callbacks passed to other components — pass
storeApi(stable) instead of selected values (changing) to keep memoization working.
The store exposes the three Zustand primitives:
storeApi.getState()— currentStudioStatesnapshotstoreApi.setState(updater)— rarely needed; prefer named actionsstoreApi.subscribe(listener)— imperative subscription (returns unsubscribe)
State shape
StudioState is one object with six logical slices and a giant set of action methods. Actions live
on the state object (not separate from it) — that's how Zustand stores work.
interface StudioState {
timeline: TimelineState
selection: SelectionState
history: HistoryState
player: PlayerState
project: ProjectState
eventBus: StudioEventBus
// …plus ~70 action methods
}timeline
interface TimelineState {
tracks: Track[] // ordered
items: Record<string, TrackItem> // flat, keyed by id
itemIdsByTrack: Record<string, string[]> // secondary index: trackId → sorted ids
markers: Marker[]
regions: Region[]
transitions: Record<string, TimelineTransition>
zoom: { pixelsPerFrame: number; min: number; max: number }
view: { scrollLeftPx: number; scrollTopPx: number }
totalDurationFrames: number // derived, max of (startFrame + duration)
}itemIdsByTrack is the secondary index — for O(1) "give me items on track X in order." The store
keeps it consistent with items automatically; don't write to it directly.
selection
interface SelectionState {
selectedItemIds: string[] // currently selected items
activeItemId: string | null // the "primary" selection (for inspector)
}activeItemId is the one item whose properties the inspector shows. With multiple selections it
defaults to the most recently selected.
history
interface HistoryState {
undoStack: Command[] // max 100
redoStack: Command[]
canUndo: boolean // mirrors undoStack.length > 0
canRedo: boolean
isBatching: boolean // inside startBatch / endBatch
batchCommands: Command[] // batch buffer
}The 100-entry cap is the constant MAX_HISTORY. See Commands for details.
player
interface PlayerState {
currentFrame: number
isPlaying: boolean
playbackRate: number // 0.25 – 4
volume: number // 0 – 1
isMuted: boolean
}project
interface ProjectState {
projectId: string | null
projectName: string
settings: ProjectSettings
isDirty: boolean // true after any executed command
isSyncing: boolean // your code sets this during save
isLoading: boolean // your code sets this during async load
lastSavedAt: number | null
schemaVersion: number
export: ExportState // phase, progress, downloadUrl, profile
}eventBus
A StudioEventBus instance (mitt wrapped in a class so Immer doesn't freeze it). See Events.
All actions, grouped
The store exposes ~70 actions. Below is the full list, grouped by domain. Every action that mutates
timeline / selection / property state is wrapped in a Command and pushed to the undo stack — see
Commands.
History
| Action | Signature | Behavior |
|---|---|---|
executeCommand(cmd) | (command: Command) => void | Execute, push to undo stack, clear redo, mark dirty |
undo() | () => void | Pop top of undo, run undo(), push to redo |
redo() | () => void | Pop top of redo, run execute(), push to undo |
startBatch() | () => void | Begin a batch — all commands inside undo as one |
endBatch(label) | (label: string) => void | Wrap collected commands as one BATCH command |
cancelBatch() | () => void | Roll back collected batch commands, discard |
Timeline items
| Action | Signature |
|---|---|
addItem(item) | (Omit<TrackItem,'id'> & { id?: string }) => string |
removeItem(id) | (itemId: string) => void |
moveItem(id, toTrackId, toStartFrame) | (itemId, toTrackId, toStartFrame) => void |
trimItemStart(id, newStart) | (itemId, newStartFrame) => void |
trimItemEnd(id, newDuration) | (itemId, newDurationFrames) => void |
splitItem(id, atFrame) | (itemId, atFrame) => { leftId, rightId } | null |
duplicateItem(id) | (itemId) => string | null |
rippleDelete(id) | (itemId) => void |
rippleInsert(atFrame, dur, trackId) | (atFrame, durationFrames, trackId) => void |
nudge(ids, delta) | (itemIds: string[], deltaFrames: number) => void |
updateItemProperties(id, props) | see below |
updateItemProperties is the canonical way to change any property on an item. It accepts a partial
of:
{
transform?: Partial<TrackItemTransform>
style?: Record<string, unknown> // VideoStyle, TextStyle, etc.
filters?: FilterDefinition[]
zIndex?: number
locked?: boolean
disabled?: boolean
muted?: boolean
startFrame?: number
durationFrames?: number
}Nested keys (e.g. transform) are shallow-merged.
Tracks
| Action | Signature |
|---|---|
addTrack(track) | (Omit<Track,'id'> & { id?: string }) => string |
removeTrack(trackId) | (trackId: string) => void |
setTrackOrder(newOrder) | (Array<{ id, order }>) => void |
setTrackFlags(id, flags) | (trackId, { locked?, muted?, hidden? }) => void |
renameTrack(id, name) | (trackId, name: string) => void |
Markers
| Action | Signature |
|---|---|
addMarker(frame, label?, color?) | returns the new marker id |
removeMarker(id) | — |
moveMarker(id, frame) | — |
Selection
| Action | Signature |
|---|---|
selectItem(id, additive?) | (itemId, additive?: boolean) => void — additive = ctrl-click |
deselectItem(id) | (itemId) => void |
selectAll() | — |
deselectAll() | — |
Player
| Action | Signature |
|---|---|
setCurrentFrame(frame) | direct set (no event) |
seekTo(frame) | set + emit playback:seek |
play() | start playback |
pause() | stop playback |
togglePlay() | flip |
setPlaybackRate(rate) | 0.25 – 4 |
setVolume(v) | 0 – 1 |
toggleMute() | flip |
Zoom / view
| Action | Signature |
|---|---|
setZoom(pixelsPerFrame) | — |
setView(partial) | merge Partial<ViewState> |
Project
| Action | Signature |
|---|---|
updateSettings(partial) | — |
changeFps(fps) | rescales all item start/duration to the new FPS |
changeResolution(w, h, aspectRatio?) | rescales transforms |
setProjectName(name) | — |
markDirty() | — |
markClean() | — |
setSyncing(b) | shows/hides the syncing indicator |
setLoading(b) | shows/hides the full-screen loading overlay |
requestSave() | emits project:save-request (when there's no onSave) |
Keyframes
| Action | Signature |
|---|---|
setKeyframe(itemId, path, frame, value, easing?) | upsert |
removeKeyframe(itemId, path, keyframeId) | — |
updateKeyframe(itemId, path, keyframeId, updates) | — |
clearAnimation(itemId, path?) | clear one path or all |
Transitions
| Action | Signature |
|---|---|
addTransition(fromId, toId, type, durFrames, params?) | returns id or null |
removeTransition(transitionId) | — |
updateTransition(transitionId, updates) | — |
Masks
| Action | Signature |
|---|---|
addMask(itemId, type) | returns mask id or null |
removeMask(itemId, maskId) | — |
updateMask(itemId, maskId, updates) | — |
duplicateMask(itemId, maskId) | — |
reorderMasks(itemId, fromIndex, toIndex) | — |
setActiveMask(itemId, maskId | null) | mask currently being edited |
Export
| Action | Signature | Notes |
|---|---|---|
startExport(profile) | (profile: RenderProfile) | Sets phase to 'preparing', stores active profile |
setExportPhase(phase) | — | 'idle' | 'preparing' | 'uploading' | 'rendering' | 'finalizing' | 'completed' | 'failed' | 'aborting' |
setExportProgress(progress, stepLabel?) | — | 0 – 1 |
setExportCompleted(downloadUrl) | — | Sets phase 'completed' |
setExportFailed(error) | — | Sets phase 'failed' |
abortExport(reason?) | — | Sets phase 'aborting', emits export:abort |
resetExport() | — | Back to 'idle' |
Bundle / reset
| Action | Signature |
|---|---|
loadBundle(bundle) | hard load — see Project bundle |
toBundle() | serialize current state |
resetStore() | back to initial empty state |
How actions work internally
Take addItem as a concrete example — it's representative of most timeline actions:
addItem: (itemData) => {
const { command, itemId } = createAddItemCommand(itemData)
get().executeCommand(command)
eventBus.emit('item:added', { itemId, trackId: itemData.trackId })
return itemId
}Three steps: build a Command (execute + undo), push it through history, emit an event. Plugin and
consumer code can hook in at the event layer without touching the action itself.
Recipes
Read multiple slices in one render
Use a tuple-returning selector with an equalityFn if values change independently:
import { useStudioStore } from '@studio-dev/vsdk'
import { shallow } from 'zustand/shallow'
const [items, currentFrame] = useStudioStore(
(s) => [s.timeline.items, s.player.currentFrame] as const,
shallow,
)Without shallow the tuple identity changes every render and React re-renders unnecessarily.
Modify an item in response to an event
function MyAutoNamer() {
const storeApi = useStudioStoreApi()
useEffect(() => {
const eventBus = storeApi.getState().eventBus
const handler = ({ itemId }: { itemId: string }) => {
const state = storeApi.getState()
const item = state.timeline.items[itemId]
if (item && !item.name) {
state.updateItemProperties(itemId, { /* nothing — example */ })
}
}
eventBus.on('item:added', handler)
return () => eventBus.off('item:added', handler)
}, [storeApi])
return null
}Subscribe imperatively from outside React
const storeApi = /* useStudioStoreApi() inside the tree, then pass via context */
const unsub = storeApi.subscribe((state, prev) => {
if (state.player.currentFrame !== prev.player.currentFrame) {
// playback frame changed
}
})Batch a multi-step operation as one undo step
const state = storeApi.getState()
state.startBatch()
const id1 = state.addItem({ ... })
const id2 = state.addItem({ ... })
state.endBatch('Add two items')
// Ctrl+Z removes both in one step.If something throws mid-batch, call state.cancelBatch() in a catch block — it rolls back what was
already executed and exits batch mode.