Events
The typed event bus — full catalog, subscription patterns, and how it cooperates with the store.
The store carries a typed StudioEventBus instance under state.eventBus. Every domain mutation
(item added, item moved, transition added, export progressed, …) emits an event on it. Plugins and
consumer code subscribe to react without coupling to internal store mechanics.
Why a bus at all?
The store is the source of truth for state. The event bus is the source of truth for what just happened. State changes are observable, but they're declarative — you see "items now contains X" not "X was just added." For analytics, autosave triggers, plugin coordination, etc., the bus is the right tool.
Reading and writing
import { useStudioStoreApi } from '@studio-dev/vsdk'
const storeApi = useStudioStoreApi()
const eventBus = storeApi.getState().eventBus
// Subscribe
eventBus.on('item:added', ({ itemId, trackId }) => {
console.log('Added', itemId, 'to', trackId)
})
// Unsubscribe (pass the same handler reference)
eventBus.off('item:added', handler)
// Emit (rare from consumer code — useful for custom events / testing)
eventBus.emit('item:added', { itemId: 'foo', trackId: 'trk_main' })The bus is a class instance (StudioEventBus), not a plain object — this is deliberate so Immer
doesn't deep-freeze its internal handler list when the state is produced.
In React
Subscribe in useEffect, return the unsubscribe in the cleanup:
import { useEffect } from 'react'
import { useStudioStoreApi } from '@studio-dev/vsdk'
function ItemAddedToast() {
const storeApi = useStudioStoreApi()
useEffect(() => {
const eventBus = storeApi.getState().eventBus
const handler = ({ itemId }: { itemId: string }) => toast(`Added ${itemId}`)
eventBus.on('item:added', handler)
return () => eventBus.off('item:added', handler)
}, [storeApi])
return null
}The storeApi handle is stable, so this effect runs once per mount.
Full event catalog
All event payloads are fully typed. The keys below are the union of StudioEvents.
Items
| Event | Payload |
|---|---|
item:added | { itemId, trackId } |
item:removed | { itemId } |
item:moved | { itemId, fromTrackId, toTrackId } |
item:trimmed | { itemId } |
item:split | { originalId, leftId, rightId } |
item:selected | { itemIds } |
item:deselected | { itemIds } |
item:properties-changed | { itemId, changes: Record<string, unknown> } |
Tracks
| Event | Payload |
|---|---|
track:added | { trackId } |
track:removed | { trackId } |
track:reordered | { trackIds: string[] } |
Playback
| Event | Payload |
|---|---|
playback:play | void |
playback:pause | void |
playback:seek | { frame } |
playback:frame-update | { frame } |
frame-update fires on every frame while playing — be careful what you do in its handler.
History
| Event | Payload |
|---|---|
history:push | { commandType } — new command executed |
history:undo | { commandType } |
history:redo | { commandType } |
Project
| Event | Payload |
|---|---|
project:loaded | { projectId } |
project:saved | { projectId, version } |
project:dirty | void |
project:clean | void |
project:sync-start | void |
project:sync-end | void |
project:save-request | void — emitted by requestSave() when no onSave is configured |
Keyframes / animation
| Event | Payload |
|---|---|
keyframe:added | { itemId, propertyPath, frame } |
keyframe:removed | { itemId, propertyPath, keyframeId } |
keyframe:updated | { itemId, propertyPath, keyframeId } |
animation:cleared | { itemId, propertyPath? } |
Transitions
| Event | Payload |
|---|---|
transition:added | { transitionId, fromItemId, toItemId } |
transition:removed | { transitionId } |
transition:updated | { transitionId } |
Masks
| Event | Payload |
|---|---|
mask:added | { itemId, maskId } |
mask:removed | { itemId, maskId } |
mask:updated | { itemId, maskId } |
Export
| Event | Payload |
|---|---|
export:started | { profileId, projectId } |
export:progress | { progress, stepLabel } |
export:completed | { downloadUrl } |
export:failed | { error } |
export:dismissed | void |
export:abort | { projectId, profileId, reason } — emitted by abortExport() |
Assets / jobs
| Event | Payload |
|---|---|
asset:uploaded | { assetId } |
job:created | { jobId, type } |
job:progress | { jobId, progress } |
job:completed | { jobId } |
job:failed | { jobId, error } |
TypeScript
Event names are a union, and payloads are strongly typed. You can import the type for use in generics:
import type { StudioEvents } from '@studio-dev/vsdk'
type EventName = keyof StudioEvents
type ItemAddedPayload = StudioEvents['item:added'] // { itemId: string; trackId: string }Patterns
One-time listener
const handler = (data: StudioEvents['export:completed']) => {
eventBus.off('export:completed', handler)
notify(data.downloadUrl)
}
eventBus.on('export:completed', handler)Multiple events, one handler
mitt's '*' (any event) isn't typed here — register each separately:
const onChange = () => markUnsynced()
eventBus.on('item:added', onChange)
eventBus.on('item:removed', onChange)
eventBus.on('item:moved', onChange)
return () => {
eventBus.off('item:added', onChange)
eventBus.off('item:removed', onChange)
eventBus.off('item:moved', onChange)
}Autosave trigger
Debounce on any dirty-marking event:
import { debounce } from 'lodash-es'
const debouncedSave = debounce(async () => {
const bundle = storeApi.getState().toBundle()
await saveToBackend(bundle)
storeApi.getState().markClean()
}, 2000)
eventBus.on('history:push', debouncedSave)Plugin coordination
Plugin A can emit a custom payload that plugin B listens to. Stick to the existing event names if you
can — they're typed. For genuinely new events, use requestSave style: define a tiny module-level
event in your plugin package and have consumers import the constant.
Anti-patterns
- Don't trigger store mutations from inside an event handler synchronously — you'll re-enter the
store mid-action. If you must, defer to a microtask:
queueMicrotask(() => storeApi.getState().…). - Don't rely on event ordering across domains — within a single action the events fire in a fixed
order, but across batched / nested actions the order can shift. Read state from
storeApi.getState()inside the handler if you need the truth. - Don't forget to unsubscribe. The bus retains handlers for the lifetime of the store
(≈ the lifetime of the provider). Returning the unsubscribe from
useEffectis the safe pattern.