Skip to main content

Getting Started

5 min read

Choosing the Right Primitive

When to use each Directive primitive — and when not to.


Decision Tree

Start here when you're unsure which primitive to reach for:

  1. Are you storing a value the user or server provides directly?Fact
  2. Are you computing a value from other facts?Derivation
  3. Do you need to react to a user action synchronously?Event
  4. Do you need to declare "this must be true" and let the runtime figure out how?Constraint + Resolver
  5. Do you need a fire-and-forget side effect (logging, DOM updates, analytics)?Effect

If you're still unsure, ask: does this thing produce requirements, or consume them? Constraints produce requirements. Resolvers consume them. Everything else is either data (facts, derivations) or reactions (events, effects).


Comparison Table

PrimitivePurposeSync/AsyncReads StateWrites StateExample
FactSource of truthSyncYesfacts.user = data
DerivationComputed valueSyncYesNoisAdmin: (facts) => facts.role === 'admin'
EventSynchronous mutationSyncYesYesevents.addItem(facts, payload)
ConstraintDeclares a requirementSync or AsyncYesNowhen: (facts) => !facts.userrequire: { type: 'FETCH_USER' }
ResolverFulfills a requirementAsyncYesYesresolve: async (req, context) => { ... }
EffectSide effectSyncYesNo (should not)run: (facts) => document.title = facts.title

Key distinctions:

  • Derivations vs Effects: Derivations compute values. Effects perform side effects. If you need the result, it's a derivation. If you need the action, it's an effect.
  • Events vs Resolvers: Events are synchronous and immediate. Resolvers handle async work triggered by constraints.
  • Constraints vs Effects: Constraints say "something is missing." Effects say "something changed, react to it."

Common Mistakes

Putting async logic in events

// Wrong — events are synchronous
events: {
  fetchUser: async (facts) => {
    const res = await fetch('/api/user'); // Don't do this
    facts.user = await res.json();
  },
},

// Right — use a constraint + resolver
constraints: {
  needsUser: {
    when: (facts) => !facts.user && facts.token,
    require: { type: 'FETCH_USER' },
  },
},
resolvers: {
  fetchUser: {
    requirement: 'FETCH_USER',
    resolve: async (req, context) => {
      const res = await fetch('/api/user');
      context.facts.user = await res.json();
    },
  },
},

Mutating facts in effects

// Wrong — effects shouldn't write to facts
effects: {
  syncTheme: {
    run: (facts) => {
      facts.theme = window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark' : 'light'; // Don't mutate facts here
    },
  },
},

// Right — use a constraint + resolver to read the system preference
constraints: {
  needsThemeDetection: {
    when: (facts) => !facts.themeDetected,
    require: { type: 'DETECT_THEME' },
  },
},
resolvers: {
  detectTheme: {
    requirement: 'DETECT_THEME',
    resolve: async (req, context) => {
      const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      context.facts.theme = dark ? 'dark' : 'light';
      context.facts.themeDetected = true;
    },
  },
},

Using constraints for synchronous transforms

// Wrong — constraint + resolver for a simple computation
constraints: {
  needsFullName: {
    when: (facts) => !facts.fullName && facts.firstName,
    require: { type: 'COMPUTE_NAME' },
  },
},

// Right — just use a derivation
derive: {
  fullName: (facts) => `${facts.firstName} ${facts.lastName}`,
},

Over-constraining: when a simple event is enough

// Overkill — constraint + resolver for a synchronous toggle
constraints: {
  needsToggle: {
    when: (facts) => facts.toggleRequested,
    require: { type: 'TOGGLE_SIDEBAR' },
  },
},

// Right — just use an event
events: {
  toggleSidebar: (facts) => {
    facts.sidebarOpen = !facts.sidebarOpen;
  },
},

Same Feature, Two Ways

1. Theme Toggle

Wrong: Constraint + Resolver

constraints: {
  needsThemeSwitch: {
    when: (facts) => facts.themeChangeRequested,
    require: { type: 'SWITCH_THEME' },
  },
},
resolvers: {
  switchTheme: {
    requirement: 'SWITCH_THEME',
    resolve: async (req, context) => {
      context.facts.theme = context.facts.theme === 'light' ? 'dark' : 'light';
      context.facts.themeChangeRequested = false;
    },
  },
},

Right: Event (synchronous, no async needed)

events: {
  toggleTheme: (facts) => {
    facts.theme = facts.theme === 'light' ? 'dark' : 'light';
  },
},

2. Filtered List

Wrong: Effect that writes facts

effects: {
  filterItems: {
    deps: ['searchQuery', 'items'],
    run: (facts) => {
      facts.filtered = facts.items.filter(i => i.name.includes(facts.searchQuery));
    },
  },
},

Right: Derivation (pure computation)

derive: {
  filteredItems: (facts) => {
    return facts.items.filter(i => i.name.includes(facts.searchQuery));
  },
},

3. Loading User Data

Wrong: Event with async logic

events: {
  loadUser: async (facts) => {
    facts.loading = true;
    const user = await fetchUser(facts.userId);
    facts.user = user;
    facts.loading = false;
  },
},

Right: Constraint + Resolver

constraints: {
  needsUser: {
    when: (facts) => !facts.user && facts.userId,
    require: (facts) => ({ type: 'LOAD_USER', userId: facts.userId }),
  },
},
resolvers: {
  loadUser: {
    requirement: 'LOAD_USER',
    resolve: async (req, context) => {
      const user = await fetchUser(req.userId);
      context.facts.user = user;
    },
  },
},

4. Page Title Sync

Wrong: Derivation with side effects

derive: {
  pageTitle: (facts) => {
    const title = `${facts.currentPage} - MyApp`;
    document.title = title; // Side effect in a derivation!

    return title;
  },
},

Right: Derivation + Effect

derive: {
  pageTitle: (facts) => `${facts.currentPage} - MyApp`,
},
effects: {
  syncTitle: {
    deps: ["currentPage"],
    run: (facts) => {
      document.title = `${facts.currentPage} - MyApp`;
    },
  },
},

5. Auto-Save

Wrong: Effect that triggers async work

effects: {
  autoSave: {
    deps: ['document'],
    run: async (facts) => {
      await fetch('/api/save', {
        method: 'POST',
        body: JSON.stringify(facts.document),
      });
    },
  },
},

Right: Constraint + Resolver (with debounce)

constraints: {
  needsSave: {
    when: (facts) => facts.isDirty && !facts.isSaving,
    require: (facts) => ({ type: 'SAVE_DOCUMENT', content: facts.document }),
  },
},
resolvers: {
  saveDocument: {
    requirement: 'SAVE_DOCUMENT',
    resolve: async (req, context) => {
      await fetch('/api/save', {
        method: 'POST',
        body: JSON.stringify(req.content),
      });
      context.facts.isDirty = false;
    },
  },
},

Previous
Comparison

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