Skip to main content

Data Fetching

6 min read

Queries

Queries fetch data on demand and cache it as facts. When the facts they depend on change, they refetch automatically.


Basic Query

import { createQuery } from "@directive-run/query";

const user = createQuery({
  name: "user",
  key: (facts) => facts.userId ? { userId: facts.userId } : null,
  fetcher: async (params, signal) => {
    const res = await fetch(`/api/users/${params.userId}`, { signal });
    return res.json();
  },
});
  • name – unique identifier, becomes the derivation key for system.read("user")
  • key – derive params from facts. Return null to disable the query.
  • fetcher – async function that receives typed params + AbortSignal.

Options

Caching

createQuery({
  name: "user",
  key: (facts) => facts.userId ? { userId: facts.userId } : null,
  fetcher: async (params, signal) => api.getUser(params.userId),
  refetchAfter: 30_000,     // data is stale after 30s
  expireAfter: 5 * 60_000,  // clear cache 5 min after query goes idle
  structuralSharing: true,   // preserve references if data unchanged (default)
});

Cache Expiration

When a query goes idle (key returns null), the cache is automatically cleared after expireAfter milliseconds. If the query reactivates before the timer fires, the cached data is preserved.

createQuery({
  name: "user",
  key: (f) => f.userId ? { userId: f.userId } : null,
  fetcher: async (p) => api.getUser(p.userId),
  expireAfter: 5 * 60_000,  // default: 5 minutes
  // expireAfter: 0,         // disable cache expiration
  // expireAfter: Infinity,  // disable cache expiration
});
  • Default: 5 minutes (300,000ms) – matches TanStack Query
  • Disable: Set to 0 or Infinity
  • Idle: A query is idle when its key function returns null
  • Reactivation: If the key becomes non-null before expiry, the timer cancels and cached data is preserved

Transform

createQuery({
  name: "user",
  key: () => ({ id: "1" }),
  fetcher: async () => api.getRawUser("1"),
  transform: (raw) => ({
    id: raw.user_id,
    name: `${raw.first_name} ${raw.last_name}`,
  }),
});

Retry

createQuery({
  name: "user",
  key: () => ({ id: "1" }),
  fetcher: async (p, signal) => api.getUser(p.id),
  retry: { attempts: 3, backoff: "exponential" },
  // or shorthand:
  retry: 3,
});

Conditional Fetching

createQuery({
  name: "profile",
  key: (facts) => facts.userId ? { userId: facts.userId } : null, // null = disabled
  fetcher: async (p) => api.getProfile(p.userId),
  enabled: (facts) => facts.isLoggedIn === true, // additional condition
  dependsOn: ["auth"], // wait for "auth" query to succeed first
});

Refetch Triggers

createQuery({
  name: "notifications",
  key: () => ({ all: true }),
  fetcher: async () => api.getNotifications(),
  refetchOnWindowFocus: true,   // refetch when tab regains focus (default)
  refetchOnReconnect: true,     // refetch when browser comes back online (default)
  refetchInterval: 10_000,      // poll every 10 seconds
  // or dynamic interval:
  refetchInterval: (data) => data?.unread > 0 ? 5_000 : 30_000,
});

Placeholder & Initial Data

createQuery({
  name: "user",
  key: (facts) => facts.userId ? { userId: facts.userId } : null,
  fetcher: async (p) => api.getUser(p.userId),
  keepPreviousData: true,   // show old user while loading new user
  // or custom placeholder:
  placeholderData: (previousData) => previousData,
  // or pre-populate from SSR:
  initialData: { id: "1", name: "Server-rendered" },
  initialDataUpdatedAt: Date.now() - 60_000, // fetched 60s ago
});

Tags

createQuery({
  name: "user",
  key: (facts) => facts.userId ? { userId: facts.userId } : null,
  fetcher: async (p) => api.getUser(p.userId),
  tags: ["users"],          // invalidated when a mutation invalidates "users"
  tags: ["users:42"],       // specific entity tag
});

Callbacks

createQuery({
  name: "user",
  key: () => ({ id: "1" }),
  fetcher: async () => api.getUser("1"),
  onSuccess: (data) => console.log("Fetched:", data),
  onError: (error) => console.error("Failed:", error),
  onSettled: (data, error) => console.log("Done"),
});

Imperative Handles

When using the advanced path (createQuery + withQueries):

