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:
- Redundant network requests: Multiple components requesting the same data will trigger duplicate calls unless caching is in place.
- Stale data: After a mutation, your UI may continue showing outdated information unless you explicitly invalidate or refetch.
- Optimistic UI complexity: Showing immediate feedback before a server response requires careful coordination between local and remote state.
- Offline and error handling: Retries, rollbacks, and error boundaries all depend on a coherent state model.
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.
- Server state: Use tRPC with TanStack Query. Let the cache handle deduplication, refetching, and persistence.
- Client state: Use Zustand, Jotai, Redux Toolkit, or even React's built-in
useStateanduseReducerfor ephemeral UI concerns. - Derived state: Compute derived values with
useMemoor selector functions rather than duplicating data in multiple stores.
Best Practices
To get the most out of tRPC state management, keep these guidelines in mind:
- Lean on the cache. Avoid copying server data into a separate store. If you need it in multiple places, rely on TanStack Query's shared cache.
- Invalidate strategically. After mutations, invalidate only the queries that are actually affected. Over-invalidation causes unnecessary refetches.
- Use query keys deliberately. tRPC generates query keys automatically, but understanding that they include procedure name and input helps you reason about cache behavior.
- Handle loading and error states. Always render something for
isLoading,error, anddata. This prevents flash-of-empty-content and improves perceived performance. - Prefetch when possible. Use
prefetchmethods on the server or in route loaders to populate the cache before rendering, reducing client-side waterfalls. - Keep mutations idempotent where possible. This makes retries safer and reduces the risk of duplicate side effects.
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.