Skip to main content

Framework Adapters

13 min read

Lit Adapter

Directive provides native Lit integration using the Reactive Controller pattern. Controllers automatically subscribe on connect and clean up on disconnect.


Installation

The Lit adapter is included in the main package:

import { DerivedController, FactController, createDerived } from '@directive-run/lit';

Setup

Create your system and start it in connectedCallback:

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { createSystem } from '@directive-run/core';
import { DerivedController, FactController } from '@directive-run/lit';
import { counterModule } from './modules/counter';

// Create and start the system
const system = createSystem({ module: counterModule });
system.start();

@customElement('my-counter')
class MyCounter extends LitElement {
  // Subscribe to the count derivation – re-renders when it changes
  private count = new DerivedController<number>(this, system, 'count');

  render() {
    return html`
      <div>
        <p>Count: ${this.count.value}</p>
        <button @click=${() => system.facts.count--}>-</button>
        <button @click=${() => system.facts.count++}>+</button>
      </div>
    `;
  }
}

Each controller calls host.addController(this) in its constructor, subscribes in hostConnected, and unsubscribes in hostDisconnected. You never manage subscriptions manually.


Core Controllers

DerivedController

Subscribe to one or more derivations. The host re-renders when any value changes.

Single key:

import { DerivedController } from '@directive-run/lit';

class StatusDisplay extends LitElement {
  // Subscribe to the isRed derivation – re-renders when it changes
  private isRed = new DerivedController<boolean>(this, system, 'isRed');

  render() {
    return html`<div>${this.isRed.value ? 'Red' : 'Not Red'}</div>`;
  }
}

Array of keys:

import { DerivedController } from '@directive-run/lit';

class StatusBar extends LitElement {
  // Subscribe to multiple derivations at once
  private state = new DerivedController<{ isRed: boolean; elapsed: number }>(
    this, system, ['isRed', 'elapsed']
  );

  render() {
    const { isRed, elapsed } = this.state.value;

    return html`<div>${isRed ? `Red for ${elapsed}s` : 'Not Red'}</div>`;
  }
}

FactController

Subscribe to a single fact:

import { FactController } from '@directive-run/lit';

class PhaseDisplay extends LitElement {
  // Subscribe to the current phase – re-renders when it changes
  private phase = new FactController<string>(this, system, 'phase');

  render() {
    return html`<div>Current phase: ${this.phase.value}</div>`;
  }
}

WatchController

Watch a fact or derivation and fire a callback on change (no re-render). The key is auto-detected as either a fact or derivation, so no discriminator is needed:

import { WatchController } from '@directive-run/lit';

class PhaseWatcher extends LitElement {
  // Watch the phase derivation for logging – auto-detected
  private watcher = new WatchController<string>(
    this, system, 'phase',
    (newPhase, oldPhase) => {
      console.log(`Phase changed from ${oldPhase} to ${newPhase}`);
    }
  );

  // Watch the count fact for logging – auto-detected, no discriminator needed
  private countWatcher = new WatchController<number>(
    this, system, 'count',
    (newCount, oldCount) => {
      console.log(`Count changed from ${oldCount} to ${newCount}`);
    }
  );
}

Deprecated: fact discriminator object

The old { kind: "fact", factKey: "key" } options pattern still works but is deprecated. Use the string key directly instead – the runtime auto-detects whether the key is a fact or derivation.

// Deprecated – still works but not recommended
private watcher = new WatchController<number>(
  this, system,
  { kind: "fact", factKey: "count" },
  (newCount, oldCount) => { /* ... */ }
);

// Preferred – auto-detects fact vs derivation
private watcher = new WatchController<number>(
  this, system, 'count',
  (newCount, oldCount) => { /* ... */ }
);

Inspection Controllers

InspectController

Get system inspection data with optional throttling. Returns InspectState with isSettled, unmet, inflight, isWorking, hasUnmet, and hasInflight:

import { InspectController } from '@directive-run/lit';

class Inspector extends LitElement {
  // Get reactive system inspection data
  private inspection = new InspectController(this, system);

  render() {
    return html`
      <div>Settled: ${this.inspection.value.isSettled}</div>
      <div>Unmet: ${this.inspection.value.unmet.length}</div>
      <div>Inflight: ${this.inspection.value.inflight.length}</div>
      <div>Working: ${this.inspection.value.isWorking}</div>
    `;
  }
}

