Skip to main content

Core API

6 min read

Events

Events are type-safe state mutation handlers – named operations that modify facts.


Defining Events

Events are defined in two places: the schema declares the payload shape, and the events object defines the handler:

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

const counterModule = createModule("counter", {
  schema: {
    // Define the state shape for this module
    facts: {
      count: t.number(),
      items: t.array<string>(),
    },

    // Declare event names and their payload types
    events: {
      increment: {},                        // No payload needed
      addAmount: { amount: t.number() },    // Requires a numeric amount
      addItem: { item: t.string() },        // Requires a string item
    },
  },

  // Set initial state when the system starts
  init: (facts) => {
    facts.count = 0;
    facts.items = [];
  },

  // Event handlers mutate facts synchronously
  events: {
    // Simple mutation – no payload required
    increment: (facts) => {
      facts.count += 1;
    },

    // Destructure the typed payload from the schema
    addAmount: (facts, { amount }) => {
      facts.count += amount;
    },

    // Immutable update – replace the array, don't push
    addItem: (facts, { item }) => {
      facts.items = [...facts.items, item];
    },
  },
});

Event Anatomy

Event handlers are functions that receive facts and an optional typed payload:

// No payload – simple mutation
eventName: (facts) => {
  facts.someValue = newValue;
}

// With payload – typed from schema.events
eventName: (facts, { field1, field2 }) => {
  facts.someValue = field1;
}
PartDescription
factsWritable facts proxy – mutate directly
payloadTyped from schema.events definition (optional)
Returnvoid – events are synchronous

Dispatching Events

Two ways to dispatch events:

The typed proxy provides autocomplete and type checking:

const system = createSystem({ module: counterModule });
system.start();

// Call events as typed methods – TypeScript enforces payload shapes
system.events.increment();                  // No payload needed
system.events.addAmount({ amount: 5 });     // Typed payload with autocomplete
system.events.addItem({ item: "hello" });   // Compile-time type checking

system.dispatch() object syntax

Pass a full event object with type:

// Object syntax with explicit type field
system.dispatch({ type: "increment" });
system.dispatch({ type: "addAmount", amount: 5 });
system.dispatch({ type: "addItem", item: "hello" });

Both approaches are equivalent. The events accessor is more ergonomic with better type inference.


Batched Mutations

Event handlers run inside store.batch() – all fact mutations within a handler are coalesced into a single notification. This means constraints and derivations are only re-evaluated once after the handler completes, not after each individual mutation:

events: {
  resetAll: (facts) => {
    // All three mutations trigger ONE reconciliation, not three
    facts.count = 0;
    facts.items = [];
    facts.error = null;
  },
}

Complex Mutations

Events are the right place for multi-step state changes:

const cartModule = createModule("cart", {
  schema: {
    facts: {
      items: t.array<CartItem>(),
      subtotal: t.number(),
    },

    events: {
      addToCart: { productId: t.string(), price: t.number(), quantity: t.number() },
      removeFromCart: { productId: t.string() },
      clearCart: {},
    },
  },

  events: {
    addToCart: (facts, { productId, price, quantity }) => {
      // Update quantity if item already exists, otherwise add new
      const existing = facts.items.find(i => i.productId === productId);
      if (existing) {
        facts.items = facts.items.map(i =>
          i.productId === productId
            ? { ...i, quantity: i.quantity + quantity }
            : i
        );
      } else {
        facts.items = [...facts.items, { productId, price, quantity }];
      }

      // Recalculate subtotal after modification
      facts.subtotal = facts.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    },

    removeFromCart: (facts, { productId }) => {
      facts.items = facts.items.filter(i => i.productId !== productId);
      facts.subtotal = facts.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    },

    clearCart: (facts) => {
      facts.items = [];
      facts.subtotal = 0;
    },
  },
});

Namespaced Events (Multi-Module)

In multi-module systems using the object syntax, events are namespaced automatically:

const system = createSystem({
  modules: {
    auth: authModule,
    cart: cartModule,
  },
});

// Access events through the module namespace
system.events.auth.login({ token: "abc" });
system.events.cart.addToCart({ productId: "123", price: 999, quantity: 1 });

// Or use dispatch with prefixed type names
system.dispatch({ type: "auth::login", token: "abc" });
system.dispatch({ type: "cart::addToCart", productId: "123", price: 999, quantity: 1 });

Tick Events

For time-based systems, Directive supports a built-in tick mechanism:

const timerModule = createModule("timer", {
  schema: {
    facts: { elapsed: t.number() },
    events: { tick: {} },
  },

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

  events: {
    // Called automatically at the configured interval
    tick: (facts) => {
      facts.elapsed += 1;
    },
  },
});

// Dispatch "tick" every 1000ms while the system is running
const system = createSystem({
  module: timerModule,
  tickMs: 1000,
});
system.start();

The system automatically dispatches the tick event at the configured interval. A dev warning is shown if tickMs is set but no module defines a tick event handler.

Time-travel tip

Tick events fire frequently and clutter undo history. Use snapshotEvents to exclude them from time-travel snapshots – see Filtering Snapshot Events.


Dev-Mode Warnings

In development, dispatching an unknown event type logs a warning:

// Dispatching an unknown event type logs a helpful warning
system.dispatch({ type: "typo_event" });
// [Directive] Unknown event type "typo_event".
// No handler is registered for this event.
// Available events: increment, addAmount, addItem

Events vs Other Concepts

AspectEventsEffectsResolvers
PurposeMutate factsSide effects (logging, DOM)Fulfill requirements (API calls)
TriggerExplicit dispatchFact changesConstraint activation
Modifies factsYes (primary purpose)NoYes
SynchronousYesCan be asyncAsync
BatchedYes (auto)N/AYes (auto)

Best Practices

Keep Handlers Focused

Each event should represent one logical mutation:

// Good - clear, focused events
events: {
  setUser: (facts, { user }) => { facts.user = user; },
  clearUser: (facts) => { facts.user = null; },
}

// Avoid - vague catch-all
events: {
  update: (facts, { key, value }) => { facts[key] = value; },
}

Use Descriptive Names

// Good - describes what happens
"addToCart"
"removeItem"
"resetFilters"

// Avoid - vague
"update"
"set"
"handle"

Don't Put Async Logic in Events

Events are synchronous fact mutations. For async operations, use constraints and resolvers:

// Bad - don't do async in events
events: {
  fetchUser: async (facts) => {           // Don't do this!
    facts.user = await api.getUser(123);
  },
}

// Good - use constraints + resolvers for async
constraints: {
  needsUser: {
    when: (facts) => facts.userId > 0 && !facts.user,
    require: { type: "FETCH_USER" },
  },
}

Next Steps

Previous
Effects

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 State Management for TypeScript