Skip to main content

Core API

7 min read

Module & System

Modules encapsulate state and logic. Systems run modules and provide the runtime.

Define
Init
Start
Running
Stopping
Stopped

Creating a Module

A module is created with createModule:

import { createModule, t } from '@directive-run/core';

// Define a module with its schema and initial values
const counterModule = createModule("counter", {
  schema: {
    facts: {
      count: t.number(),
    },
    derivations: {},
    events: {},
    requirements: {},
  },

  init: (facts) => {
    facts.count = 0;
  },
});

Module Options

OptionDescription
schemaType definitions for facts, derivations, events, requirements
initInitialize facts with default values
deriveComputed values
constraintsRules that generate requirements
resolversFunctions that fulfill requirements
effectsSide effects that run on changes
historyHistory options – e.g. history: { snapshotEvents: [...] } to control which events create snapshots (omit to snapshot all)

Creating a System

A system runs one or more modules:

import { createSystem } from '@directive-run/core';

// Single module – facts and derivations are accessed directly
// const system = createSystem({ module: counterModule });

// With plugins and time-travel
const system = createSystem({
  module: counterModule,
  plugins: [loggingPlugin(), devtoolsPlugin()],
  history: true,
});

System Options

OptionDescription
moduleSingle module to run (direct access)
modulesMultiple modules as { name: module } (namespaced access)
pluginsArray of plugins
historyEnable time-travel debugging (snapshots, undo/redo)
errorBoundaryError handling strategies per subsystem
initialFactsOverride initial fact values
tickMsInterval in ms for automatic tick event dispatch
zeroConfigEnable sensible defaults for dev mode

Initialization Order

Facts are applied in this order, each layer overriding the previous:

createModule()     →  init(facts)          →  Schema defaults
createSystem()     →  initialFacts         →  Override init() values
system.hydrate()   →  hydrate callback     →  Override everything (highest precedence)
system.start()     →  Constraints evaluate →  Reconciliation begins

init() runs during system.start() (or system.initialize()), not during createSystem(). hydrate() must be called before start() and its values take highest precedence – use it for SSR restoration or persisted state.


System API

Facts

Read and write facts directly:

// Read a fact value
const count = system.facts.count;

// Write a fact (triggers reconciliation)
system.facts.count = 10;

// Batch multiple updates into a single reconciliation
system.batch(() => {
  system.facts.count = 10;
  system.facts.loading = true;
});

Batching

system.batch() defers notifications until the callback completes. All fact mutations inside a batch trigger a single reconciliation instead of one per mutation:

// Without batch: 3 reconciliation cycles
system.facts.a = 1;
system.facts.b = 2;
system.facts.c = 3;

// With batch: 1 reconciliation cycle
system.batch(() => {
  system.facts.a = 1;
  system.facts.b = 2;
  system.facts.c = 3;
});

Batches can nest – only the outermost batch triggers reconciliation. Resolver resolve() functions are automatically batched, so multiple fact mutations inside a resolver always coalesce.

Derivations

Access computed values:

// Read a computed derivation (auto-tracked, lazy, cached)
const doubled = system.derive.doubled;

Settle

Wait for all resolvers to complete:

// Trigger async work by setting a fact
system.facts.userId = 123;

// Wait for all resolvers to finish before continuing
await system.settle();

Subscribe

React to changes in facts or derivations. Both subscribe() and watch() auto-detect whether each key is a fact or derivation – you can freely mix them:

// Subscribe to specific keys (facts or derivations)
const unsubscribe = system.subscribe(["displayName", "isLoggedIn"], () => {
  console.log('Value changed:', system.derive.displayName);
});

// Mix facts and derivations in one call
const unsub2 = system.subscribe(["userId", "displayName"], () => {
  console.log("userId fact or displayName derivation changed");
});

// Watch a single key with old and new values
const unsub3 = system.watch("displayName", (newValue, prevValue) => {
  console.log(`Changed from "${prevValue}" to "${newValue}"`);
});

// Watch with a custom equality function
const unsub4 = system.watch("items", (newVal, oldVal) => {
  console.log(`Items updated: ${newVal.length} items`);
}, { equalityFn: (a, b) => a.length === b.length });

// Clean up subscriptions when no longer needed
unsubscribe();
unsub3();

When

Wait for a condition to become true. system.when() returns a promise that resolves once the predicate passes:

// Wait until the system reaches a specific state
await system.when(() => system.facts.status === "ready");

// With a timeout (rejects if condition isn't met in time)
await system.when(() => system.derive.isLoggedIn, { timeout: 5000 });

Events

Dispatch events to update facts:

// Dispatch via typed accessor (preferred – autocomplete + type checking)
system.events.increment();
system.events.setUser({ user: newUser });

