Skip to main content

Guides

•

3 min read

How to Debounce Constraint Evaluation

Prevent constraints from firing too frequently during rapid user input.


The Problem

When a user types in a search box, each keystroke updates a fact, which triggers constraint evaluation, which emits a requirement, which fires a resolver. Without debouncing, a 10-character search query fires 10 API calls. The first 9 are wasted work, and their responses may arrive out of order, showing stale results.

The Solution

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

const search = createModule('search', {
  schema: {
    facts: {
      query: t.string(),
      debouncedQuery: t.string(),
      results: t.array<{ id: string; title: string }>(),
      isSearching: t.boolean(),
    },
    events: {
      QUERY_SETTLED: { query: t.string() },
    },
  },

  init: (facts) => {
    facts.query = '';
    facts.debouncedQuery = '';
    facts.results = [];
    facts.isSearching = false;
  },

  // Debounce with a keyed source. Every keystroke changes the key, which
  // tears down the previous attach (and its timer) before the next starts.
  // Time travel never re-attaches a source, so a rewind can't fire a stale timer.
  sources: {
    debounceQuery: {
      key: (facts) => `q:${facts.query}`,
      attach: (publish, _reportError, ctx) => {
        const query = ctx!.key.slice(2);
        const timer = setTimeout(() => publish('QUERY_SETTLED', { query }), 300);

        return () => clearTimeout(timer);
      },
    },
  },

  events: {
    QUERY_SETTLED: (facts, { query }) => {
      facts.debouncedQuery = query;
    },
  },

  constraints: {
    // Fires only on debouncedQuery, not raw keystrokes
    needsSearch: {
      when: (facts) =>
        facts.debouncedQuery.length >= 2 &&
        !facts.isSearching,
      require: (facts) => ({
        type: 'SEARCH',
        query: facts.debouncedQuery,
      }),
    },
  },

  resolvers: {
    search: {
      requirement: 'SEARCH',
      // Key by query – if debouncedQuery changes while in-flight,
      // the old request is superseded
      key: (req) => `search-${req.query}`,
      resolve: async (req, context) => {
        context.facts.isSearching = true;
        try {
          const res = await fetch(`/api/search?q=${encodeURIComponent(req.query)}`);
          if (!res.ok) {
            throw new Error(`Search failed: ${res.status}`);
          }

          const data = await res.json();
          // Only apply if this is still the current query
          if (context.facts.debouncedQuery === req.query) {
            context.facts.results = data.results;
          }
        } finally {
          context.facts.isSearching = false;
        }
      },
    },
  },
});
function SearchBox({ system }) {
  const { facts } = useDirective(system);

  return (
    <div>
      <input
        value={facts.query}
        onChange={(e) => { system.facts.query = e.target.value; }}
        placeholder="Search..."
      />
      {facts.isSearching && <Spinner />}
      <ul>
        {facts.results.map((r) => (
          <li key={r.id}>{r.title}</li>
        ))}
      </ul>
    </div>
  );
}

Step by Step

  1. query updates on every keystroke – the input writes directly to facts.query, giving instant feedback in the text field.

  2. A keyed source debounces – debounceQuery is keyed by the current query. Each keystroke changes the key, so the source detaches (its cleanup cancels the old timer) and attaches again with a new 300ms timer. Only when typing pauses for 300ms does it publish QUERY_SETTLED, whose handler updates debouncedQuery.

  3. Constraint watches debouncedQuery – since needsSearch reads debouncedQuery (not query), it only evaluates after the debounce settles.

  4. Resolver key prevents stale results – key: (req) => \search-${req.query}`` ensures each unique query gets its own resolution. If the user types again before the previous search completes, the old result is ignored.

Common Variations

Throttle instead of debounce

Leading-edge throttle needs no timer. Do it in the event handler that receives the input, and add a lastEmittedAt: t.number() fact:

events: {
  setQuery: (facts, { value }) => {
    facts.query = value;
    const now = Date.now();
    if (now - facts.lastEmittedAt >= 500) {
      facts.debouncedQuery = value;
      facts.lastEmittedAt = now;
    }
  },
},

Minimum character guard in the constraint

constraints: {
  needsSearch: {
    when: (facts) =>
      facts.debouncedQuery.length >= 3 && // Min 3 chars
      facts.debouncedQuery !== facts.lastSearchedQuery, // Don't re-search same query
    require: (facts) => ({
      type: 'SEARCH',
      query: facts.debouncedQuery,
    }),
  },
},
Previous
Batch Mutations

Stay in the loop. Sign up for our newsletter.

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 Runtime for TypeScript | AI Guardrails & State Management