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 forsystem.read("user")key– derive params from facts. Returnnullto 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
0orInfinity - 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>
);
}

