Module and ModuleHost
Own non-React systems with selected-state sync, typed events, ticking, and complete teardown.
Module describes an owner. ModuleHost mounts its tree and coordinates the
capabilities each module declares. There is no built-in renderer, store, or frame
loop; supply those at the application boundary. The runtime and snapshot APIs are
part of the core @joycostudio/xyz entrypoint.
Use named ESM imports to let your bundler remove unused exports. Importing a core
utility such as Disposer or Derived does not retain modules, the preview bridge,
React, Tweakpane, or Three.js. A used ModuleHost retains its snapshot and activity
methods; JavaScript bundlers generally cannot remove individual class methods.
Activity is opt-in at runtime and disabled in production. The preview bridge can
be removed independently when it is not imported.
Usage
import { Module, ModuleHost } from '@joycostudio/xyz'
import { createStore } from 'zustand/vanilla'
const state = createStore(() => ({ count: 0, ignored: false }))
class Counter extends Module<{ increment: number }> {
advance() {
this.emit('increment', 1)
}
}
class Application extends Module {
readonly counter = this.attach(new Counter())
private readonly count = this.select(state, (value) => value.count)
mount() {
this.listen(this.counter, 'increment', (amount) => {
state.setState((previous) => ({ count: previous.count + amount }))
})
}
sync() {
console.log('Current count:', this.count.value)
}
}
const host = new ModuleHost({
ticker: { add: () => undefined }, // No frame loop needed for this example.
defaultTickPriority: 0,
})
const application = host.mount(new Application()) // Logs 0.
application.counter.advance() // Logs 1.
state.setState({ ignored: true }) // No sync: the selection did not change.
host.dispose()
Zustand is used only by this example. Any store satisfying ModuleStore<State>
works without an adapter:
interface ModuleStore<State> {
getState(): State
subscribe(listener: () => void): () => void
}
Subscriptions must synchronously notify after state changes. Selections are snapshots, so return immutable values or supply an equality function suited to the selected value.
Ownership and lifecycle
Declare only the methods a module needs:
| Method | Called by the host |
|---|---|
mount(): void | Once, owner before children. May attach newly discovered children. |
sync(): void | After the tree mounts, then when a selection changes. |
tick(dt: number, time: number): void | Every scheduled frame, owner before children; both arguments are seconds. |
debug(): DebugFolder | readonly DebugFolder[] | Lazily through the optional debug manager. |
warmup(): void | (() => void) | When host.warmup() traverses the tree. Returns optional restoration. |
dispose(): void | During retirement, children before their owner. |
disposeResources(): void | When deferred resources are released. |
These hooks are synchronous. A host can be registered with XYZ's asynchronous
Warmup orchestration, but module warmup hooks themselves must return synchronously.
Module
new ModuleSubclass({ tickPriority? }) optionally chooses a root's ticker priority.
Attached children inherit their root's priority; declaring a child priority fails
mounting. children returns a fresh array of the owned modules.
Protected helpers for subclasses:
| Helper | Behavior |
|---|---|
attach(child) | Own a new child and return it. Allowed during construction or mount(); cycles, reuse, and disposed children are rejected. |
select(store, selector, equals = Object.is) | Return a read-only Selection<T> with .value. Declare before mounting. |
emit(event, payload) | Synchronously notify listeners with the payload declared by Module<Events>. |
listen(emitter, event, listener) | Subscribe and own the returned unsubscribe callback. The returned callback can also stop listening early. |
lifetime | A Disposer for timers, DOM listeners, and other owner-local cleanup. |
Public on(event, listener) returns an unsubscribe callback for external
consumers. The consumer owns that callback; disposing the emitter also clears all
its listeners. A disposed emitter rejects new subscriptions.
Both on() and listen() return idempotent cleanup callbacks.
The host shares one subscription per store. All affected selections refresh before
syncing, and dirty modules sync in mount order. Writes made inside sync() join the
same flush, so children see the latest selected values. Avoid unconditional state
writes from sync() that would keep changing its own selection forever.
If a sync() hook throws, the host still attempts the remaining queued syncs in
mount order, including children of the failed module. It then rethrows a single
error, or an AggregateError for multiple failures. Sync hooks are not
transactional: earlier work is not rolled back, and a child must not assume its
owner's hook succeeded. A failed hook is not retried within the same flush, even
if a nested write requeues it; a later notification that changes its selection can
retry it. An initial-sync failure still unwinds the newly mounted tree.
ModuleHost
new ModuleHost({ ticker, defaultTickPriority, debug? })
ticker.add(callback, { priority }) registers one callback per ticking root and
returns an unsubscribe callback, or undefined when the scheduler does not expose
one. The scheduler invokes callback(time, delta, frame) with time and delta in
milliseconds. Roots without tick() anywhere in their tree register no callback.
Return an unsubscribe callback whenever possible so retirement releases the
scheduler registration.
debug accepts XYZ's DebugManager or any object with the same register()
contract. Children nest under their owner's first debug folder; an owner without
debug() is transparent. Debug registrations remount from fresh definitions when
debug is toggled.
| Method or property | Behavior |
|---|---|
mount(instance) | Mount an independent root and return it. Failed setup unwinds the complete tree and preserves the setup error. |
unmount(instance) | Dispose one root, including resources, and remove it. |
roots | Fresh array of currently mounted roots. |
warmup() | Visit mounted owners before children and return an idempotent reverse-order restoration callback. Failure restores completed work. |
retire() | Permanently stop behavior and detach every root while retaining deferred resources. |
disposeResources() | Release resources collected by retire(), once. Call after rendering has finished using them. |
dispose() | Retire and release all resources immediately. |
Retirement removes the ticker, internal subscriptions, and debug registration,
then disposes children, the owner's lifetime, and the owner's dispose() hook.
Cleanup continues after failures. One cleanup error is rethrown; multiple errors
become an AggregateError. Hosts and modules are single-use; construct a new tree
after retirement.
Retirement blocks store-driven syncs as soon as it starts, including writes from cleanup hooks. Unmounting a root or retiring the host during a mount hook cancels the remaining setup; disposed modules cannot be mounted or scheduled again.
Use two-phase teardown when in-flight rendering still owns GPU resources:
host.retire()
try {
await waitForRenderingToFinish() // Supplied by your renderer integration.
} finally {
host.disposeResources()
}
Ownership is independent of scene parenting. Reparenting a rendered object does not change which module retires its behavior or releases its resources.
For snapshots and activity observation, see Module inspection.
Porting an existing local module framework
Replace imports from @/lib/module with @joycostudio/xyz. Existing
Zustand selections, ticker adapters, typed events, debug folders, and synchronous
warmup hooks retain their contracts. The runtime reuses XYZ's existing Disposer
and debug schema rather than application-local helpers.