@elah/react
A thin hook layer over @elah/core. @elah/core has zero React imports and its stores are vanilla, so everything React-specific lives here.
Install
@elah/react has been its own package since 0.4.0. It depends on @elah/core and zustand and has a peer dependency on react 18 or newer. Install it directly when you are building custom UI on @elah/core without the full editor SDK:
npm install @elah/react @elah/coreIf you use @elah/editor you already have it: the editor re-exports the hooks below, so import { useEditor } from '@elah/editor' works. The EditorProvider component itself lives in @elah/editor, not here.
EditorContext
EditorContext carries one value, EditorContextValue, which is { engine: TimelineEngine; playback: PlaybackEngine }. Three hooks read it. All of them throw useEditor must be used inside <EditorProvider> when there is no provider above.
import { useEditor, useTimelineEngine, usePlaybackEngine } from '@elah/react'
function Toolbar() {
const { engine, playback } = useEditor() // both engines
const sameEngine = useTimelineEngine() // engine only: use this for mutations
const samePlayback = usePlaybackEngine() // playback only
return <button onClick={() => engine.undo()}>Undo</button>
}EditorProvider creates both engines, provides the context, and does the wiring that keeps the stores honest: engine change and history:change events sync the tracks and transitions stores, playback state flows both ways between PlaybackEngine and usePlaybackStore, and a project:loaded event with transport: 'rewind' returns the playhead to frame 0. If you provide EditorContext yourself, that wiring is yours to write. The stores are module-scoped singletons, so one provider per page.
Store Hooks
Each hook is a React view over one of core's vanilla Zustand stores. Pass a selector so a component re-renders only for the slice it reads. A trim on track 3 never re-renders a component subscribed to track 7.
useTracksStoreuseTransitionsStoreusePlaybackStoreuseSelectionStoreuseMediaLibraryStoreuseClipLoadStoreuseTextStylePresetsStoreimport { useTracksStore, usePlaybackStore, useSelectionStore } from '@elah/react'
const totalFrames = useTracksStore((s) => s.totalFrames)
const isPlaying = usePlaybackStore((s) => s.isPlaying)
const togglePlayPause = usePlaybackStore((s) => s.togglePlayPause)
const selected = useSelectionStore((s) => s.selectedClipIds) // Set<string>The type of every hook is BoundStoreHook<S>: callable with or without a selector, and it also carries the store's own API (getState, setState, subscribe).
Mutations do not go through the stores. The tracks and transitions stores are read-only mirrors, and writing to them desyncs the UI and breaks undo. Change the project through TimelineEngine. The playback store is the exception for transport: its actions drive PlaybackEngine.
useMediaLibrary() returns UseMediaLibraryApi, which is { assets, getAsset, removeAsset, updateAsset, importFiles, importUrl, importBlob }. assets is an array in library order. useAssets is an alias for it.
Audio Hooks
Three hooks work on an AudioPlaybackController from @elah/core. Each accepts null while the controller does not exist yet, and its methods become no-ops until one arrives.
useAudioMixer(controller)returns{ setMasterGain(value), setTrackGain(trackId, value) }. Gains are linear, 0 to 2, and ramp click-free.setTrackGaindoes nothing for a track with no active clips.useMasterVolume(controller, engine)returns{ masterVolume, setMasterVolume }. Setting it ramps the audio graph immediately and also saves the value to the project model withengine.setMasterVolume, so it survives a remount and is exported.useTrackLevels(controller)returns aMap<string, { left: number; right: number }>of RMS levels by track id, polled on every animation frame and stopped on unmount. It is empty when the controller is null or no tracks are active. Use it for level meters.
import { useMemo, useEffect } from 'react'
import { AudioPlaybackController } from '@elah/core'
import { useEditor, useAudioMixer, useMasterVolume, useTrackLevels } from '@elah/react'
function Mixer() {
const { engine, playback } = useEditor()
// <Preview> owns its own controller and does not hand it out. For custom
// mixer UI, build one and render <Preview enableAudio={false} /> so audio
// is not played twice.
const controller = useMemo(
() => new AudioPlaybackController(playback, () => engine.getProject()),
[engine, playback],
)
useEffect(() => {
controller.start()
return () => controller.destroy()
}, [controller])
const { setTrackGain } = useAudioMixer(controller)
const { masterVolume, setMasterVolume } = useMasterVolume(controller, engine)
const levels = useTrackLevels(controller) // Map<trackId, { left, right }>
return null
}Load State & Presets
Two hooks added in 0.6.0 bind the new vanilla stores.
useClipLoadStore reports which clips the preview cannot draw yet. byClipId[clipId] is 'loading' (a decoder exists and has not produced a drawable frame) or 'error' (the container could not be opened), and a clip that is drawing normally has no entry. This is renderer state. It is never part of the project, undo history or an autosave. The renderer writes it only at clip-boundary events, so subscribers see a handful of notifications per clip and none per frame. The timeline uses it for the loading shimmer.
import { useClipLoadStore } from '@elah/react'
function ClipStatus({ clipId }: { clipId: string }) {
const state = useClipLoadStore((s) => s.byClipId[clipId]) // 'loading' | 'error' | undefined
if (state === 'loading') return <Spinner />
if (state === 'error') return <span>Could not open this media</span>
return null
}useTextStylePresetsStore binds textStylePresetsStore, the user-saved text looks. The built-in ones are the separate BUILT_IN_TEXT_STYLE_PRESETS array. See Text Templates & Motion.
import { useTextStylePresetsStore } from '@elah/react'
const presets = useTextStylePresetsStore((s) => s.presets)
const addPreset = useTextStylePresetsStore((s) => s.addPreset) // assigns the idWithout React
Core has zero React imports, enforced by a test, so you can use every store from event handlers, workers, Vue, Svelte or plain scripts. Import the vanilla stores from @elah/core (or @elah/editor) and use the standard Zustand vanilla API.
import { tracksStore, playbackStore, selectionStore } from '@elah/core'
// Read now
const { tracks, totalFrames } = tracksStore.getState()
const clips = tracksStore.getState().clips[trackId]
// Subscribe. Returns an unsubscribe function.
const unsubscribe = playbackStore.subscribe((state, prev) => {
if (state.isPlaying !== prev.isPlaying) onTransportChange(state.isPlaying)
})
// Subscribe to a slice by comparing in the listener
tracksStore.subscribe((state, prev) => {
if (state.totalFrames !== prev.totalFrames) updateDurationLabel(state.totalFrames)
})
// Playback stays the engine's job
playbackStore.getState().togglePlayPause()The stores are only mirrors. Something has to feed them, and in React that is EditorProvider. Outside React you create the engines yourself and call tracksStore.getState().sync(project, { canUndo, canRedo }) and transitionsStore.getState().sync(project) from engine.on('change', ...). Or skip the stores entirely and read engine.getProject() and subscribe with engine.on, which is all a headless script needs.
For a typed view of the whole data model see API Reference: Types. The same TimelineEngine runs unchanged in Node and Web Workers, which is how @elah/cli renders projects server-side.