For high-frequency updates, pass { throttleMs }:

class ThrottledInspector extends LitElement {
  // Throttle inspection updates to limit render frequency
  private inspection = new InspectController(this, system, { throttleMs: 200 });

  render() {
    if (!this.inspection.value.isSettled) {
      return html`<spinner-el></spinner-el>`;
    }

    return html`<content-el></content-el>`;
  }
}

ExplainController

Get a reactive explanation of why a requirement exists:

import { ExplainController } from '@directive-run/lit';

class RequirementDebug extends LitElement {
  // Get a detailed explanation of why the FETCH_USER requirement exists
  private explanation = new ExplainController(this, system, 'FETCH_USER');

  render() {
    if (!this.explanation.value) {
      return html`<p>No active requirement</p>`;
    }

    return html`<pre>${this.explanation.value}</pre>`;
  }
}

ConstraintStatusController

Read constraint status reactively. Without a constraint ID, returns all constraints. With an ID, returns a single constraint or null:

import { ConstraintStatusController } from '@directive-run/lit';

// All constraints
class ConstraintDashboard extends LitElement {
  // Get all constraints for the debug panel
  private constraints = new ConstraintStatusController(this, system);

  render() {
    const all = this.constraints.value as ConstraintInfo[];

    return html`
      <ul>
        ${all.map(c => html`
          <li>${c.id}: ${c.active ? 'Active' : 'Inactive'} (priority: ${c.priority})</li>
        `)}
      </ul>
    `;
  }
}

// Single constraint
class AuthConstraint extends LitElement {
  // Check a specific constraint by ID
  private auth = new ConstraintStatusController(this, system, 'requireAuth');

  render() {
    const info = this.auth.value as ConstraintInfo | null;
    if (!info) {
      return html`<p>Constraint not found</p>`;
    }

    return html`<p>Auth: ${info.active ? 'Active' : 'Inactive'}</p>`;
  }
}

OptimisticUpdateController

Apply optimistic mutations with automatic rollback on resolver failure:

import { OptimisticUpdateController } from '@directive-run/lit';

class SaveButton extends LitElement {
  // Set up optimistic mutations with automatic rollback
  private optimistic = new OptimisticUpdateController(
    this, system, statusPlugin, 'SAVE_DATA'
  );

  handleSave() {
    this.optimistic.mutate(() => {
      // Optimistically update the UI before the server responds
      system.facts.savedAt = Date.now();
      system.facts.status = 'saved';
    });
    // If "SAVE_DATA" resolver fails, facts are rolled back automatically
  }

  render() {
    return html`
      <button @click=${() => this.handleSave()} ?disabled=${this.optimistic.isPending}>
        ${this.optimistic.isPending ? 'Saving...' : 'Save'}
      </button>
      ${this.optimistic.error
        ? html`<div class="error">${this.optimistic.error.message}</div>`
        : ''}
    `;
  }
}

Manual rollback is also available via rollback(). The statusPlugin and requirementType parameters are optional – without them, you get manual-only rollback.

ModuleController

Zero-config all-in-one controller. Creates a system scoped to the component lifecycle, starts it, and subscribes to all facts and derivations:

import { ModuleController } from '@directive-run/lit';
import { counterModule } from './modules/counter';

class CounterApp extends LitElement {
  // Create a zero-config scoped system tied to this element's lifecycle
  private mod = new ModuleController(this, counterModule);

  render() {
    return html`
      <p>Count: ${this.mod.facts.count}</p>
      <p>Doubled: ${this.mod.derived.doubled}</p>
      <button @click=${() => this.mod.events.increment()}>+</button>
      <button @click=${() => this.mod.dispatch({ type: 'reset' })}>Reset</button>
    `;
  }
}

With plugins and status tracking:

class AppElement extends LitElement {
  // Create a scoped system with plugins, time-travel, and status tracking
  private mod = new ModuleController(this, myModule, {
    plugins: [loggingPlugin()],
    debug: { timeTravel: true },
    status: true,
    initialFacts: { count: 10 },
  });

  render() {
    const status = this.mod.statusPlugin?.getStatus('FETCH_DATA');

    return html`
      <div>${status?.isLoading ? 'Loading...' : 'Ready'}</div>
    `;
  }
}

Selector Controller

Use DirectiveSelectorController for all transforms and derived values from facts. It auto-tracks which fact keys your selector reads and subscribes only to those.

DirectiveSelectorController

