---
name: client-db
description: "Use when reading or writing client data with TanStack DB: collections, live queries, route preloading, optimistic writes, eager/on-demand sync, or browser SQLite persistence."
metadata:
  type: convention
  library: wcz-layout
---

> Mechanics (the live-query builder, `createOptimisticAction`, collection adapters,
> browser persistence) are covered by TanStack's own skills. List the skills shipped by
> `@tanstack/db` and `@tanstack/react-db` and load whichever cover the task at hand.
> This skill adds the wcz-layout conventions on top, and overrides upstream where noted.

## Rules

- One `DbClient` per request, built in `src/router.tsx` and put on the router context
  next to `queryClient`. Components reach it with `useDbClient()`, loaders with
  `context.dbClient`.
- Declare collections as descriptors: `collectionOptions(id, (client) => ...)`. Take the
  query client from the factory with `client.requireDependency<QueryClient>("queryClient")`,
  never a module-scope one — it is built per request and a singleton leaks across SSR.
- Export a `use<Table>Collection` hook next to each collection. It is the only way components
  get a live collection to write to.
- Define every live query once in `src/db/queries/<table>.ts` as
  `{ query: (q: InitialQueryBuilder) => ... }`. Reuse that definition in loaders and
  components.
- Preload in the route loader and return the promise:
  `loader: ({ context }) => context.dbClient.preloadLiveQuery(<x>QueryOptions)`.
  This transports the query result. Explicit collection preload transports source rows
  and is a no-op for on-demand collections.
- `useLiveQuery` for list pages, `useLiveSuspenseQuery` for detail components under
  suspense. Suspense data is initialized, but `.findOne()` still returns `T | undefined`;
  handle not-found explicitly.
- Shape data in the query: filter, order, group and aggregate only the fields the
  consumer needs. Do not load broad results and transform them in component code.
- Mutations go through `createOptimisticAction`: mirror the server write in `onMutate`,
  call the server function in `mutationFn`, then
  `await utils.refetch({ throwOnError: true })` on every collection that function writes.
  Await `transaction.isPersisted.promise` at the call site and surface failures with
  `alert()`.
- `syncMode: "eager"` for top-level collections, `"on-demand"` for child data and anything expected past ~10k rows.
- Increment persisted `schemaVersion` whenever the row shape changes.

## Examples

```ts
// src/db/collections/<table>.ts
import { persistedCollectionOptions } from "@tanstack/browser-db-sqlite-persistence";
import { queryCollectionOptions } from "@tanstack/query-db-collection";
import { collectionOptions, useDbClient } from "@tanstack/react-db";
import type { QueryClient } from "@tanstack/react-query";
import { browserPersistence } from "../persistence";
import { TodoSchema } from "~/lib/schemas/todo";
import { selectTodos } from "~/server/actions/todo";

const TODOS_ID = "todos";

export const todoCollection = collectionOptions(TODOS_ID, (client) => {
  const options = queryCollectionOptions({
    id: TODOS_ID,
    queryKey: [TODOS_ID],
    queryFn: () => selectTodos(),
    getKey: ({ id }) => id,
    schema: TodoSchema,
    queryClient: client.requireDependency<QueryClient>("queryClient"),
    syncMode: "eager",
  });

  const persistence = browserPersistence();
  if (!persistence) return options;

  return persistedCollectionOptions({
    ...options,
    persistence,
    schemaVersion: 1, // Increment when the persisted row shape changes.
  }) as typeof options;
});

export const useTodoCollection = () => useDbClient().collection(todoCollection);
```

```ts
// src/db/queries/<table>.ts
import type { InitialQueryBuilder } from "@tanstack/react-db";
import { eq } from "@tanstack/react-db";
import { todoCollection } from "../collections/todo";

export const todosQueryOptions = {
  query: (q: InitialQueryBuilder) =>
    q.from({ todo: todoCollection }).orderBy(({ todo }) => todo.id, "desc"),
};

export const todoByIdQueryOptions = (id: string) => ({
  query: (q: InitialQueryBuilder) =>
    q
      .from({ todo: todoCollection })
      .where(({ todo }) => eq(todo.id, id))
      .findOne(),
});
```

```tsx
// route + component
export const Route = createFileRoute("/todos/$id")({
  beforeLoad: requireAuth("admin"),
  loader: ({ context, params }) =>
    context.dbClient.preloadLiveQuery(todoByIdQueryOptions(params.id)),
});

const { id } = Route.useParams();
const todos = useTodoCollection();
const { data } = useLiveSuspenseQuery(todoByIdQueryOptions(id));

const handleOnDelete = createOptimisticAction<string>({
  onMutate: (id) => {
    todos.delete(id);
  },
  mutationFn: async (id) => {
    await deleteTodo({ data: { id } });
    await todos.utils.refetch({ throwOnError: true });
  },
});

try {
  const transaction = handleOnDelete(id);
  await transaction.isPersisted.promise;
} catch (error) {
  if (error instanceof Error) await alert(error.message);
}

if (!data) return null;
```
