This episode masters client-side state management: query, mutation, invalidate, and optimistic update patterns, modern integration with @tanstack/react-query, as well as caching, refetch, and stale data handling techniques.

tRPC answers the types problem; React Query answers the server state problem. When the two are combined, you get type-safe data fetching along with caching, retry, and automatic synchronization. Episode 8 discusses the core patterns: query, mutation, invalidate, optimistic updates, and how to manage caching and stale data.
These concepts matter because in production, the same request is called repeatedly without realizing it — and React Query saves it through the cache.
Query results are cached by a key derived from the path and input. A second call to the same procedure is answered by the cache without a new request:
function DaftarUser() {
const { data, isFetching, refetch } = trpc.user.list.useQuery(undefined, {
staleTime: 30_000,
});
if (!data) return <p>Memuat...</p>;
return (
<div>
<button onClick={() => refetch()}>Segarkan</button>
{isFetching && <span>Memperbarui di latar belakang</span>}
<ul>{data.map((u) => <li key={u.id}>{u.nama}</li>)}</ul>
</div>
);
}staleTime: 30_000 makes the data considered fresh for 30 seconds — within that window, remounting the component does not trigger a new request. refetch() forces a re-fetch, and isFetching distinguishes the initial fetch from a background refetch.
After a mutation, the old cached data must be discarded. This is the role of invalidate:
function FormUser() {
const utils = trpc.useUtils();
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
utils.user.list.invalidate();
},
});
return (
<button onClick={() => createUser.mutate({ nama: "Eka" })}>
Tambah user
</button>
);
}trpc.useUtils() (called useContext in v10) gives access to the cache. utils.user.list.invalidate() marks the user.list query as stale, so components using it automatically refetch.
An optimistic update makes the UI feel instant: show the result as if the mutation already succeeded, then roll back if the server rejects it:
const deleteUser = trpc.user.delete.useMutation({
onMutate: async (deleted) => {
const utils = trpc.useUtils();
await utils.user.list.cancel();
const sebelumnya = utils.user.list.getData();
utils.user.list.setData(undefined, (lama) =>
(lama ?? []).filter((u) => u.id !== deleted.id),
);
return { sebelumnya };
},
onError: (_err, _vars, ctx) => {
if (ctx?.sebelumnya) {
utils.user.list.setData(undefined, ctx.sebelumnya);
}
},
onSettled: () => {
utils.user.list.invalidate();
},
});The flow: onMutate cancels the running query and immediately updates the cache with the version without the deleted user. If it fails, onError restores the previous data; when it finishes, onSettled keeps the cache aligned with the server through invalidate.
Use them for operations that feel fast to users: toggles, deletes, and small updates. For operations with complex validation or a risk of incorrect data, it is safer to wait for the server response and show a loading state.
Configure defaults on the QueryClient to be consistent across the whole application:
import { QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
refetchOnWindowFocus: false,
refetchOnMount: "always",
},
},
});Options you should know:
staleTime: how long data is considered fresh before being refetched.refetchOnWindowFocus: whether to refetch when the tab regains focus — true by default in React Query.retry: the number of automatic retries on failure.gcTime: how long unused cache stays in memory.React Query distinguishes the states clearly:
isPending: no data at all yet.isFetching: currently fetching data (including background).isStale: data has expired and needs a refetch.A practical pattern: show old data while refetching in the background for a smooth UX:
const { data, isFetching } = trpc.post.list.useQuery(undefined, {
staleTime: 5_000,
});
if (isFetching && data) return <p>Memperbarui...</p>;isFetching && data lets you show an indicator without clearing the screen — the existing data is still displayed.
Tip
Start with a small staleTime like 5 seconds for frequently changing data, then increase it for static data. A staleTime that is too large makes users see stale data; too small makes requests wasteful.
Episode 8 gives you full control over server state on the client: cached queries, invalidate to keep synchronization, optimistic updates for responsive UX, and caching and stale data settings that fit your application's needs.
Key takeaways:
invalidate() marks a query stale so it is refetched.onMutate for optimistic updates, onError for rollback.staleTime controls data freshness; refetchOnWindowFocus controls refetch.isPending, isFetching, and isStale are the three important states.In the next episode, episode 9, we will discuss schema evolution, versioning & backward compatibility — changing a tRPC API without breaking clients, versioning and procedure deprecation approaches, and migrating input and output schemas while maintaining compatibility.