React 19 shipped with a set of new primitives that overlap with what TanStack Query has been doing for years. That overlap is intentional — but it doesn’t make TanStack Query obsolete. It changes where the boundary sits.
Here’s what I’ve settled on after shipping a production app with React 19 + TanStack Query v5.
What React 19 Actually Adds
Three things that matter for data fetching:
1. use() for promise unwrapping
// Unwrap a promise in a component — but it suspends!
function UserName({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // suspends until resolved
return <span>{user.name}</span>;
}
2. Actions and useActionState
// Form mutations with built-in pending/error state
const [state, submitAction, isPending] = useActionState(
async (prevState, formData) => {
const result = await updateProfile(formData);
return result;
},
null
);
3. useOptimistic
// Optimistic UI without external state manager
const [optimisticItems, addOptimistic] = useOptimistic(items);
These are great primitives. They don’t replace TanStack Query. Here’s why.
Where TanStack Query Still Wins
React 19’s primitives handle a single request well. TanStack Query handles the lifecycle of requests across your entire app:
- Deduplication — 10 components request the same user, 1 network call
- Background refetching — stale-while-revalidate, focus refetch, interval polling
- Cache coordination — invalidate after mutation, optimistic update with rollback
- DevTools — see every query, its state, when it last fetched
- Infinite queries — pagination with automatic page management
Once your app has more than a handful of async calls, you want TanStack Query managing the cache.
The Pattern I Use: Server for Mutations, Query for Reads
The clean split:
// ✅ Reads → TanStack Query
function useUser(userId: string) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => api.get<User>(`/users/${userId}`),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// ✅ Mutations → useMutation + React 19 useOptimistic
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateUserPayload) => api.patch('/users/me', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
Optimistic Updates: The Right Way
React 19 has useOptimistic, but TanStack Query’s onMutate + onError gives you rollback for free:
function useToggleFavourite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemId: string) => api.post(`/items/${itemId}/favourite`),
onMutate: async (itemId) => {
// Cancel any outgoing refetches (avoid overwriting optimistic update)
await queryClient.cancelQueries({ queryKey: ['items'] });
// Snapshot the previous value
const previousItems = queryClient.getQueryData<Item[]>(['items']);
// Optimistically update
queryClient.setQueryData<Item[]>(['items'], (old = []) =>
old.map(item =>
item.id === itemId
? { ...item, isFavourite: !item.isFavourite }
: item
)
);
// Return snapshot for rollback
return { previousItems };
},
onError: (_err, _itemId, context) => {
// Roll back on error
if (context?.previousItems) {
queryClient.setQueryData(['items'], context.previousItems);
}
},
onSettled: () => {
// Always refetch after mutation
queryClient.invalidateQueries({ queryKey: ['items'] });
},
});
}
This pattern handles every failure mode: network error, server error, optimistic state gets rolled back automatically.
Forms with React 19 Actions + Query Invalidation
Where React 19 Actions genuinely shine: form submissions that need pending state and validation feedback.
// components/ProfileForm.tsx
function ProfileForm() {
const queryClient = useQueryClient();
const { data: user } = useUser('me');
const [state, submitAction, isPending] = useActionState(
async (_prevState: FormState, formData: FormData) => {
const result = await updateProfileAction(formData); // server action
if (result.success) {
// Invalidate the query cache after successful mutation
await queryClient.invalidateQueries({ queryKey: ['users', 'me'] });
return { success: true, errors: null };
}
return { success: false, errors: result.errors };
},
{ success: false, errors: null }
);
return (
<form action={submitAction}>
<input name="name" defaultValue={user?.name} />
{state.errors?.name && (
<p className="text-red-500 text-sm">{state.errors.name}</p>
)}
<button disabled={isPending}>
{isPending ? 'Saving...' : 'Save changes'}
</button>
</form>
);
}
The action handles submission + pending state. TanStack Query handles cache invalidation. Clean separation.
Query Key Factories: Avoid Key Spaghetti
As your app grows, query keys become a maintenance problem. Key factories fix this:
// lib/query-keys.ts
export const queryKeys = {
users: {
all: ['users'] as const,
lists: () => [...queryKeys.users.all, 'list'] as const,
list: (filters: UserFilters) => [...queryKeys.users.lists(), filters] as const,
details: () => [...queryKeys.users.all, 'detail'] as const,
detail: (id: string) => [...queryKeys.users.details(), id] as const,
},
items: {
all: ['items'] as const,
byUser: (userId: string) => [...queryKeys.items.all, 'byUser', userId] as const,
},
} as const;
// Usage — no magic strings anywhere
const { data } = useQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => api.get<User>(`/users/${userId}`),
});
// Invalidation is precise
queryClient.invalidateQueries({ queryKey: queryKeys.users.details() });
// ^ invalidates ALL detail queries, not the list
This makes refactoring safe. Change the key structure in one place, TypeScript catches every consumer.
Suspense Integration
React 19 makes Suspense more ergonomic. TanStack Query v5 has first-class Suspense support:
// The query — note useSuspenseQuery
function UserProfile({ userId }: { userId: string }) {
// No loading/error state to handle — Suspense/ErrorBoundary do it
const { data: user } = useSuspenseQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => api.get<User>(`/users/${userId}`),
});
return <div>{user.name}</div>;
}
// The parent
function ProfilePage({ userId }: { userId: string }) {
return (
<ErrorBoundary fallback={<ErrorCard />}>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={userId} />
</Suspense>
</ErrorBoundary>
);
}
useSuspenseQuery — not useQuery — triggers Suspense. The component itself is clean: no if (isLoading), no if (error).
Prefetching for Perceived Performance
The biggest performance win in TanStack Query that most apps don’t use:
// Prefetch on hover — data is ready before user clicks
function UserCard({ userId }: { userId: string }) {
const queryClient = useQueryClient();
return (
<div
onMouseEnter={() => {
queryClient.prefetchQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => api.get<User>(`/users/${userId}`),
staleTime: 5 * 60 * 1000,
});
}}
>
{/* ... */}
</div>
);
}
When the user hovers on a card, the data fetches in the background. By the time they click, it’s already in cache. The navigation feels instant.
What I Reach For and When
| Scenario | Tool |
|---|---|
| Read data, cache it, background refresh | useQuery |
| Create / update / delete with side effects | useMutation + invalidateQueries |
| Form with pending state + validation | useActionState (React 19) |
| Optimistic update with rollback | useMutation.onMutate |
| Loading/error boundaries | useSuspenseQuery + ErrorBoundary |
| Dependent requests | enabled option on useQuery |
| Infinite scroll / pagination | useInfiniteQuery |
| One-off promise in component | use() (React 19) |
React 19 fills the gaps at the edges. TanStack Query owns the cache layer. Together they cover every async pattern cleanly.
Building a React 19 app and want feedback on your data fetching architecture? Let’s talk.