Coreclass

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:

MethodCalled by the host
mount(): voidOnce, owner before children. May attach newly discovered children.
sync(): voidAfter the tree mounts, then when a selection changes.
tick(dt: number, time: number): voidEvery 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(): voidDuring retirement, children before their owner.
disposeResources(): voidWhen 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:

HelperBehavior
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.
lifetimeA 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 propertyBehavior
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.
rootsFresh 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.