Skip to main content
All posts

React 19 + TanStack Query: Patterns That Actually Work in Production

12 May 2026

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:

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

ScenarioTool
Read data, cache it, background refreshuseQuery
Create / update / delete with side effectsuseMutation + invalidateQueries
Form with pending state + validationuseActionState (React 19)
Optimistic update with rollbackuseMutation.onMutate
Loading/error boundariesuseSuspenseQuery + ErrorBoundary
Dependent requestsenabled option on useQuery
Infinite scroll / paginationuseInfiniteQuery
One-off promise in componentuse() (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.

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch