Skip to main content

Examples

Notifications

Toast queue with auto-dismiss, priority ordering, overflow handling, and cross-module triggers.

Try it

Loading example…

Click the buttons to add notifications. Watch them auto-dismiss based on level (errors stay longer). Try “Burst” to test overflow handling.

How it works

A notification module manages a queue with auto-dismiss constraints driven by tickMs, while an app module demonstrates cross-module notification triggers.

  1. Facts queue (notification array), maxVisible, now (ticking timestamp), and idCounter
  2. Derivations visibleNotifications (first N from queue), oldestExpired (checks TTL against ticking now)
  3. Constraints autoDismiss (priority 50) fires when the oldest notification exceeds its TTL; overflow (priority 60) removes excess notifications first
  4. tickMs – the system ticks every 1000ms, advancing now and driving constraint re-evaluation without manual timers

Summary

What: A notification queue with level-based TTL (errors 10s, info 4s), priority-based overflow handling, and cross-module triggers.

How: The tickMs system option drives a ticking now fact. The autoDismiss constraint checks if the oldest notification has exceeded its TTL, while overflow handles queue limits at higher priority.

Why it works: Time-based constraints replace manual setTimeout chains. Priority ordering ensures overflow is handled before TTL-based dismissal. Any module can trigger notifications through events.

Source code

/**
 * Notifications & Toasts — Directive Modules
 *
 * Two modules:
 * - notifications: queue management, auto-dismiss via constraints, overflow protection
 * - app: action log that triggers cross-module notifications via effects
 */

import { createModule, t, type ModuleSchema } from "@directive-run/core";

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

export interface Notification {
  id: string;
  message: string;
  level: "info" | "success" | "warning" | "error";
  createdAt: number;
  ttl: number;
}

// ============================================================================
// Notifications Module
// ============================================================================

export const notificationsSchema = {
  facts: {
    queue: t.object<Notification[]>(),
    maxVisible: t.number(),
    now: t.number(),
    idCounter: t.number(),
  },
  derivations: {
    visibleNotifications: t.object<Notification[]>(),
    hasNotifications: t.boolean(),
    oldestExpired: t.object<Notification | null>(),
  },
  events: {
    addNotification: {
      message: t.string(),
      level: t.string(),
      ttl: t.number().optional(),
    },
    dismissNotification: { id: t.string() },
    tick: {},
    setMaxVisible: { value: t.number() },
  },
  requirements: {
    DISMISS_NOTIFICATION: { id: t.string() },
  },
} satisfies ModuleSchema;

export const notificationsModule = createModule("notifications", {
  schema: notificationsSchema,

  init: (facts) => {
    facts.queue = [];
    facts.maxVisible = 5;
    facts.now = Date.now();
    facts.idCounter = 0;
  },

  // ============================================================================
  // Derivations
  // ============================================================================

  derive: {
    visibleNotifications: (facts) => {
      return (facts.queue as Notification[]).slice(0, facts.maxVisible as number);
    },

    hasNotifications: (facts) => {
      return (facts.queue as Notification[]).length > 0;
    },

    oldestExpired: (facts) => {
      const queue = facts.queue as Notification[];
      const oldest = queue[0];
      if (!oldest) {
        return null;
      }

      if ((facts.now as number) > oldest.createdAt + oldest.ttl) {
        return oldest;
      }

      return null;
    },
  },

  // ============================================================================
  // Constraints
  // ============================================================================

  constraints: {
    autoDismiss: {
      priority: 50,
      when: (_facts, derive) => derive.oldestExpired !== null,
      require: (_facts, derive) => ({
        type: "DISMISS_NOTIFICATION" as const,
        id: (derive.oldestExpired as Notification).id,
      }),
    },

    overflow: {
      priority: 60,
      when: (facts) => {
        const queue = facts.queue as Notification[];

        return queue.length > (facts.maxVisible as number) + 5;
      },
      require: (facts) => ({
        type: "DISMISS_NOTIFICATION" as const,
        id: (facts.queue as Notification[])[0].id,
      }),
    },
  },

  // ============================================================================
  // Resolvers
  // ============================================================================

  resolvers: {
    dismiss: {
      requirement: "DISMISS_NOTIFICATION",
      resolve: async (req, context) => {
        context.facts.queue = (context.facts.queue as Notification[]).filter(
          (n) => n.id !== req.id,
        );
      },
    },
  },

  // ============================================================================
  // Events
  // ============================================================================

  events: {
    addNotification: (facts, payload: { message: string; level: string; ttl?: number }) => {
      const ttlMap: Record<string, number> = {
        info: 4000,
        success: 3000,
        warning: 6000,
        error: 10000,
      };
      const counter = (facts.idCounter as number) + 1;
      facts.idCounter = counter;

      const notification: Notification = {
        id: `notif-${counter}`,
        message: payload.message,
        level: payload.level as Notification["level"],
        createdAt: Date.now(),
        ttl: payload.ttl ?? ttlMap[payload.level] ?? 4000,
      };

      facts.queue = [...(facts.queue as Notification[]), notification];
    },

    dismissNotification: (facts, { id }: { id: string }) => {
      facts.queue = (facts.queue as Notification[]).filter((n) => n.id !== id);
    },

    tick: (facts) => {
      facts.now = Date.now();
    },

    setMaxVisible: (facts, { value }: { value: number }) => {
      facts.maxVisible = value;
    },
  },
});

// ============================================================================
// App Module
// ============================================================================

export const appSchema = {
  facts: {
    actionLog: t.object<string[]>(),
  },
  events: {
    simulateAction: { message: t.string(), level: t.string() },
  },
} satisfies ModuleSchema;

export const appModule = createModule("app", {
  schema: appSchema,

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

  events: {
    simulateAction: (facts, { message }: { message: string }) => {
      facts.actionLog = [...(facts.actionLog as string[]), message];
    },
  },
});

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