// Dispatch via object syntax
system.dispatch({ type: "increment" });
system.dispatch({ type: "setUser", user: newUser });

Events are defined in the module and handler functions update facts:

events: {
  // Simple mutation – increment the count
  increment: (facts) => { facts.count += 1; },

  // Mutation with payload
  setUser: (facts, { user }) => { facts.user = user; },
},

Hydrate

Apply persisted or server-rendered state before starting. Values from hydrate() take highest precedence – they override both init() and initialFacts:

// Must be called before system.start()
system.hydrate((facts) => {
  facts.userId = savedState.userId;
  facts.token = savedState.token;
});

system.start();

Snapshot / Restore

Capture and restore system state:

// Capture the current state
const snapshot = system.getSnapshot();
// { facts: { userId: 123, user: {...} }, version: 1 }

// Restore to a previous snapshot
system.restore(snapshot);

Lifecycle

// Initialize facts/derivations without starting reconciliation (SSR-safe)
system.initialize();

// Start the reconciliation loop
system.start();

// Wait for the first reconciliation to complete
await system.whenReady();

// Stop the reconciliation loop (can be restarted)
system.stop();

// Clean up all resources (irreversible)
system.destroy();

State Flags

system.isRunning;      // Whether reconciliation is currently active
system.isSettled;       // Whether all resolvers have completed
system.isInitialized;  // Whether all modules completed initialization
system.isReady;        // Whether system completed first reconciliation

// Subscribe to settled state changes
const unsub = system.onSettledChange(() => {
  console.log('Settled:', system.isSettled);
});

Runtime Control

Disable or enable individual constraints and effects at runtime:

// Constraints
system.constraints.disable("expensiveCheck");
system.constraints.enable("expensiveCheck");
system.constraints.isDisabled("expensiveCheck"); // boolean

// Effects
system.effects.disable("analytics");
system.effects.enable("analytics");
system.effects.isEnabled("analytics"); // boolean

See Constraints and Effects for details.

Inspection

// Read a derivation programmatically
const value = system.read("displayName");

// Get detailed system state
const info = system.inspect();
// { unmet, inflight, constraints, resolvers, trace? }

// Explain why a requirement exists
const reason = system.explain(requirementId);

Distributable Snapshots

Export derivation data for caching (Redis, JWT, edge KV):

const snap = system.getDistributableSnapshot({
  includeDerivations: ['effectivePlan'],
  ttlSeconds: 3600,
});

// Watch for changes
const unsub = system.watchDistributableSnapshot(
  { includeDerivations: ['effectivePlan'] },
  (snapshot) => cache.set('plan', snapshot),
);

See Time-Travel & Snapshots for full options.

Trace

When trace is enabled, the system tracks per-run changelogs:

const system = createSystem({
  module: myModule,
  trace: true,
});

// Access the run changelog
system.trace; // TraceEntry[] | null

Time-Travel

When history is enabled, system.history exposes the full time-travel API:

const system = createSystem({
  module: myModule,
  history: true,
});

system.history?.goBack();
system.history?.goForward();

// Subscribe to time-travel changes
const unsub = system.onHistoryChange(() => {
  console.log('Snapshot index:', system.history?.currentIndex);
});

See Time-Travel for the full API.


Multi-Module Systems

For larger apps, compose multiple modules:

// Compose multiple modules into one system
const system = createSystem({
  modules: {
    user: userModule,
    cart: cartModule,
    checkout: checkoutModule,
  },
});

// Facts are namespaced by module name
system.facts.user.userId = 123;
system.facts.cart.items = [...system.facts.cart.items, item];

See Multi-Module for more details.


Module Factory

Use createModuleFactory() when you need multiple instances of the same module definition:

import { createModuleFactory, t } from '@directive-run/core';

const chatRoom = createModuleFactory({
  schema: {
    facts: { messages: t.array<string>(), users: t.array<string>() },
    derivations: { count: t.number() },
  },
  init: (facts) => { facts.messages = []; facts.users = []; },
  derive: { count: (facts) => facts.messages.length },
});

const system = createSystem({
  modules: {
    lobby: chatRoom("lobby"),
    support: chatRoom("support"),
  },
});

See Multi-Module for dynamic registration and factory patterns.


Module Lifecycle

  1. createModule() creates the module definition
  2. createSystem() creates the runtime with the module (plugins initialized)
  3. system.start() applies initialFacts/hydrate overrides, then triggers the first reconciliation
  4. Constraints evaluate, requirements are generated, resolvers execute
  5. System settles when all requirements are fulfilled

When facts change, the reconciliation loop runs until all constraints are satisfied.


Next Steps

Previous
Overview
Next
Facts

Stay in the loop. Sign up for our newsletter.

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven Runtime for TypeScript | AI Guardrails & State Management