Select across all facts:

import { DirectiveSelectorController } from '@directive-run/lit';

class Summary extends LitElement {
  // Select across all facts with custom equality
  private summary = new DirectiveSelectorController(
    this, system,
    (facts) => ({ count: facts.items?.length ?? 0, loading: facts.loading ?? false }),
    (a, b) => a.count === b.count && a.loading === b.loading
  );

  render() {
    return html`<div>${this.summary.value.count} items</div>`;
  }
}

Transform a single fact:

class UserName extends LitElement {
  // Derive the user's display name – only re-renders when the name changes
  private userName = new DirectiveSelectorController<string>(
    this, system,
    (facts) => facts.user?.name ?? 'Guest',
  );

  render() {
    return html`<span>${this.userName.value}</span>`;
  }
}

Status Controllers

These controllers require a statusPlugin created with createRequirementStatusPlugin:

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

// Create the status plugin for tracking requirement resolution
const statusPlugin = createRequirementStatusPlugin();

// Pass the plugin when creating the system
const system = createSystem({
  module: myModule,
  plugins: [statusPlugin.plugin],
});
system.start();

RequirementStatusController

Full status for a requirement type:

class UserLoader extends LitElement {
  // Track the loading state of the FETCH_USER requirement
  private status = new RequirementStatusController(this, statusPlugin, 'FETCH_USER');

  render() {
    if (this.status.value.isLoading) {
      return html`<spinner-el></spinner-el>`;
    }

    if (this.status.value.hasError) {
      return html`<error-el .message=${this.status.value.lastError?.message}></error-el>`;
    }

    return html`<user-content></user-content>`;
  }
}

Factory Functions

Every controller has a factory function shorthand. These are functionally identical to using new directly:

import {
  createDerived,
  createFact,
  createInspect,
  createRequirementStatus,
  createWatch,
  createDirectiveSelector,
  createExplain,
  createConstraintStatus,
  createOptimisticUpdate,
  createModule,
} from '@directive-run/lit';

class MyElement extends LitElement {
  // These two are equivalent:
  private isRed = new DerivedController<boolean>(this, system, 'isRed');
  private isRed2 = createDerived<boolean>(this, system, 'isRed');

  // Subscribe to a single fact
  private phase = createFact<string>(this, system, 'phase');

  // Subscribe to multiple derivations as a single object
  private state = createDerived<{ isRed: boolean }>(this, system, ['isRed']);

  // Get system inspection with throttling
  private inspection = createInspect(this, system, { throttleMs: 100 });

  // Get a requirement explanation
  private explanation = createExplain(this, system, 'FETCH_USER');

  // Get all constraint statuses
  private constraints = createConstraintStatus(this, system);

  // Get a single constraint by ID
  private authConstraint = createConstraintStatus(this, system, 'requireAuth');

  // Set up optimistic mutations with rollback
  private optimistic = createOptimisticUpdate(this, system, statusPlugin, 'SAVE_DATA');
}

The createModule factory creates a ModuleController:

class CounterApp extends LitElement {
  // Create a scoped system with the module factory
  private mod = createModule(this, counterModule, {
    status: true,
    debug: { timeTravel: true },
  });

  render() {
    return html`<p>Count: ${this.mod.facts.count}</p>`;
  }
}

Non-Reactive Utilities

For event handlers and imperative code where you do not need reactivity:

useDispatch

Get a typed dispatch function:

import { useDispatch } from '@directive-run/lit';

class Controls extends LitElement {
  // Get a typed dispatch function for sending events
  private dispatch = useDispatch(system);

  handleClick() {
    this.dispatch({ type: 'tick' });
  }
}

useEvents

Get a typed reference to the system's event dispatchers:

import { useEvents } from '@directive-run/lit';

class Controls extends LitElement {
  // Get typed event dispatchers for the system
  private events = useEvents(system);

  handleClick() {
    this.events.increment();
  }
}

shallowEqual

Re-exported utility for custom equality in selectors:

import { shallowEqual, DirectiveSelectorController } from '@directive-run/lit';

class UserIds extends LitElement {
  // Use shallowEqual to prevent re-renders when IDs haven't changed
  private ids = new DirectiveSelectorController<number[]>(
    this, system,
    (facts) => facts.users?.map(u => u.id) ?? [],
    shallowEqual,
  );
}

TimeTravelController

Reactive controller – returns null when disabled, otherwise the full TimeTravelState. Destructure in render() to pull out what you need:

