Introducing @directive-run/query
Every data fetching library makes you manage cache keys. You define them, compose them, and manually invalidate them when data changes. When something refetches unexpectedly, you open DevTools and guess.
@directive-run/query takes a different approach. There are no cache keys. Change a fact, and every query that reads it refetches automatically. When you want to know why something fetched, you call explainQuery and it tells you – which fact changed, which constraint fired, how long the resolver took.
This is possible because Directive has a constraint engine underneath. Queries are constraints. Cache invalidation is causal, not manual.
The 30-second version
import { createQuerySystem } from "@directive-run/query";
const app = createQuerySystem({
facts: { userId: "" },
queries: {
user: {
key: (f) => f.userId ? { userId: f.userId } : null,
fetcher: async (p, signal) => {
const res = await fetch(`/api/users/${p.userId}`, { signal });
return res.json();
},
},
},
});
app.facts.userId = "42";
// Query fires automatically. No queryKey. No invalidation call.
const { data, isPending, error } = app.read("user");
One function. One import. The system auto-starts, queries fire when facts change, and bound handles let you app.queries.user.refetch() without passing internal state around.
What makes this different
1. Causal cache invalidation
TanStack Query:
queryClient.invalidateQueries({ queryKey: ["users", userId] });
Directive Query:
app.facts.userId = "42";
// That's it. Every query reading userId refetches.
The constraint engine tracks which facts each query depends on. When a fact changes, affected queries refetch. No keys to manage, no manual invalidation.
2. "Why did that fetch?"
app.explain("user");
Query "user"
Status: refetching in background (stale-while-revalidate)
Cache key: {"userId":"42"}
Data age: 45s
Last fetch causal chain:
Fact changed: userId "41" -> "42"
Constraint: _q_user_fetch (priority 50)
Resolved in: 145ms
No other data fetching library can show you this. It requires a constraint engine to know why something happened, not just what happened.
3. Time-travel through API responses
Directive snapshots facts. Queries store cache as facts. That means undo/redo through your entire data fetching history works out of the box:
const app = createQuerySystem({
facts: { userId: "" },
queries: { user: { ... } },
history: { maxSnapshots: 50 },
});
// Later
system.history.goBack(); // previous API response
system.history.goForward(); // next API response
4. Framework agnostic
The query package has zero framework imports. It produces a standard Directive system that any adapter subscribes to:
// React
const user = useDerived(app, "user");
// Vue
const user = useDerived(app, "user");
// Svelte
const user = useDerived(app, "user");
// $user.data, $user.isPending, etc.
Each adapter has a useQuerySystem hook for lifecycle management:
function App() {
const app = useQuerySystem(() => createQuerySystem({
facts: { userId: "" },
queries: { user: { ... } },
autoStart: false,
}));
const user = useDerived(app, "user");
return <div>{user.data?.name}</div>;
}
Everything you need
Queries
Pull-based data fetching with automatic refetching, caching, structural sharing, and garbage collection.
createQuery({
name: "user",
key: (f) => f.userId ? { userId: f.userId } : null,
fetcher: async (p, signal) => api.getUser(p.userId),
refetchAfter: 30_000,
expireAfter: 5 * 60_000,
keepPreviousData: true,
tags: ["users"],
});
Mutations
Write operations with tag-based invalidation and optimistic updates.
createMutation({
name: "updateUser",
mutator: async (vars, signal) => api.updateUser(vars),
invalidateTags: ["users"],
onMutate: (vars) => ({ previous: currentData }),
onError: (error, vars, context) => { /* rollback */ },
});
When the mutation succeeds, every query tagged "users" refetches automatically.
Subscriptions
Push-based data for WebSocket, SSE, and AI streaming.
createSubscription({
name: "prices",
key: (f) => f.ticker ? { ticker: f.ticker } : null,
subscribe: (params, { onData, onError, signal }) => {
const ws = new WebSocket(`wss://api.example.com/${params.ticker}`);
ws.onmessage = (e) => onData(JSON.parse(e.data));
signal.addEventListener("abort", () => ws.close());
return () => ws.close();
},
});
Infinite queries
Cursor-based pagination with bidirectional scrolling and memory management.
createInfiniteQuery({
name: "feed",
key: (f) => f.userId ? { userId: f.userId } : null,
fetcher: async (p, signal) => api.getFeed(p.userId, p.pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: null,
maxPages: 10,
});
GraphQL
Full TypedDocumentNode support with shared client configuration.
const gql = createGraphQLClient({
endpoint: "/api/graphql",
headers: () => ({ Authorization: `Bearer ${getToken()}` }),
});
const user = gql.query({
name: "user",
document: GetUserDocument,
variables: (f) => f.userId ? { id: f.userId } : null,
});
Three paths, one library
| Path | When | Setup |
|---|---|---|
createQuerySystem | Most apps | 1 function, 1 import |
createQueryModule | Multi-module systems | Module + createSystem |
createQuery + withQueries | Full control | Composable primitives |
Start simple. Graduate to the advanced path when you need custom constraints, cross-module dependencies, or multi-module composition. Your query definitions stay the same.
Multi-module composition
Query modules compose with other Directive modules in namespaced systems:
const dataModule = createQueryModule("data", [
userQuery,
updateMutation,
], { schema, init });
const system = createSystem({
modules: {
data: dataModule,
auth: authModule,
ui: uiModule,
},
});
system.facts.data.userId = "42";
system.read("data.user");
Other modules can react to query state changes via crossModuleDeps – the constraint engine handles the dependency graph automatically.
Install
npm install @directive-run/query @directive-run/core
For React:
npm install @directive-run/react
The package is published on npm as @directive-run/query@0.1.1.
What's next
- Typed
createQuerySystem– full generic inference on the simple path - Query devtools panel – visual causal timeline
- Suspense integration –
throwOnError+suspensewired to React adapter
Links
Directive is free and open source. If this was useful, consider supporting the project.

