Skip to main content

Examples

Dynamic Modules

Runtime module registration, namespaced fact access, and multi-module system composition.

Try it

Loading example…

Click “Load” to register modules at runtime. Each module brings its own facts, constraints, resolvers, and derivations into the shared system. Watch the inspector grow as modules load.

How it works

A widget dashboard where feature modules load dynamically at runtime – the first multi-module namespaced system example.

  1. System starts – with only the dashboard module using createSystem({ modules }) (multi-module mode)
  2. User clicks “Load Counter” – calls system.registerModule("counter", counterModule)
  3. Module integrates – facts, constraints, resolvers, and derivations are added under the counter namespace
  4. subscribeModule system.subscribeModule("counter", render) wires up reactivity for the new module
  5. Widget appears – counter card with increment/decrement, step slider, and overflow constraint (auto-resets at 100)
  6. Same for Weather and Dice – Weather uses an async resolver for city lookup; Dice uses pure derivations for total and doubles detection
  7. Inspector grows – shows all namespaced facts across every loaded module in real time

Summary

What: A widget dashboard with 3 dynamically loadable modules (Counter, Weather, Dice), a system inspector showing namespaced facts, and an event timeline.

How: createSystem initializes with a dashboard module. registerModule adds modules at runtime with their own facts, constraints, resolvers, and derivations. subscribeModule subscribes to per-namespace changes for reactive rendering.

Why it works: Each module is self-contained with its own schema, events, and resolution logic. The system handles namespace isolation automatically — modules can’t interfere with each other’s facts, and the inspector reveals the full namespaced state tree.

Source code

/**
 * Dynamic Modules — Directive Module Definitions
 *
 * Dashboard module (always loaded) + 3 dynamic modules (Counter, Weather, Dice).
 * Demonstrates runtime module registration, namespaced fact access,
 * constraints, resolvers, and derivations across independent modules.
 */

import { createModule, t, type ModuleSchema } from "@directive-run/core";
import { mockFetchWeather } from "./mock-weather.js";

// ============================================================================
// Types
// ============================================================================

export interface EventLogEntry {
  timestamp: number;
  event: string;
  detail: string;
}

// ============================================================================
// Helpers
// ============================================================================

function addLogEntry(
  facts: any,
  event: string,
  detail: string,
): void {
  const log = [...(facts.eventLog as EventLogEntry[])];
  log.push({ timestamp: Date.now(), event, detail });
  if (log.length > 50) {
    log.splice(0, log.length - 50);
  }
  facts.eventLog = log;
}

// ============================================================================
// Dashboard Module (core, always loaded)
// ============================================================================

export const dashboardSchema = {
  facts: {
    loadedModules: t.object<string[]>(),
    eventLog: t.object<EventLogEntry[]>(),
  },
  derivations: {
    loadedCount: t.number(),
  },
  events: {
    moduleLoaded: { name: t.string() },
  },
  requirements: {},
} satisfies ModuleSchema;

export const dashboardModule = createModule("dashboard", {
  schema: dashboardSchema,

  init: (facts) => {
    facts.loadedModules = [];
    facts.eventLog = [];
  },

  derive: {
    loadedCount: (facts) => (facts.loadedModules as string[]).length,
  },

  events: {
    moduleLoaded: (facts, { name }) => {
      facts.loadedModules = [...(facts.loadedModules as string[]), name];
      addLogEntry(facts, "loaded", `Loaded "${name}" module`);
    },
  },
});

// ============================================================================
// Counter Module (dynamic)
// ============================================================================

export const counterSchema = {
  facts: {
    count: t.number(),
    step: t.number(),
  },
  derivations: {
    isNearMax: t.boolean(),
  },
  events: {
    increment: {},
    decrement: {},
    setStep: { value: t.number() },
  },
  requirements: {
    COUNTER_RESET: {},
  },
} satisfies ModuleSchema;