Undo / Redo Controls

import { TimeTravelController } from '@directive-run/lit';

class UndoRedo extends LitElement {
  private _timeTravel = new TimeTravelController(this, system);

  render() {
    const timeTravel = this._timeTravel.value;

    if (!timeTravel) {
      return html``;
    }

    const { canUndo, canRedo, undo, redo, currentIndex, totalSnapshots } = timeTravel;

    return html`
      <button @click=${undo} ?disabled=${!canUndo}>Undo</button>
      <button @click=${redo} ?disabled=${!canRedo}>Redo</button>
      <span>${currentIndex + 1} / ${totalSnapshots}</span>
    `;
  }
}

Snapshot Timeline

snapshots is lightweight metadata only (no facts data). Use getSnapshotFacts(id) to lazily load a snapshot's state on demand:

class SnapshotTimeline extends LitElement {
  private _timeTravel = new TimeTravelController(this, system);

  render() {
    const timeTravel = this._timeTravel.value;

    if (!timeTravel) {
      return html``;
    }

    const { snapshots, goTo, getSnapshotFacts } = timeTravel;

    return html`
      <ul>
        ${snapshots.map((snap) => html`
          <li>
            <button @click=${() => goTo(snap.id)}>
              ${snap.trigger}${new Date(snap.timestamp).toLocaleTimeString()}
            </button>
            <button @click=${() => console.log(getSnapshotFacts(snap.id))}>
              Inspect
            </button>
          </li>
        `)}
      </ul>
    `;
  }
}
class TimeTravelControls extends LitElement {
  private _timeTravel = new TimeTravelController(this, system);

  render() {
    const timeTravel = this._timeTravel.value;

    if (!timeTravel) {
      return html``;
    }

    const {
      goBack, goForward, goTo, replay,
      exportSession, importSession,
      isPaused, pause, resume,
    } = timeTravel;

    return html`
      <!-- Navigation -->
      <button @click=${() => goBack(5)}>Back 5</button>
      <button @click=${() => goForward(5)}>Forward 5</button>
      <button @click=${() => goTo(0)}>Jump to Start</button>
      <button @click=${replay}>Replay All</button>

      <!-- Session persistence -->
      <button @click=${() => localStorage.setItem('debug', exportSession())}>
        Save Session
      </button>
      <button @click=${() => {
        const saved = localStorage.getItem('debug');
        if (saved) {
          importSession(saved);
        }
      }}>
        Restore Session
      </button>

      <!-- Recording control -->
      <button @click=${isPaused ? resume : pause}>
        ${isPaused ? 'Resume' : 'Pause'} Recording
      </button>
    `;
  }
}

Changesets

Group multiple fact mutations into a single undo/redo unit:

class BatchedAction extends LitElement {
  private _timeTravel = new TimeTravelController(this, system);

  private _handleComplexAction() {
    this._timeTravel.value?.beginChangeset('Move piece A→B');
    // ... multiple fact mutations ...
    this._timeTravel.value?.endChangeset();
    // Now undo/redo treats all mutations as one step
  }

  render() {
    return html`<button @click=${this._handleComplexAction}>Move Piece</button>`;
  }
}

useTimeTravel

Non-reactive shorthand (useful outside Lit elements for imperative code):

import { useTimeTravel } from '@directive-run/lit';

const timeTravel = useTimeTravel(system);

if (timeTravel) {
  const { undo, redo, goTo, goBack, goForward, replay } = timeTravel;
  const { exportSession, importSession } = timeTravel;
  const { beginChangeset, endChangeset } = timeTravel;
  const { isPaused, pause, resume } = timeTravel;

  // Navigate
  undo();
  goBack(3);
  goTo(0);

  // Persist
  localStorage.setItem('debug', exportSession());

  // Changesets
  beginChangeset('batch edit');
  // ... mutations ...
  endChangeset();

  // Recording
  isPaused ? resume() : pause();
}

See Time-Travel for the full TimeTravelState interface and keyboard shortcuts.

getDerived / getFact

Non-reactive getter functions (return a function you call to read the current value):

import { getDerived, getFact } from '@directive-run/lit';

// Create non-reactive getters for reading values on demand
const getIsRed = getDerived<boolean>(system, 'isRed');
const getPhase = getFact<string>(system, 'phase');

console.log(getIsRed()); // Current value, non-reactive
console.log(getPhase()); // Current value, non-reactive

