Skip to main content
·5 min read

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

PathWhenSetup
createQuerySystemMost apps1 function, 1 import
createQueryModuleMulti-module systemsModule + createSystem
createQuery + withQueriesFull controlComposable 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 integrationthrowOnError + suspense wired to React adapter

Directive is free and open source. If this was useful, consider supporting the project.

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