user.refetch(system.facts);
user.invalidate(system.facts);
user.cancel(system.facts);
user.setData(system.facts, newData);
user.prefetch(system.facts, { userId: "99" });

When using createQuerySystem, handles are bound automatically:

app.queries.user.refetch();
app.queries.user.invalidate();
app.queries.user.cancel();
app.queries.user.setData(newData);

Shared Fetcher Config

Use createBaseQuery to share base URL, headers, and error handling across queries:

import { createBaseQuery, createQuery } from "@directive-run/query";

const api = createBaseQuery({
  baseUrl: "/api/v1",
  prepareHeaders: (headers) => {
    headers.set("Authorization", `Bearer ${getToken()}`);
    return headers;
  },
  transformError: (error, response) => ({
    status: response?.status,
    message: error instanceof Error ? error.message : "Unknown error",
  }),
  timeout: 10_000,
});

const users = createQuery({
  name: "users",
  key: () => ({ all: true }),
  fetcher: (params, signal) => api({ url: "/users" }, signal),
});

Queries + Directive Core

Queries compose naturally with Directive's constraint engine. Use constraints to create complex data dependencies:

import { createModule, createSystem, t } from "@directive-run/core";
import { createQuery, createMutation, withQueries } from "@directive-run/query";

// Queries that depend on each other
const authQuery = createQuery({
  name: "auth",
  key: () => ({ check: true }),
  fetcher: async (_, signal) => {
    const res = await fetch("/api/auth/me", { signal });
    return res.json();
  },
});

const profileQuery = createQuery({
  name: "profile",
  key: (f) => {
    const userId = f._q_auth_state?.data?.id;
    if (!userId) {
      return null;
    }

    return { userId };
  },
  fetcher: async (p, signal) => {
    const res = await fetch(`/api/profiles/${p.userId}`, { signal });
    return res.json();
  },
  dependsOn: ["auth"], // waits for auth to succeed first
});

// Custom constraint alongside queries
const mod = createModule("app", withQueries([authQuery, profileQuery], {
  schema: {
    facts: { theme: t.string() },
    derivations: {},
    events: { setTheme: { theme: t.string() } },
    requirements: {},
  },
  init: (f) => { f.theme = "light"; },
  events: {
    setTheme: (f, { theme }) => { f.theme = theme; },
  },
  // Directive constraints work alongside query constraints
  constraints: {
    darkMode: {
      when: (f) => f.theme === "dark",
      require: { type: "APPLY_THEME", theme: "dark" },
    },
  },
  resolvers: {
    applyTheme: {
      requirement: "APPLY_THEME",
      resolve: async (req, ctx) => {
        document.documentElement.classList.toggle("dark", req.theme === "dark");
      },
    },
  },
}));

const system = createSystem({ module: mod });
system.start();
// Auth query fires -> profile query waits -> then fires
// Custom constraints run alongside query constraints

Queries in React

import { useQuerySystem, useDerived } from "@directive-run/react";
import { createQuerySystem } from "@directive-run/query";

function UserList() {
  const app = useQuerySystem(() => createQuerySystem({
    facts: { page: 1, search: "" },
    queries: {
      users: {
        key: (f) => ({
          page: f.page,
          search: f.search || undefined,
        }),
        fetcher: async (p, signal) => {
          const params = new URLSearchParams({ page: String(p.page) });
          if (p.search) params.set("q", p.search);
          const res = await fetch(`/api/users?${params}`, { signal });
          return res.json();
        },
        keepPreviousData: true,
        refetchAfter: 30_000,
      },
    },
    autoStart: false,
  }));

  const users = useDerived(app, "users");

  return (
    <div>
      <input
        placeholder="Search users..."
        onChange={(e) => {
          app.facts.search = e.target.value;
          app.facts.page = 1;
        }}
      />

      {users.isPending && <p>Loading...</p>}
      {users.isPreviousData && <p>Updating...</p>}

      {users.data?.items.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}

      <button
        onClick={() => { app.facts.page = (app.facts.page as number) - 1; }}
        disabled={(app.facts.page as number) <= 1}
      >
        Previous
      </button>
      <button
        onClick={() => { app.facts.page = (app.facts.page as number) + 1; }}
      >
        Next
      </button>

      <p>
        {app.explain("users")}
      </p>
    </div>
  );
}
Previous
Overview

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