Scoped Systems

SystemController

Create a system scoped to a component's lifecycle. The system starts on connect and is destroyed on disconnect:

import { SystemController, DerivedController } from '@directive-run/lit';
import { counterModule } from './modules/counter';

class CounterElement extends LitElement {
  // Create a system scoped to this element's lifecycle
  private directive = new SystemController(this, counterModule);

  // Access the scoped system for other controllers
  private count?: DerivedController<number>;

  connectedCallback() {
    super.connectedCallback();
    this.count = new DerivedController<number>(
      this, this.directive.system, 'count'
    );
  }

  render() {
    return html`
      <button @click=${() => this.directive.system.facts.count++}>
        Count: ${this.count?.value ?? 0}
      </button>
    `;
  }
}

You can also pass full system options:

class AppElement extends LitElement {
  // Create a system with plugins and time-travel debugging
  private directive = new SystemController(this, {
    module: myModule,
    plugins: [loggingPlugin()],
    debug: { timeTravel: true },
  });
}

Typed Hooks

Create typed controllers for your module schema. The factory also returns useEvents:

import { createTypedHooks } from '@directive-run/lit';
import type { ModuleSchema } from '@directive-run/core';

// Create typed controller factories pre-bound to your schema
const {
  createDerived,
  createFact,
  useDispatch,
  useEvents,
} = createTypedHooks<typeof myModule.schema>();

class Counter extends LitElement {
  // Fully typed – key autocompletes, return type inferred
  private count = createFact(this, system, 'count');       // Type: FactController<number>
  private doubled = createDerived(this, system, 'doubled'); // Type: DerivedController<number>
  private dispatch = useDispatch(system);
  private events = useEvents(system);                       // Typed event dispatchers

  handleClick() {
    this.events.increment(); // Typed!
  }
}

Context Protocol

Use @lit/context to share a system across shadow DOM boundaries:

import { provide, consume } from '@lit/context';
import { directiveContext } from '@directive-run/lit';

// Share the system with all descendant elements via Lit context
@customElement('app-root')
class AppRoot extends LitElement {
  @provide({ context: directiveContext })
  system = createSystem({ module: myModule });

  connectedCallback() {
    super.connectedCallback();
    this.system.start();
  }
}

// Consume the system from an ancestor provider
@customElement('child-widget')
class ChildWidget extends LitElement {
  @consume({ context: directiveContext })
  system!: System<typeof myModule.schema>;

  private count = new DerivedController<number>(this, this.system, 'count');

  render() {
    return html`<span>Count: ${this.count.value}</span>`;
  }
}

Patterns

Loading States

class UserCard extends LitElement {
  // Subscribe to loading and user state
  private loading = new FactController<boolean>(this, system, 'loading');
  private user = new FactController<User | null>(this, system, 'user');

  // Track the loading state of the fetch requirement
  private status = new RequirementStatusController(this, statusPlugin, 'FETCH_USER');

  render() {
    if (this.loading.value) {
      return html`<spinner-el></spinner-el>`;
    }

    if (this.status.value.hasError) {
      return html`<error-el .message=${this.status.value.lastError?.message}></error-el>`;
    }

    if (!this.user.value) {
      return html`<empty-state></empty-state>`;
    }

    return html`<user-details .user=${this.user.value}></user-details>`;
  }
}

Multiple Systems

Use separate controllers for different systems:

class Dashboard extends LitElement {
  // Subscribe to derivations from different systems
  private userName = new DerivedController<string>(this, authSystem, 'displayName');
  private cartCount = new DerivedController<number>(this, cartSystem, 'itemCount');

  render() {
    return html`
      <header>${this.userName.value}</header>
      <cart-badge .count=${this.cartCount.value}></cart-badge>
    `;
  }
}

Testing

import { fixture, html, expect } from '@open-wc/testing';
import { createTestSystem } from '@directive-run/core/testing';
import { counterModule } from './modules/counter';
import './my-counter';

it('displays the count', async () => {
  // Create a test system with mock data
  const system = createTestSystem({ module: counterModule });
  system.facts.count = 5;

  const el = await fixture(html`<my-counter></my-counter>`);
  expect(el.shadowRoot?.textContent).to.contain('5');
});

Next Steps

  • Quick Start – Build your first module
  • Facts – State management deep dive
  • Testing – Testing components with Directive
Previous
Solid

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