Smarter Data Fetching with TanStack Query: Reusable Patterns and Optimistic UI

Fetching data is one of the most common — and error-prone — tasks in frontend development. Loading states, error boundaries, retries, stale data, race conditions… the list goes on.

Fortunately, TanStack Query (formerly React Query) gives you a powerful set of tools to tame this chaos. I’ll share a few patterns I’ve found especially helpful on real-world projects:

  • Reusing query configuration across components
  • Using select to shape responses
  • Writing mutations with optimistic updates
  • Invalidating data to keep things in sync

Reusing Query Options for Clean, Cached Code

TanStack Query encourages collocation of data logic with components — but that doesn’t mean you have to repeat yourself. You can bundle the queryKey, queryFn, and shared options like staleTime into a reusable object.

Let’s say we’re working with user profile data. Here’s how to set up a shared query definition:

// types.ts
export type Profile = {
  id: string;
  name: string;
  email: string;
};
// api/profile.ts
import axios from 'axios';
import { Profile } from './types';

export const profileQueryOptions = {
  queryKey: ['profile'],
  queryFn: async (): Promise<Profile> => {
    const { data } = await axios.get('/api/profile');
    return data;
  },
  staleTime: 1000 * 60 * 5,
};

Then, in your hook:


import { useQuery } from '@tanstack/react-query';
import { profileQueryOptions } from './api/profile';

export const useProfile = () => {
  return useQuery(profileQueryOptions);
};

Why This Pattern Is Great

  • Single source of truth for query key, fetch logic, and behavior
  • Reuses the same cache entry — no extra network requests
  • Easy to use in prefetching or background updates

Using the Same Options Elsewhere

You can reuse the same query options and corresponding cache anywhere useQueryClient() is available — such as prefetching data when a component mounts, in anticipation of a user visiting a related page.

Here’s an example where we prefetch a user’s profile as soon as a user card appears, ensuring the data is ready by the time the user navigates:


import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { profileQueryOptions } from './api/profile';

const UserCard = () => {
  const queryClient = useQueryClient();

  useEffect(() => {
    queryClient.prefetchQuery(profileQueryOptions);
  }, [queryClient]);

  return (
    <a href="/profile">
      View Profile
    </a>
  );
};

Because the queryKey and queryFn match your main useQuery hook, the cache is reused — so when the user lands on the profile page, there’s no need to refetch.

Picking Just What You Need with select

Sometimes your components only care about a slice of the data — not the whole response. TanStack Query’s select option lets you project the data into exactly what you need.

Here’s how to reuse your query options while customizing the result:


const { data: profileName } = useQuery({
  ...profileQueryOptions,
  select: (profile) => profile.name,
});

Another example: extracting an array of user IDs:


type User = {
  id: string;
  name: string;
};

const { data: userIds } = useQuery({
  queryKey: ['users'],
  queryFn: async (): Promise<User[]> => {
    const { data } = await axios.get('/api/users');
    return data;
  },
  select: (users) => users.map(u => u.id),
  staleTime: 1000 * 60,
});

Mutations with Optimistic Updates

Mutations let you update data on the server — but with optimistic updates, you can reflect those changes immediately in the UI, even before the server confirms.

Here’s a mutation pattern for updating a post title, with optimistic caching:


type Post = {
  id: number;
  title: string;
};

export const useUpdatePostTitle = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, newTitle }: { id: number; newTitle: string }) =>
      axios.patch(`/api/posts/${id}`, { title: newTitle }),

    onMutate: async ({ id, newTitle }) => {
      await queryClient.cancelQueries(['posts']);

      const previousPosts = queryClient.getQueryData<Post[]>(['posts']);

      queryClient.setQueryData<Post[]>(['posts'], (old = []) =>
        old.map(post =>
          post.id === id ? { ...post, title: newTitle } : post
        )
      );

      return { previousPosts };
    },

    onError: (_err, _variables, context) => {
      queryClient.setQueryData(['posts'], context?.previousPosts);
    },

    onSettled: () => {
      queryClient.invalidateQueries(['posts']);
    },
  });
};

When the mutation occurs the cache is updated with the same data that was sent to the server. If an error occurs during this mutation the change is rolled back using the previous data from the cache. Finally when the mutation settles, which happens on error or success, the cache is invalidated to ensure that the newest data from the server is populated into the cache.

Example Usage with Input


import { useState } from 'react';
import { useUpdatePostTitle } from './hooks/useUpdatePostTitle';

const PostEditor = ({ postId }: { postId: number }) => {
  const [title, setTitle] = useState('');
  const { mutate: updateTitle, isLoading } = useUpdatePostTitle();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (title.trim()) {
      updateTitle({ id: postId, newTitle: title });
      setTitle('');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Enter new title"
        disabled={isLoading}
      />
      <button type="submit" disabled={isLoading || !title.trim()}>
        {isLoading ? 'Updating...' : 'Update Title'}
      </button>
    </form>
  );
};

Invalidate and Refetch After Mutations

After creating or deleting data, it’s best to invalidate affected queries so your UI stays in sync.


type NewPost = {
  title: string;
  content: string;
};

export const useCreatePost = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (newPost: NewPost) =>
      axios.post('/api/posts', newPost),

    onSuccess: () => {
      queryClient.invalidateQueries(['posts']);
    },
  });
};

This ensures that your list view reflects the new post without manually appending it to cached data.

Wrapping Up

TanStack Query is more than a data-fetching library — it’s a powerful toolkit for managing server state and synchronizing your UI with confidence.

Use these:

  • Reusable query definitions with queryKey, queryFn, and staleTime
  • select for shaping return values cleanly
  • Optimistic mutations for better UX
  • Cache invalidation to keep things in sync

You’ll drastically simplify complex frontend data flows.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *