Trovella Wiki

Server State (tRPC + TanStack Query)

How server data is fetched, cached, and mutated using tRPC React Query hooks throughout the application.

Server state is the dominant state management pattern in Trovella. Nearly every data-driven component fetches data through tRPC React Query hooks, which wrap TanStack Query under the hood.

Provider Setup

The Providers component in apps/web/src/components/providers.tsx sets up both the tRPC client and the TanStack Query client:

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: { staleTime: 30_000 },
        },
      }),
  );
  const [trpcClient] = useState(makeTRPCClient);

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </trpc.Provider>
  );
}

Key configuration:

  • staleTime: 30_000 (30 seconds) -- queries are considered fresh for 30 seconds after fetch. This prevents redundant refetches when navigating between tabs or re-mounting components.
  • httpBatchLink -- multiple tRPC calls made in the same tick are batched into a single HTTP request to /api/trpc.
  • Both clients are created inside useState to ensure they are stable across re-renders and unique per-session (no cross-request leakage in SSR).

tRPC Client Setup

The tRPC client is defined in apps/web/src/lib/trpc-react.ts:

export const trpc: CreateTRPCReact<AppRouter, unknown> = createTRPCReact<AppRouter>();

export function makeTRPCClient() {
  return trpc.createClient({
    links: [
      httpBatchLink({
        url: "/api/trpc",
      }),
    ],
  });
}

This gives every component access to fully typed hooks like trpc.aiLogs.list.useQuery(...) where the input and output types are inferred from the AppRouter type exported by @repo/api.

Type Helpers

apps/web/src/lib/trpc-types.ts exports a RouterOutputs type for use in child components that receive server data as props:

import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@repo/api";

export type RouterOutputs = inferRouterOutputs<AppRouter>;

Some components also use inferRouterOutputs directly:

type ArtifactItem = inferRouterOutputs<AppRouter>["researchPlan"]["artifacts"]["items"][number];

This is useful when a parent component fetches data and passes individual items to child components -- the child gets a precise type without re-importing the full router.

Query Patterns

Basic Query

The most common pattern: call useQuery with typed input, destructure data, isLoading, and error:

const { data, isLoading, error } = trpc.aiLogs.detail.useQuery({ usageId });

Conditional Query

Use the enabled option to defer a query until a condition is met. The search debugger uses this to avoid firing until the user submits:

const { data, isLoading, error } = trpc.hybridSearch.debugSearch.useQuery(
  { query: submittedQuery, limit: 20 },
  { enabled: submittedQuery.length > 0 },
);

Multiple Parallel Queries

Dashboard components often fire several queries simultaneously. TanStack Query deduplicates and manages them independently:

const summary = trpc.aiLogs.summary.useQuery({});
const usageOverTime = trpc.aiLogs.usageOverTime.useQuery({ granularity: "day" });
const modelDistribution = trpc.aiLogs.modelDistribution.useQuery();
const logs = trpc.aiLogs.list.useQuery({
  limit: PAGE_SIZE,
  offset: page * PAGE_SIZE,
  ...filters,
});

The httpBatchLink batches these four calls into a single HTTP request.

Pagination with Offset

Paginated lists use useState for the offset and pass it into the query. Changing the offset triggers an automatic refetch:

const [offset, setOffset] = useState(0);
const limit = 20;
const { data, error } = trpc.researchPlan.list.useQuery({ limit, offset });

Navigation buttons increment/decrement the offset:

<Button
  disabled={offset === 0}
  onClick={() => setOffset(Math.max(0, offset - limit))}
>
  Previous
</Button>
<Button
  disabled={offset + limit >= data.total}
  onClick={() => setOffset(offset + limit)}
>
  Next
</Button>

Mutation Patterns

Basic Mutation with Cache Invalidation

The PAT manager demonstrates the standard mutation pattern: mutate, invalidate related queries on success, show a toast on error:

const utils = trpc.useUtils();
const createMutation = trpc.pat.create.useMutation({
  onSuccess(data) {
    setCreatedToken(data.token);
    void utils.pat.list.invalidate();
  },
  onError(error) {
    toast.error(error.message);
  },
});

Key points:

  • trpc.useUtils() returns the TanStack Query utility object, scoped to the tRPC router. Use it for cache invalidation.
  • utils.pat.list.invalidate() marks the pat.list query as stale, triggering a refetch. The void prefix is because the return is a Promise but we do not need to await it.
  • mutation.isPending provides loading state for the submit button.
  • mutation.reset() clears mutation state when closing a dialog.

Triggering a Mutation

createMutation.mutate({ name: name.trim(), expiresInDays: days });

The input is type-checked against the tRPC procedure's Zod input schema. TypeScript will error at compile time if the shape does not match.

Error Handling

Components use a shared QueryError component for displaying tRPC errors. The pattern is to check for the first error across parallel queries:

const firstError = summary.error ?? usageOverTime.error ?? modelDistribution.error ?? logs.error;
if (firstError) return <QueryError error={firstError} label="Failed to load AI logs" />;

For mutations, errors are typically shown as toast notifications via Sonner:

onError(error) {
  toast.error(error.message);
}

Conventions

  1. Always use tRPC hooks for server data. Do not use fetch or axios directly in client components. The one exception is the AI playground streaming endpoint, which uses raw fetch for SSE streaming.
  2. Invalidate, don't refetch. After a mutation, call utils.<router>.<procedure>.invalidate() rather than manually refetching. TanStack Query handles the rest.
  3. Keep query keys implicit. tRPC React Query generates query keys automatically from the procedure path and input. Do not construct manual query keys.
  4. Co-locate queries with the component that uses them. Each component calls its own useQuery. Do not lift queries to parent components unless multiple children need the same data.

On this page