---
title: The live data layer
description: The query cache is per-mount and automatic — keyed dedup, Hash/Equal keys, interruption on unmount — and it is a service. QueryCache drives invalidation and refetch from service methods, and mutation() declares optimistic, typed-rollback mutations in a service make, testable with no DOM.
group: Core
order: 6
---

[Loading](/docs/loading/) covers how `query` suspends and what the type makes you handle. This page is
what sits behind it — the cache — and the write side: mutations. One idea runs through both, the same
one that runs through all of effract: **the policy lives in services.** What reloads after what, what is
optimistic, what rolls back — none of it is written in a component, because the door to the cache is a
service, the built-in **`QueryCache`**, and a mutation is a value a service holds.

## Let the cache dedupe — two components ask, one fetch

`query` is a per-`mount` cache. Two components under the same `mount` issuing a query with the same key
share **one** fetch — whichever renders first starts it, the other joins it. The request is also deduped
across every render *attempt* (including React StrictMode's double-invoke and pre-commit retries), so an
effect runs once, not twice, on mount. When the last reader unmounts, the in-flight fiber is
**interrupted** — finalizers run, nothing leaks.

```tsx
const Header = rec(function* () {
  const user = yield* query(fetchUser(id), id); // starts the fetch
  return <b>{user.name}</b>;
});
const Sidebar = rec(function* () {
  const user = yield* query(fetchUser(id), id); // same key — joins it, no second request
  return <img src={user.avatar} alt="" />;
});
```

`query(effect, key)` refetches when the key changes; `suspend(effect)` is the load-once form — same
dedup, same interruption, no keyed refetch. Both settle in the same cache.

## Key by value — `Hash`/`Equal`, objects welcome

Keys are compared with Effect's `Hash`/`Equal`, not by reference — so an object key built fresh every
render is fine, and property order does not matter:

```tsx
const results = yield* query(search(params), { page, filter }); // a new object each render — still one entry
// { page: 1, filter: 'a' } and { filter: 'a', page: 1 } are the same key
```

A value implementing the `Equal` trait (a `Data.struct`, a domain class) works the same way — the cache
asks the value, not the pointer.

## Tag queries; invalidate from a service

`{ tags }` opts a query into invalidation. The cache itself is the built-in **`QueryCache`** service —
every client `mount` (and the `/ssr` `renderToStream`) provides it — so a service `make` does
`yield* QueryCache` and exposes reload policy as methods:

```tsx
import { QueryCache, query, rec } from '@tmonier/effract';

class Api extends Context.Service<Api>()('app/Api', {
  make: Effect.gen(function* () {
    const cache = yield* QueryCache; // the built-in service — provided by mount
    return {
      reloadUsers: () => cache.invalidate('user'), // reload every 'user'-tagged query
    };
  }),
}) {
  static readonly layer = Layer.effect(Api, Api.make);
}

const users = yield* query(loadUsers, 'all', { tags: ['user'] }); // in a REC
```

`cache.invalidate(tag)` returns how many queries matched. An **active** query (something is reading it)
revalidates in the background — its readers keep showing the current value, then update; an **inactive**
one is dropped, so its next read fetches afresh.

## Serve stale, revalidate in the background — `staleTime`

`{ staleTime }` (ms) turns a query into stale-while-revalidate: within the window the cached value is
served instantly; after it, a read still shows the cached value and a background refetch replaces it
when it lands. A **failed** revalidation keeps the last good value on screen — a background retry never
blanks the UI.

```tsx
const feed = yield* query(loadFeed, 'feed', { tags: ['feed'], staleTime: 30_000 });
```

## Refetch by key — retry and pull-to-refresh are service methods

`cache.refetch(key)` re-runs every query whose key is `Equal`-equal to `key` — no key change, no
remount. It is the retry button, pull-to-refresh, and the post-mutation refresh, and like invalidation
it belongs on the service:

```tsx
return {
  refreshUser: (id: number) => cache.refetch(id), // re-run one keyed query
};
```

## Gate a dependent query with `query.enabled`

`query.enabled(effect, key, enabled)` never fires while `enabled` is `false`, and the read is an
`Option<A>` — the disabled case is honest in the type, not a sentinel:

```tsx
const profile = yield* query.enabled(fetchProfile(userId ?? 0), userId, userId !== undefined);
return Option.match(profile, { onNone: () => <Pick />, onSome: (p) => <Card profile={p} /> });
```

## Declare mutations in services — `mutation(label, run, options)`

A mutation is yielded in a service `make`, so the whole policy — the optimistic apply, its rollback,
what reloads — lives with the service that owns the data. `optimistic` applies a change immediately and
returns a rollback thunk; effract calls that rollback automatically when the effect fails with a
**typed error** (the failure lands, typed, in `state.error`; defects are never folded into it — they
re-throw, unswallowed). On success, `invalidates` reloads the tagged queries through `QueryCache`.

```tsx
import { atom, mutation, rec } from '@tmonier/effract';

class Profile extends Context.Service<Profile>()('app/Profile', {
  make: Effect.gen(function* () {
    const name = atom('name', 'Ada');
    const save = (next: string): Effect.Effect<string, Forbidden> => api.rename(next);
    const rename = yield* mutation('profile/rename', save, {
      // Apply optimistically; the returned thunk restores the exact prior value.
      optimistic: (next) => {
        const previous = name.value;
        name.set(next);
        return () => name.set(previous);
      },
      invalidates: ['profile'], // reload queries tagged 'profile' on success
    });
    return { name, rename };
  }),
}) {
  static readonly layer = Layer.effect(Profile, Profile.make);
}
```

The handle is `{ run(input): void; state; reset() }`. State is **shared by default** — one atom per
constructed mutation, not per component instance: two components rendering the same mutation see one
state, which is what a service-owned value means.

## Read mutation state with `yield*` — it is a labeled atom

`state` is a reactive atom labeled `` `${label}.state` `` (`'profile/rename.state'` above). A REC reads
it like any other atom — no hook in sight:

```tsx
const Editor = rec(function* () {
  const profile = yield* Profile;
  const state = yield* profile.rename.state; // reactive read — re-renders per transition
  return (
    <button disabled={state.status === 'pending'} onClick={() => profile.rename.run('Grace')}>
      {state.status === 'error' ? `couldn’t save: ${state.error?._tag}` : 'save'}
    </button>
  );
});
```

Because it is a labeled atom, the whole [render channel](/docs/render-control/) applies for free: it
appears in `RendersOn<typeof Editor>`, the dev ledger attributes the re-render to it by name, and
devtools see every transition.

## Go per-entity with `atomFamily` — the key rides in the input

For a list of entities, keep the per-entity *data* in an `atomFamily` and let one mutation take the key
as part of its input — the optimistic apply touches exactly that entity's atom, so only its row rolls
back:

```tsx
class Todos extends Context.Service<Todos>()('app/Todos', {
  make: Effect.gen(function* () {
    const doneOf = atomFamily('todos/done', (id: number) => atom(String(id), false));
    const toggle = yield* mutation('todos/toggle', (id: number) => api.toggle(id), {
      optimistic: (id) => {
        const cell = doneOf(id); // this entity's atom — no other row is touched
        const previous = cell.value;
        cell.update((done) => !done);
        return () => cell.set(previous);
      },
      invalidates: ['todos'],
    });
    return { doneOf, toggle };
  }),
}) {
  static readonly layer = Layer.effect(Todos, Todos.make);
}
```

One mutation, one shared state — a global "saving…" indicator is one `yield*` away — while the data, and
the rollback, stay per-entity.

## Test the whole policy with no DOM

Nothing in a mutation is React. Construct it under a stub `QueryCache`, drive `run()`, and assert on
`state.value` — a plain Effect program, no renderer, no `act`:

```ts
import { describe, expect, it } from '@effect/vitest';
import * as Context from 'effect/Context';
import * as Effect from 'effect/Effect';
import { QueryCache, atom, mutation } from '@tmonier/effract';

describe('profile/rename', () => {
  it('rolls back to the exact prior value on a typed failure', async () => {
    const name = atom('name', 'Ada');
    const rename = Effect.runSync(
      Effect.provideContext(
        mutation('profile/rename', () => Effect.fail(new Forbidden({ reason: 'nope' })), {
          optimistic: (next: string) => {
            const previous = name.value;
            name.set(next);
            return () => name.set(previous);
          },
        }),
        Context.make(QueryCache, { invalidate: () => 0, refetch: () => 0 }),
      ),
    );

    rename.run('Grace');
    expect(name.value).toBe('Grace'); // optimistic value shows immediately
    await new Promise((resolve) => setTimeout(resolve, 0)); // let the run settle
    await new Promise((resolve) => setTimeout(resolve, 0));
    expect(name.value).toBe('Ada'); // rolled back exactly
    expect(rename.state.value.error?._tag).toBe('Forbidden'); // the failure, typed
  });
});
```

The stub records or ignores invalidations as the test needs; Effect's `TestClock` covers anything
time-dependent (`staleTime`, retries) with no real timers.

## One limitation: an injected runtime doesn't carry the cache

Pass a **`Layer`** to `mount` and `QueryCache` is merged underneath automatically — your layer may even
*require* it (`mount` accepts `Layer<ROut, LE, QueryCache>`). An **injected `ManagedRuntime`** is built
outside effract and cannot be retrofitted: a service resolving `QueryCache` under an injected runtime
that lacks it fails at runtime. Build with the `Layer` form if your services use the cache. (The RSC
server `mount` provides an honest no-op — a server render resolves each query inline exactly once, so
both methods report zero matches.)