export const counterModule = createModule("counter", {
  schema: counterSchema,

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

  derive: {
    isNearMax: (facts) => (facts.count as number) >= 90,
  },

  events: {
    increment: (facts) => {
      facts.count = (facts.count as number) + (facts.step as number);
    },
    decrement: (facts) => {
      facts.count = Math.max(0, (facts.count as number) - (facts.step as number));
    },
    setStep: (facts, { value }) => {
      facts.step = value;
    },
  },

  constraints: {
    overflow: {
      priority: 100,
      when: (facts) => (facts.count as number) >= 100,
      require: () => ({ type: "COUNTER_RESET" }),
    },
  },

  resolvers: {
    counterReset: {
      requirement: "COUNTER_RESET",
      resolve: async (_req, context) => {
        context.facts.count = 0;
      },
    },
  },
});

// ============================================================================
// Weather Module (dynamic)
// ============================================================================

export const weatherSchema = {
  facts: {
    city: t.string(),
    temperature: t.number(),
    condition: t.string(),
    humidity: t.number(),
    isLoading: t.boolean(),
    lastFetchedCity: t.string(),
  },
  derivations: {
    summary: t.string(),
    hasFetched: t.boolean(),
  },
  events: {
    setCity: { value: t.string() },
    refresh: {},
  },
  requirements: {
    FETCH_WEATHER: {
      city: t.string(),
    },
  },
} satisfies ModuleSchema;

export const weatherModule = createModule("weather", {
  schema: weatherSchema,

  init: (facts) => {
    facts.city = "";
    facts.temperature = 0;
    facts.condition = "";
    facts.humidity = 0;
    facts.isLoading = false;
    facts.lastFetchedCity = "";
  },

  derive: {
    summary: (facts) => {
      if ((facts.city as string) === "") {
        return "";
      }

      return `${facts.temperature}\u00B0F, ${facts.condition}`;
    },
    hasFetched: (facts) => (facts.lastFetchedCity as string) !== "",
  },

  events: {
    setCity: (facts, { value }) => {
      facts.city = value;
    },
    refresh: (facts) => {
      facts.lastFetchedCity = "";
    },
  },

  constraints: {
    needsFetch: {
      priority: 100,
      when: (facts) =>
        (facts.city as string).length >= 2 &&
        facts.city !== facts.lastFetchedCity &&
        !(facts.isLoading as boolean),
      require: (facts) => ({
        type: "FETCH_WEATHER",
        city: facts.city as string,
      }),
    },
  },

  resolvers: {
    fetchWeather: {
      requirement: "FETCH_WEATHER",
      key: (req) => `weather-${req.city}`,
      timeout: 10000,
      resolve: async (req, context) => {
        context.facts.isLoading = true;

        const data = await mockFetchWeather(req.city, 800);

        // Stale check: only apply if city still matches
        if ((context.facts.city as string) === req.city) {
          context.facts.temperature = data.temperature;
          context.facts.condition = data.condition;
          context.facts.humidity = data.humidity;
          context.facts.lastFetchedCity = req.city;
        }

        context.facts.isLoading = false;
      },
    },
  },
});

// ============================================================================
// Dice Module (dynamic)
// ============================================================================

export const diceSchema = {
  facts: {
    die1: t.number(),
    die2: t.number(),
    rollCount: t.number(),
  },
  derivations: {
    total: t.number(),
    isDoubles: t.boolean(),
  },
  events: {
    roll: {},
  },
  requirements: {},
} satisfies ModuleSchema;

export const diceModule = createModule("dice", {
  schema: diceSchema,

  init: (facts) => {
    facts.die1 = 1;
    facts.die2 = 1;
    facts.rollCount = 0;
  },

  derive: {
    total: (facts) => (facts.die1 as number) + (facts.die2 as number),
    isDoubles: (facts) => facts.die1 === facts.die2,
  },

  events: {
    roll: (facts) => {
      facts.die1 = Math.floor(Math.random() * 6) + 1;
      facts.die2 = Math.floor(Math.random() * 6) + 1;
      facts.rollCount = (facts.rollCount as number) + 1;
    },
  },
});

// ============================================================================
// Module Registry
// ============================================================================

export const moduleRegistry: Record<string, { module: any; label: string }> = {
  counter: { module: counterModule, label: "Counter" },
  weather: { module: weatherModule, label: "Weather" },
  dice: { module: diceModule, label: "Dice" },
};

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