Skip to content

wcz-layout

client-db

137 linesSource

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.

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, so useLiveQuery has rows on the first render. Never fire it with void: the server then renders an empty table and the browser fetches again. Explicit collection preload transports source rows and is a no-op for on-demand collections.
  • A detail route awaits the preload, then throws notFound() when the row is missing, and shows a skeleton pendingComponent meanwhile (see the example).
  • useLiveQuery for list pages, useLiveSuspenseQuery for detail components under suspense. .findOne() still returns T | undefined after the loader's check, so keep an if (!data) return null guard.
  • 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.

Examples

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

const TODOS_ID = "todos";

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

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
// detail route: src/routes/todos/$id.tsx
export const Route = createFileRoute("/todos/$id")({
  component: RouteComponent,
  pendingComponent: TodoSkeleton,
  beforeLoad: requireAuth("admin"),
  loader: async ({ context, params }) => {
    await context.dbClient.preloadLiveQuery(todoByIdQueryOptions(params.id));
    if (!context.dbClient.collection(todoCollection).has(params.id)) throw notFound();
  },
});

function RouteComponent() {
  const { id } = Route.useParams();
  const { alert } = useDialogs();
  const navigate = useNavigate();
  const todos = useTodoCollection();
  const { data } = useLiveSuspenseQuery(todoByIdQueryOptions(id));

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

  const handleDelete = async () => {
    try {
      const transaction = handleOnDelete(id);
      await transaction.isPersisted.promise;
      await navigate({ to: "/todos" });
    } catch (error) {
      if (error instanceof Error) await alert(error.message);
    }
  };

  if (!data) return null;
  // render data; call handleDelete from the delete button...
}