DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

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

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>;
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

3. useOptimistic

// Optimistic UI without external state manager
const [optimisticItems, addOptimistic] = useOptimistic(items);
Enter fullscreen mode Exit fullscreen mode

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'] });
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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'] });
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)