โ† Back to DevBytes

State Management in tRPC: Patterns and Libraries

State Management in tRPC: Patterns and Libraries

tRPC has rapidly become one of the most popular choices for building type-safe APIs in full-stack TypeScript applications. By allowing you to share types directly between your client and server, tRPC eliminates an entire class of bugs caused by mismatched contracts. However, once your data starts flowing, you still need a strategy for managing that data on the client. This is where state management comes in. In this tutorial, we will explore what state management means in the context of tRPC, why it matters, the most common patterns, and the libraries that pair well with tRPC.

What Is State Management in tRPC?

State management in tRPC refers to how you store, cache, synchronize, and mutate the data that your tRPC procedures return. While tRPC handles the transport layer โ€” serializing requests, deserializing responses, and enforcing type safety โ€” it does not, by itself, dictate how the client should hold onto that data between renders or across components.

In practice, tRPC clients are built on top of data-fetching primitives. The official @trpc/react-query adapter, for example, is a thin wrapper around TanStack Query (formerly React Query). This means that when you call a tRPC procedure from a React component, you are really creating a query or mutation that is managed by a cache layer. Understanding this relationship is the key to mastering state management in tRPC.

Why State Management Matters

Without a deliberate state management strategy, you will quickly run into several problems:

By choosing the right patterns and libraries, you can keep your application fast, predictable, and maintainable.

The Default Pattern: TanStack Query Integration

When you use @trpc/react-query, state management is largely handled for you through TanStack Query's cache. Each unique procedure call is identified by a query key, and TanStack Query stores the result in memory. Subsequent calls with the same key return cached data immediately while optionally refetching in the background.

Here is a basic setup showing how tRPC integrates with TanStack Query:

// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/router';

export const trpc = createTRPCReact<AppRouter>();

// _app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '../utils/trpc';

const queryClient = new QueryClient();

function MyApp({ Component, pageProps }) {
  const trpcClient = trpc.createClient({
    links: [
      httpBatchLink({
        url: '/api/trpc',
      }),
    ],
  });

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

export default MyApp;

With this setup, calling a procedure in a component is straightforward:

import { trpc } from '../utils/trpc';

function UserList() {
  const { data, isLoading, error } = trpc.user.list.useQuery();

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Behind the scenes, TanStack Query caches the result of user.list. If another component also calls trpc.user.list.useQuery(), it will receive the cached data instantly without a second network request.

Managing Mutations and Cache Invalidation

Fetching data is only half the story. When you mutate data on the server, you need to update the client cache so the UI reflects the new state. tRPC exposes a useContext hook (or useUtils in newer versions) that lets you invalidate queries imperatively.

import { trpc } from '../utils/trpc';

function CreateUserForm() {
  const utils = trpc.useUtils();
  const createUser = trpc.user.create.useMutation({
    onSuccess: () => {
      // Invalidate the user list so it refetches
      utils.user.list.invalidate();
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        createUser.mutate({ name: formData.get('name') as string });
      }}
    >
      <input name="name" />
      <button type="submit">Create</button>
    </form>
  );
}

This pattern โ€” mutate, then invalidate โ€” is the most common state management flow in tRPC applications. It keeps your cache authoritative while still allowing the server to be the source of truth.

Optimistic Updates

For a snappier user experience, you can apply optimistic updates. This means updating the cache immediately, before the server responds, and rolling back if the mutation fails. TanStack Query provides onMutate, onError, and onSettled callbacks for exactly this purpose.

import { trpc } from '../utils/trpc';

function RenameUser({ userId, currentName }: { userId: string; currentName: string }) {
  const utils = trpc.useUtils();

  const rename = trpc.user.rename.useMutation({
    onMutate: async (newData) => {
      // Cancel outgoing refetches
      await utils.user.list.cancel();

      // Snapshot the previous value
      const previousList = utils.user.list.getData();

      // Optimistically update the cache
      utils.user.list.setData(undefined, (old) =>
        old?.map((user) =>
          user.id === userId ? { ...user, name: newData.name } : user
        )
      );

      return { previousList };
    },
    onError: (_err, _newData, context) => {
      // Roll back on error
      if (context?.previousList) {
        utils.user.list.setData(undefined, context.previousList);
      }
    },
    onSettled: () => {
      utils.user.list.invalidate();
    },
  });

  return (
    <button onClick={() => rename.mutate({ id: userId, name: 'New Name' })}>
      Rename
    </button>
  );
}

Optimistic updates are powerful but should be used judiciously. They shine in low-latency interactions like toggles, reordering, or inline edits, where the user expects immediate feedback.

Using Zustand for Local UI State

Not all state belongs in the tRPC cache. UI-specific state โ€” such as which modal is open, the currently selected tab, or a draft form value โ€” is often better managed with a lightweight store. Zustand is a popular choice that pairs cleanly with tRPC.

// stores/uiStore.ts
import { create } from 'zustand';

interface UIState {
  selectedUserId: string | null;
  setSelectedUser: (id: string | null) => void;
  isModalOpen: boolean;
  toggleModal: () => void;
}

export const useUIStore = create<UIState>((set) => ({
  selectedUserId: null,
  setSelectedUser: (id) => set({ selectedUserId: id }),
  isModalOpen: false,
  toggleModal: () => set((state) => ({ isModalOpen: !state.isModalOpen })),
}));

You can then combine this local store with tRPC queries to drive your UI:

import { trpc } from '../utils/trpc';
import { useUIStore } from '../stores/uiStore';

function UserDetail() {
  const selectedUserId = useUIStore((s) => s.selectedUserId);
  const { data: user } = trpc.user.getById.useQuery(
    { id: selectedUserId! },
    { enabled: !!selectedUserId }
  );

  if (!selectedUserId) return <p>Select a user</p>;

  return <div>{user?.name}</div>;
}

Notice the enabled option. This is a TanStack Query feature that lets you conditionally fetch based on local state, preventing unnecessary requests when no user is selected.

Using Jotai for Atomic State

For applications with deeply nested or fine-grained state, Jotai offers an atomic approach. Each piece of state is an independent atom, and components subscribe only to the atoms they need. This avoids unnecessary re-renders and works well alongside tRPC's cache.

// atoms/userAtom.ts
import { atom } from 'jotai';

export const filterAtom = atom<string>('');
export const sortByAtom = atom<'name' | 'createdAt'>('name');
import { useAtom } from 'jotai';
import { trpc } from '../utils/trpc';
import { filterAtom, sortByAtom } from '../atoms/userAtom';

function FilterableUserList() {
  const [filter, setFilter] = useAtom(filterAtom);
  const [sortBy] = useAtom(sortByAtom);

  const { data } = trpc.user.list.useQuery({ filter, sortBy });

  return (
    <div>
      <input
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        placeholder="Filter users..."
      />
      <ul>
        {data?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

Here, Jotai manages the filter and sort preferences, while tRPC handles the server data. The query automatically refetches whenever the filter or sort changes because they are part of the query key.

Server State vs. Client State

A critical best practice is to clearly separate server state from client state. Server state is asynchronous, owned by the server, and needs caching, invalidation, and synchronization. Client state is synchronous, owned by the browser, and typically ephemeral. Mixing the two leads to confusing bugs.

Best Practices

To get the most out of tRPC state management, keep these guidelines in mind:

Prefetching Example

Prefetching is a powerful technique for improving perceived performance. In a Next.js app, you can prefetch tRPC queries on the server and hydrate them on the client:

// pages/users/[id].tsx
import { createServerSideHelpers } from '@trpc/react-query/server';
import { appRouter } from '../../server/router';
import { trpc } from '../../utils/trpc';

export async function getServerSideProps(context) {
  const helpers = createServerSideHelpers({
    router: appRouter,
    ctx: {},
  });

  const id = context.params.id as string;
  await helpers.user.getById.prefetch({ id });

  return {
    props: {
      trpcState: helpers.dehydrate(),
      id,
    },
  };
}

export default function UserPage({ id }: { id: string }) {
  const { data } = trpc.user.getById.useQuery({ id });
  return <div>{data?.name}</div>;
}

When the page loads, the data is already in the cache, so the client renders immediately without a loading spinner.

Conclusion

State management in tRPC is fundamentally about recognizing that tRPC handles the wire, while a separate layer handles the cache and local UI concerns. By leaning on TanStack Query for server state and pairing it with a lightweight client-state library like Zustand or Jotai, you get a clean, type-safe, and performant architecture. The key is to keep server state in the cache, use local stores only for ephemeral UI data, and always invalidate or refetch after mutations. With these patterns in place, your tRPC applications will remain predictable and easy to maintain as they grow.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles