DEV Community

Rajat Parihar
Rajat Parihar

Posted on

From Beginner to Pro: Mastering State Management with TanStack Query v5

The complete guide to building production-ready React apps with TanStack Query v5, queryOptions, and best practices

Stack

πŸ“‹ Table of Contents


πŸš€ Why TanStack Query v5?

The Problem It Solves

Traditional state management (Redux, Context API) treats server data like client state. This leads to:

  • Manual cache management - Writing cache update logic
  • Loading state hell - Managing loading/error states everywhere
  • Stale data - No automatic background refetching
  • Boilerplate overload - Hundreds of lines for simple data fetching

TanStack Query's Solution

// Without TanStack Query: ~50 lines of code
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
  setLoading(true);
  fetch('/api/users')
    .then(res => res.json())
    .then(data => setUsers(data))
    .catch(err => setError(err))
    .finally(() => setLoading(false));
}, []);

// With TanStack Query: ~5 lines
const { data: users, isLoading, error } = useQuery({
  queryKey: ['users'],
  queryFn: () => fetch('/api/users').then(res => res.json())
});
Enter fullscreen mode Exit fullscreen mode

What's New in v5?

  • 20% smaller bundle size than v4
  • queryOptions helper - Type-safe query definitions
  • Suspense is stable - useSuspenseQuery hook
  • Simplified optimistic updates - No manual cache writing
  • Better TypeScript support - Improved type inference
  • New isPending state - Clearer loading states
  • useMutationState - Access all mutation states globally
  • Infinite query improvements - maxPages option

βœ… Prerequisites

Before diving in, ensure you have:

  • Node.js (v18+)
  • React (v18+) - TanStack Query v5 requires React 18
  • TypeScript (v4.7+) - Recommended but optional
  • Basic understanding of:
    • React Hooks (useState, useEffect)
    • Async/Await and Promises
    • REST APIs

Quick Environment Check

node --version   # Should be v18+
npm --version    # Should be 8+
Enter fullscreen mode Exit fullscreen mode

πŸ“ Project Structure

tanstack-query-app/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ api/                      # API layer
β”‚   β”‚   β”œβ”€β”€ axios-instance.ts     # Axios/Fetch setup
β”‚   β”‚   β”œβ”€β”€ endpoints.ts          # API endpoint definitions
β”‚   β”‚   └── types.ts              # API response types
β”‚   β”œβ”€β”€ queries/                  # Query definitions (THE PATTERN)
β”‚   β”‚   β”œβ”€β”€ users.queries.ts      # User-related queries
β”‚   β”‚   β”œβ”€β”€ posts.queries.ts      # Post-related queries
β”‚   β”‚   └── index.ts              # Export all queries
β”‚   β”œβ”€β”€ hooks/                    # Custom hooks
β”‚   β”‚   β”œβ”€β”€ useUsers.ts           # User data hooks
β”‚   β”‚   β”œβ”€β”€ usePosts.ts           # Post data hooks
β”‚   β”‚   └── useAuth.ts            # Auth hooks
β”‚   β”œβ”€β”€ components/               # React components
β”‚   β”‚   β”œβ”€β”€ users/
β”‚   β”‚   β”‚   β”œβ”€β”€ UserList.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ UserDetail.tsx
β”‚   β”‚   β”‚   └── CreateUser.tsx
β”‚   β”‚   └── posts/
β”‚   β”‚       β”œβ”€β”€ PostList.tsx
β”‚   β”‚       └── CreatePost.tsx
β”‚   β”œβ”€β”€ lib/                      # Utilities
β”‚   β”‚   β”œβ”€β”€ query-client.ts       # QueryClient configuration
β”‚   β”‚   └── utils.ts              # Helper functions
β”‚   β”œβ”€β”€ types/                    # TypeScript types
β”‚   β”‚   β”œβ”€β”€ user.types.ts
β”‚   β”‚   └── post.types.ts
β”‚   β”œβ”€β”€ App.tsx                   # Root component
β”‚   └── main.tsx                  # Entry point
β”œβ”€β”€ .env                          # Environment variables
β”œβ”€β”€ package.json
β”œβ”€β”€ tsconfig.json
└── vite.config.ts
Enter fullscreen mode Exit fullscreen mode

⚑ Quick Start

1. Create React Project

# Using Vite (recommended)
npm create vite@latest tanstack-query-app -- --template react-ts
cd tanstack-query-app

# Or using Create React App
npx create-react-app tanstack-query-app --template typescript
cd tanstack-query-app
Enter fullscreen mode Exit fullscreen mode

2. Install Dependencies

# TanStack Query
npm install @tanstack/react-query

# DevTools (optional but highly recommended)
npm install @tanstack/react-query-devtools

# Axios for API calls
npm install axios

# Additional utilities
npm install react-router-dom
Enter fullscreen mode Exit fullscreen mode

3. Setup QueryClient

Create src/lib/query-client.ts:

import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
      retry: 3,
      refetchOnWindowFocus: false,
      refetchOnReconnect: true,
    },
    mutations: {
      retry: 1,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

4. Wrap App with Provider

Update src/main.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './lib/query-client';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

5. Create Your First Query

Create src/queries/users.queries.ts:

import { queryOptions } from '@tanstack/react-query';
import axios from 'axios';

export interface User {
  id: number;
  name: string;
  email: string;
  username: string;
}

// API functions
const fetchUsers = async (): Promise<User[]> => {
  const { data } = await axios.get('https://jsonplaceholder.typicode.com/users');
  return data;
};

const fetchUserById = async (id: number): Promise<User> => {
  const { data } = await axios.get(`https://jsonplaceholder.typicode.com/users/${id}`);
  return data;
};

// Query options using the queryOptions helper
export const usersQueryOptions = () =>
  queryOptions({
    queryKey: ['users'],
    queryFn: fetchUsers,
    staleTime: 1000 * 60 * 5, // 5 minutes
  });

export const userQueryOptions = (id: number) =>
  queryOptions({
    queryKey: ['users', id],
    queryFn: () => fetchUserById(id),
    staleTime: 1000 * 60 * 5,
  });
Enter fullscreen mode Exit fullscreen mode

6. Use in Component

Create src/components/users/UserList.tsx:

import { useQuery } from '@tanstack/react-query';
import { usersQueryOptions } from '../../queries/users.queries';

export function UserList() {
  const { data: users, isLoading, error } = useQuery(usersQueryOptions());

  if (isLoading) return <div>Loading users...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h2>Users</h2>
      <ul>
        {users?.map(user => (
          <li key={user.id}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

7. Run the App

npm run dev
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:5173 and see TanStack Query in action! πŸŽ‰


🧠 Core Concepts

Query Keys

Query keys uniquely identify queries. They're arrays that can contain:

// Simple key
['users']

// With parameters
['users', userId]

// With filters
['users', { status: 'active', role: 'admin' }]

// Hierarchical
['projects', projectId, 'tasks', { completed: false }]
Enter fullscreen mode Exit fullscreen mode

Rules:

  • Keys are deterministic (same key = same query)
  • Order matters: ['users', 1] β‰  ['users', '1']
  • Objects are compared deeply
  • Keys should be serializable

Query States

TanStack Query v5 has clear states:

{
  isPending: boolean,    // No data yet (replaces isLoading in v4)
  isLoading: boolean,    // isPending && isFetching (first load only)
  isError: boolean,      // Query failed
  isSuccess: boolean,    // Query succeeded
  isFetching: boolean,   // Currently fetching (background or initial)
  isRefetching: boolean, // Background refetch
  isStale: boolean,      // Data is stale (needs refetch)
}
Enter fullscreen mode Exit fullscreen mode

Key difference in v5:

  • isPending - No cached data exists yet
  • isLoading - First-time fetch (isPending + isFetching)
  • isFetching - Any fetch (initial or background)

Stale Time vs GC Time

{
  staleTime: 1000 * 60 * 5,  // 5 minutes - how long data is "fresh"
  gcTime: 1000 * 60 * 10,    // 10 minutes - how long unused data stays in cache
}
Enter fullscreen mode Exit fullscreen mode

Mental model:

  1. Fetch β†’ Data is fresh (staleTime countdown starts)
  2. After staleTime β†’ Data becomes stale (still in cache, shows immediately, but refetches in background)
  3. After gcTime of no usage β†’ Data is garbage collected (removed from cache)
Fetch β†’ [Fresh Period] β†’ [Stale Period] β†’ [GC Period] β†’ Deleted
        ↑staleTime         ↑gcTime
Enter fullscreen mode Exit fullscreen mode

🎯 queryOptions Pattern

Why queryOptions?

The queryOptions helper is THE recommended pattern in v5. It provides:

βœ… Type safety - Full TypeScript inference
βœ… Reusability - Share queries across components
βœ… Co-location - Keys and functions together
βœ… Prefetching - Use with prefetchQuery
βœ… Type-safe cache access - getQueryData knows the type

Basic Pattern

import { queryOptions } from '@tanstack/react-query';

// Define query options
export const todoQueryOptions = (id: number) =>
  queryOptions({
    queryKey: ['todos', id],
    queryFn: async () => {
      const res = await fetch(`/api/todos/${id}`);
      if (!res.ok) throw new Error('Failed to fetch todo');
      return res.json() as Promise<Todo>;
    },
    staleTime: 1000 * 60,
  });

// Use in component
function TodoDetail({ id }: { id: number }) {
  const { data } = useQuery(todoQueryOptions(id));
  return <div>{data?.title}</div>;
}

// Use in prefetch
queryClient.prefetchQuery(todoQueryOptions(5));

// Type-safe cache access
const todo = queryClient.getQueryData(todoQueryOptions(5).queryKey);
//    ^? Todo | undefined (TypeScript knows the type!)
Enter fullscreen mode Exit fullscreen mode

Complete Example: Posts API

Create src/api/axios-instance.ts:

import axios from 'axios';

export const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'https://jsonplaceholder.typicode.com',
  headers: {
    'Content-Type': 'application/json',
  },
});

// Request interceptor (add auth tokens, etc.)
api.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  error => Promise.reject(error)
);

// Response interceptor (handle errors globally)
api.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      // Handle unauthorized
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);
Enter fullscreen mode Exit fullscreen mode

Create src/types/post.types.ts:

export interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
}

export interface CreatePostDto {
  userId: number;
  title: string;
  body: string;
}

export interface UpdatePostDto {
  title?: string;
  body?: string;
}

export interface PostFilters {
  userId?: number;
  search?: string;
}
Enter fullscreen mode Exit fullscreen mode

Create src/queries/posts.queries.ts:

import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query';
import { api } from '../api/axios-instance';
import type { Post, PostFilters } from '../types/post.types';

// ============================================
// API Functions
// ============================================

const fetchPosts = async (filters?: PostFilters): Promise<Post[]> => {
  const { data } = await api.get<Post[]>('/posts', { params: filters });
  return data;
};

const fetchPostById = async (id: number): Promise<Post> => {
  const { data } = await api.get<Post>(`/posts/${id}`);
  return data;
};

const fetchPostsByUser = async (userId: number): Promise<Post[]> => {
  const { data } = await api.get<Post[]>(`/posts?userId=${userId}`);
  return data;
};

// ============================================
// Query Options (THE PATTERN)
// ============================================

/**
 * Fetch all posts with optional filters
 */
export const postsQueryOptions = (filters?: PostFilters) =>
  queryOptions({
    queryKey: ['posts', filters] as const,
    queryFn: () => fetchPosts(filters),
    staleTime: 1000 * 60 * 5, // 5 minutes
  });

/**
 * Fetch a single post by ID
 */
export const postQueryOptions = (id: number) =>
  queryOptions({
    queryKey: ['posts', id] as const,
    queryFn: () => fetchPostById(id),
    staleTime: 1000 * 60 * 5,
    // Only fetch if id is valid
    enabled: id > 0,
  });

/**
 * Fetch posts by user ID
 */
export const userPostsQueryOptions = (userId: number) =>
  queryOptions({
    queryKey: ['posts', 'user', userId] as const,
    queryFn: () => fetchPostsByUser(userId),
    staleTime: 1000 * 60 * 3,
  });

/**
 * Infinite query for paginated posts
 */
export const postsInfiniteQueryOptions = () =>
  infiniteQueryOptions({
    queryKey: ['posts', 'infinite'] as const,
    queryFn: async ({ pageParam }) => {
      const { data } = await api.get<Post[]>('/posts', {
        params: { _page: pageParam, _limit: 10 },
      });
      return data;
    },
    initialPageParam: 1,
    getNextPageParam: (lastPage, allPages) => {
      return lastPage.length === 10 ? allPages.length + 1 : undefined;
    },
    staleTime: 1000 * 60 * 5,
    // NEW in v5: Limit pages in cache
    maxPages: 3,
  });
Enter fullscreen mode Exit fullscreen mode

Using Query Options in Components

import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { postsQueryOptions, postQueryOptions } from '../queries/posts.queries';

// Regular query
function PostList() {
  const { data: posts, isLoading } = useQuery(postsQueryOptions());

  if (isLoading) return <div>Loading...</div>;
  return <div>{posts?.map(post => <PostCard key={post.id} post={post} />)}</div>;
}

// With filters
function FilteredPostList({ userId }: { userId: number }) {
  const { data: posts } = useQuery(postsQueryOptions({ userId }));
  return <div>{posts?.map(post => <PostCard key={post.id} post={post} />)}</div>;
}

// Suspense query (v5 stable feature)
function PostDetail({ id }: { id: number }) {
  const { data: post } = useSuspenseQuery(postQueryOptions(id));
  // data is ALWAYS defined (never undefined) with useSuspenseQuery
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </div>
  );
}

// Wrap in Suspense boundary
function PostDetailPage({ id }: { id: number }) {
  return (
    <Suspense fallback={<PostSkeleton />}>
      <PostDetail id={id} />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

πŸ” Queries Deep Dive

Dependent Queries

Queries that depend on data from other queries:

function UserPosts({ email }: { email: string }) {
  // First query: Get user by email
  const { data: user } = useQuery({
    queryKey: ['users', 'by-email', email],
    queryFn: () => fetchUserByEmail(email),
  });

  // Second query: Get posts (only runs when user exists)
  const { data: posts } = useQuery({
    queryKey: ['posts', 'user', user?.id],
    queryFn: () => fetchUserPosts(user!.id),
    enabled: !!user?.id, // Only run if user.id exists
  });

  return <PostList posts={posts} />;
}
Enter fullscreen mode Exit fullscreen mode

Parallel Queries

Fetch multiple queries simultaneously:

function Dashboard() {
  // All queries run in parallel
  const users = useQuery(usersQueryOptions());
  const posts = useQuery(postsQueryOptions());
  const comments = useQuery(commentsQueryOptions());

  if (users.isLoading || posts.isLoading || comments.isLoading) {
    return <Spinner />;
  }

  return (
    <div>
      <Stats users={users.data} posts={posts.data} comments={comments.data} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useQueries for Dynamic Lists

When you need to fetch variable number of queries:

import { useQueries } from '@tanstack/react-query';

function MultipleUsers({ userIds }: { userIds: number[] }) {
  const userQueries = useQueries({
    queries: userIds.map(id => userQueryOptions(id)),
    // NEW in v5: Combine results
    combine: results => ({
      data: results.map(r => r.data),
      isPending: results.some(r => r.isPending),
    }),
  });

  return (
    <div>
      {userQueries.data.map((user, idx) => (
        <UserCard key={userIds[idx]} user={user} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Prefetching

Prefetch data before it's needed:

import { useQueryClient } from '@tanstack/react-query';

function PostPreview({ postId }: { postId: number }) {
  const queryClient = useQueryClient();

  // Prefetch on hover
  const handleMouseEnter = () => {
    queryClient.prefetchQuery(postQueryOptions(postId));
  };

  return (
    <Link to={`/posts/${postId}`} onMouseEnter={handleMouseEnter}>
      View Post
    </Link>
  );
}

// Prefetch in route loader (React Router)
export const postDetailLoader = async ({ params }: LoaderFunctionArgs) => {
  const postId = Number(params.id);
  await queryClient.prefetchQuery(postQueryOptions(postId));
  return null;
};
Enter fullscreen mode Exit fullscreen mode

Initial Data

Provide initial data to avoid loading states:

function PostDetail({ postId }: { postId: number }) {
  const queryClient = useQueryClient();

  const { data: post } = useQuery({
    ...postQueryOptions(postId),
    // Use data from posts list if available
    initialData: () => {
      const posts = queryClient.getQueryData(postsQueryOptions().queryKey);
      return posts?.find(p => p.id === postId);
    },
    // Only use initial data if it's fresh
    initialDataUpdatedAt: () =>
      queryClient.getQueryState(postsQueryOptions().queryKey)?.dataUpdatedAt,
  });

  return <PostCard post={post} />;
}
Enter fullscreen mode Exit fullscreen mode

Placeholder Data

Show placeholder while loading (NEW behavior in v5):

function PostList({ userId }: { userId: number }) {
  const { data: posts, isPlaceholderData } = useQuery({
    ...postsQueryOptions({ userId }),
    // NEW in v5: keepPreviousData is now placeholderData
    placeholderData: previousData => previousData,
  });

  return (
    <div>
      {/* Show visual indicator during background fetch */}
      {isPlaceholderData && <div className="opacity-50">Updating...</div>}
      <PostList posts={posts} />
    </div>
  );
}

// Built-in helper (v5)
import { keepPreviousData } from '@tanstack/react-query';

const { data } = useQuery({
  queryKey: ['posts', userId],
  queryFn: fetchPosts,
  placeholderData: keepPreviousData, // Same as above
});
Enter fullscreen mode Exit fullscreen mode

✏️ Mutations & Optimistic Updates

Basic Mutation

Create src/queries/posts.mutations.ts:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/axios-instance';
import type { Post, CreatePostDto } from '../types/post.types';
import { postsQueryOptions } from './posts.queries';

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

  return useMutation({
    mutationFn: async (newPost: CreatePostDto): Promise<Post> => {
      const { data } = await api.post<Post>('/posts', newPost);
      return data;
    },
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
}

export function useUpdatePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ id, ...updates }: { id: number } & Partial<Post>) => {
      const { data } = await api.patch<Post>(`/posts/${id}`, updates);
      return data;
    },
    onSuccess: (updatedPost) => {
      // Update single item in cache
      queryClient.setQueryData(
        postQueryOptions(updatedPost.id).queryKey,
        updatedPost
      );
      // Invalidate list
      queryClient.invalidateQueries({ queryKey: ['posts'], exact: false });
    },
  });
}

export function useDeletePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (id: number) => {
      await api.delete(`/posts/${id}`);
      return id;
    },
    onSuccess: (deletedId) => {
      // Remove from cache
      queryClient.removeQueries({ queryKey: ['posts', deletedId] });
      // Invalidate lists
      queryClient.invalidateQueries({ queryKey: ['posts'], exact: false });
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Using Mutations in Components

import { useCreatePost, useUpdatePost } from '../queries/posts.mutations';

function CreatePostForm() {
  const createPost = useCreatePost();

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    createPost.mutate(
      {
        userId: 1,
        title: formData.get('title') as string,
        body: formData.get('body') as string,
      },
      {
        onSuccess: () => {
          alert('Post created!');
          e.currentTarget.reset();
        },
        onError: (error) => {
          alert(`Error: ${error.message}`);
        },
      }
    );
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Title" required />
      <textarea name="body" placeholder="Body" required />
      <button type="submit" disabled={createPost.isPending}>
        {createPost.isPending ? 'Creating...' : 'Create Post'}
      </button>
      {createPost.isError && <p>Error: {createPost.error.message}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Optimistic Updates (Simplified in v5!)

TanStack Query v5 makes optimistic updates easier by using mutation variables:

export function useTogglePostLike() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (postId: number) => {
      const { data } = await api.post(`/posts/${postId}/like`);
      return data;
    },
    // NEW v5 pattern: Use variables for optimistic UI
    onMutate: async (postId) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['posts', postId] });

      // Snapshot previous value
      const previousPost = queryClient.getQueryData(
        postQueryOptions(postId).queryKey
      );

      // Optimistically update
      queryClient.setQueryData(postQueryOptions(postId).queryKey, (old: Post) => ({
        ...old,
        likes: old.likes + 1,
      }));

      // Return context with previous value
      return { previousPost };
    },
    onError: (err, postId, context) => {
      // Rollback on error
      queryClient.setQueryData(
        postQueryOptions(postId).queryKey,
        context?.previousPost
      );
    },
    onSettled: (data, error, postId) => {
      // Always refetch after error or success
      queryClient.invalidateQueries({ queryKey: ['posts', postId] });
    },
  });
}

// Usage with optimistic UI feedback
function LikeButton({ postId }: { postId: number }) {
  const toggleLike = useTogglePostLike();

  return (
    <button
      onClick={() => toggleLike.mutate(postId)}
      disabled={toggleLike.isPending}
    >
      πŸ‘ Like {toggleLike.variables === postId && '(saving...)'}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Advanced: Optimistic Updates with Lists

export function useCreatePostOptimistic() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (newPost: CreatePostDto): Promise<Post> => {
      const { data } = await api.post('/posts', newPost);
      return data;
    },
    onMutate: async (newPost) => {
      await queryClient.cancelQueries({ queryKey: ['posts'] });

      const previousPosts = queryClient.getQueryData(postsQueryOptions().queryKey);

      // Optimistic post with temporary ID
      const optimisticPost: Post = {
        id: Date.now(), // Temporary ID
        ...newPost,
      };

      // Add to list
      queryClient.setQueryData(postsQueryOptions().queryKey, (old: Post[] = []) => [
        optimisticPost,
        ...old,
      ]);

      return { previousPosts };
    },
    onError: (err, newPost, context) => {
      queryClient.setQueryData(postsQueryOptions().queryKey, context?.previousPosts);
    },
    onSuccess: (createdPost) => {
      // Replace optimistic post with real one
      queryClient.setQueryData(postsQueryOptions().queryKey, (old: Post[] = []) =>
        old.map(post => (post.id === createdPost.id ? createdPost : post))
      );
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

useMutationState (NEW in v5!)

Access mutation state globally:

import { useMutationState } from '@tanstack/react-query';

function GlobalLoadingIndicator() {
  // Get all pending mutations
  const pendingMutations = useMutationState({
    filters: { status: 'pending' },
    select: mutation => ({
      mutationKey: mutation.state.mutationKey,
      variables: mutation.state.variables,
    }),
  });

  if (pendingMutations.length === 0) return null;

  return (
    <div className="fixed top-0 right-0 p-4">
      {pendingMutations.map((m, i) => (
        <div key={i}>Saving {m.mutationKey}...</div>
      ))}
    </div>
  );
}

// Check if specific mutation is pending
function useIsCreatingPost() {
  const mutations = useMutationState({
    filters: { mutationKey: ['posts', 'create'], status: 'pending' },
  });
  return mutations.length > 0;
}
Enter fullscreen mode Exit fullscreen mode

πŸš€ Advanced Patterns

Infinite Queries

Pagination made easy:

import { useInfiniteQuery } from '@tanstack/react-query';
import { postsInfiniteQueryOptions } from '../queries/posts.queries';

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery(postsInfiniteQueryOptions());

  if (isLoading) return <Spinner />;

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.map(post => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more...'
          : hasNextPage
          ? 'Load More'
          : 'No more posts'}
      </button>
    </div>
  );
}

// With intersection observer
import { useInView } from 'react-intersection-observer';

function AutoLoadInfiniteList() {
  const { ref, inView } = useInView();

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
    postsInfiniteQueryOptions()
  );

  // Auto-fetch when sentinel is in view
  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <div>
      {data?.pages.map(page =>
        page.map(post => <PostCard key={post.id} post={post} />)
      )}
      {/* Sentinel element */}
      <div ref={ref}>{isFetchingNextPage && <Spinner />}</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Query Cancellation

Cancel queries when component unmounts:

import { useQuery } from '@tanstack/react-query';

const fetchPosts = async ({ signal }: { signal: AbortSignal }) => {
  const res = await fetch('/api/posts', { signal });
  return res.json();
};

export const postsQueryOptions = () =>
  queryOptions({
    queryKey: ['posts'],
    queryFn: ({ signal }) => fetchPosts({ signal }),
  });

// Query auto-cancels when component unmounts
function PostList() {
  const { data } = useQuery(postsQueryOptions());
  return <div>{/* ... */}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Retry Logic

Smart retry strategies:

export const postsQueryOptions = () =>
  queryOptions({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    retry: (failureCount, error) => {
      // Don't retry on 404
      if (error.response?.status === 404) return false;
      // Retry up to 3 times for other errors
      return failureCount < 3;
    },
    retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
  });
Enter fullscreen mode Exit fullscreen mode

Polling

Auto-refetch at intervals:

function LiveDashboard() {
  const { data: stats } = useQuery({
    queryKey: ['dashboard', 'stats'],
    queryFn: fetchStats,
    refetchInterval: 5000, // Refetch every 5 seconds
    refetchIntervalInBackground: true, // Continue when tab is inactive
  });

  return <StatsDisplay stats={stats} />;
}

// Conditional polling
function ConditionalPolling() {
  const [isLive, setIsLive] = useState(false);

  const { data } = useQuery({
    queryKey: ['data'],
    queryFn: fetchData,
    refetchInterval: isLive ? 1000 : false, // Only poll when live mode is on
  });

  return (
    <div>
      <button onClick={() => setIsLive(!isLive)}>
        {isLive ? 'Stop' : 'Start'} Live Updates
      </button>
      <DataDisplay data={data} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

⚑ Performance Optimization

1. Selective Re-renders with select

Transform data to prevent unnecessary re-renders:

// Only re-render when user name changes
function UserName({ userId }: { userId: number }) {
  const { data: name } = useQuery({
    ...userQueryOptions(userId),
    select: user => user.name, // Only subscribe to name changes
  });

  return <div>{name}</div>;
}

// Extract specific fields
function PostTitles() {
  const { data: titles } = useQuery({
    ...postsQueryOptions(),
    select: posts => posts.map(p => p.title),
  });

  return <ul>{titles?.map(title => <li key={title}>{title}</li>)}</ul>;
}
Enter fullscreen mode Exit fullscreen mode

2. Structural Sharing

TanStack Query automatically prevents re-renders if data shape doesn't change:

// Even if refetch runs, component won't re-render if data is identical
const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  refetchInterval: 1000, // Refetch every second
  // But only re-render when data actually changes
});
Enter fullscreen mode Exit fullscreen mode

3. Query Splitting

Split large queries into smaller, focused ones:

// ❌ Bad: Fetch everything
const { data: user } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUserWithEverything(id), // Posts, comments, profile, settings...
});

// βœ… Good: Split into focused queries
const { data: user } = useQuery(userQueryOptions(id));
const { data: posts } = useQuery(userPostsQueryOptions(id));
const { data: settings } = useQuery(userSettingsQueryOptions(id));
Enter fullscreen mode Exit fullscreen mode

4. Prefetching Strategies

// Prefetch on hover
function PostLink({ postId }: { postId: number }) {
  const queryClient = useQueryClient();

  return (
    <Link
      to={`/posts/${postId}`}
      onMouseEnter={() => queryClient.prefetchQuery(postQueryOptions(postId))}
    >
      View Post
    </Link>
  );
}

// Prefetch next page in infinite query
function InfiniteList() {
  const { data, hasNextPage, fetchNextPage } = useInfiniteQuery(
    postsInfiniteQueryOptions()
  );

  // Prefetch next page when user scrolls to 80%
  useEffect(() => {
    if (hasNextPage) {
      const handleScroll = () => {
        const scrollPercentage =
          (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;

        if (scrollPercentage > 80) {
          fetchNextPage();
        }
      };

      window.addEventListener('scroll', handleScroll);
      return () => window.removeEventListener('scroll', handleScroll);
    }
  }, [hasNextPage, fetchNextPage]);

  return <div>{/* ... */}</div>;
}
Enter fullscreen mode Exit fullscreen mode

5. Disable Queries When Not Needed

function UserProfile({ userId }: { userId?: number }) {
  const { data: user } = useQuery({
    ...userQueryOptions(userId!),
    enabled: !!userId, // Don't fetch if userId is undefined
  });

  if (!userId) return <div>No user selected</div>;
  if (!user) return <div>Loading...</div>;
  return <UserCard user={user} />;
}
Enter fullscreen mode Exit fullscreen mode

πŸ›‘οΈ Error Handling

Component-Level Error Handling

function PostList() {
  const { data, error, isError, refetch } = useQuery(postsQueryOptions());

  if (isError) {
    return (
      <div className="error">
        <p>Failed to load posts: {error.message}</p>
        <button onClick={() => refetch()}>Try Again</button>
      </div>
    );
  }

  return <div>{/* ... */}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Global Error Handling

Create src/lib/query-client.ts:

import { QueryClient } from '@tanstack/react-query';
import { toast } from 'react-hot-toast';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      onError: (error) => {
        toast.error(`Query error: ${error.message}`);
      },
    },
    mutations: {
      onError: (error) => {
        toast.error(`Mutation error: ${error.message}`);
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Error Boundaries (Recommended Pattern)

Create src/components/ErrorBoundary.tsx:

import { Component, ReactNode } from 'react';
import { useQueryErrorResetBoundary } from '@tanstack/react-query';

interface Props {
  children: ReactNode;
  fallback?: (error: Error, reset: () => void) => ReactNode;
}

class ErrorBoundaryClass extends Component<Props, { hasError: boolean; error?: Error }> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return (
        this.props.fallback?.(this.state.error!, () =>
          this.setState({ hasError: false })
        ) || <div>Something went wrong</div>
      );
    }

    return this.props.children;
  }
}

export function ErrorBoundary({ children, fallback }: Props) {
  const { reset } = useQueryErrorResetBoundary();

  return (
    <ErrorBoundaryClass
      fallback={(error, resetError) => {
        return (
          fallback?.(error, () => {
            reset();
            resetError();
          }) || (
            <div>
              <p>Error: {error.message}</p>
              <button
                onClick={() => {
                  reset();
                  resetError();
                }}
              >
                Try again
              </button>
            </div>
          )
        );
      }}
    >
      {children}
    </ErrorBoundaryClass>
  );
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ErrorBoundary>
        <Suspense fallback={<Loading />}>
          <Posts />
        </Suspense>
      </ErrorBoundary>
    </QueryClientProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Typed Error Handling

import axios, { AxiosError } from 'axios';

interface ApiError {
  message: string;
  code: string;
  statusCode: number;
}

export const postsQueryOptions = () =>
  queryOptions<Post[], AxiosError<ApiError>>({
    queryKey: ['posts'],
    queryFn: async () => {
      const { data } = await api.get<Post[]>('/posts');
      return data;
    },
  });

// Usage with typed error
function PostList() {
  const { data, error } = useQuery(postsQueryOptions());

  if (error) {
    // TypeScript knows error is AxiosError<ApiError>
    return <div>Error {error.response?.data.code}: {error.response?.data.message}</div>;
  }

  return <div>{/* ... */}</div>;
}
Enter fullscreen mode Exit fullscreen mode

πŸ§ͺ Testing

Setup Testing Utilities

Create src/test/utils.tsx:

import { ReactNode } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

export function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        gcTime: Infinity,
      },
      mutations: {
        retry: false,
      },
    },
  });
}

interface WrapperProps {
  children: ReactNode;
}

export function createWrapper() {
  const testQueryClient = createTestQueryClient();

  return ({ children }: WrapperProps) => (
    <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
  );
}

export function renderWithClient(
  ui: React.ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) {
  const testQueryClient = createTestQueryClient();

  return render(ui, {
    wrapper: ({ children }) => (
      <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
    ),
    ...options,
  });
}
Enter fullscreen mode Exit fullscreen mode

Testing Queries

import { renderHook, waitFor } from '@testing-library/react';
import { createWrapper } from './test/utils';
import { useQuery } from '@tanstack/react-query';
import { postsQueryOptions } from './queries/posts.queries';

describe('Posts Query', () => {
  it('should fetch posts successfully', async () => {
    const { result } = renderHook(() => useQuery(postsQueryOptions()), {
      wrapper: createWrapper(),
    });

    await waitFor(() => expect(result.current.isSuccess).toBe(true));

    expect(result.current.data).toHaveLength(100);
    expect(result.current.data[0]).toHaveProperty('title');
  });

  it('should handle errors', async () => {
    // Mock API error
    vi.spyOn(api, 'get').mockRejectedValueOnce(new Error('Network error'));

    const { result } = renderHook(() => useQuery(postsQueryOptions()), {
      wrapper: createWrapper(),
    });

    await waitFor(() => expect(result.current.isError).toBe(true));

    expect(result.current.error?.message).toBe('Network error');
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing Mutations

import { renderHook, waitFor } from '@testing-library/react';
import { useCreatePost } from './queries/posts.mutations';
import { createWrapper } from './test/utils';

describe('Create Post Mutation', () => {
  it('should create a post', async () => {
    const { result } = renderHook(() => useCreatePost(), {
      wrapper: createWrapper(),
    });

    result.current.mutate({
      userId: 1,
      title: 'Test Post',
      body: 'Test Body',
    });

    await waitFor(() => expect(result.current.isSuccess).toBe(true));

    expect(result.current.data).toMatchObject({
      userId: 1,
      title: 'Test Post',
      body: 'Test Body',
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing Components

import { screen } from '@testing-library/react';
import { renderWithClient } from './test/utils';
import { PostList } from './components/PostList';
import { server } from './mocks/server';
import { rest } from 'msw';

describe('PostList Component', () => {
  it('should display posts', async () => {
    renderWithClient(<PostList />);

    expect(screen.getByText('Loading...')).toBeInTheDocument();

    await waitFor(() => {
      expect(screen.getByText('Post Title 1')).toBeInTheDocument();
    });
  });

  it('should handle errors', async () => {
    server.use(
      rest.get('/posts', (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({ message: 'Server Error' }));
      })
    );

    renderWithClient(<PostList />);

    await waitFor(() => {
      expect(screen.getByText(/error/i)).toBeInTheDocument();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

πŸ’Ž Best Practices

1. Use queryOptions Pattern

βœ… DO THIS:

// queries/users.queries.ts
export const userQueryOptions = (id: number) =>
  queryOptions({
    queryKey: ['users', id],
    queryFn: () => fetchUser(id),
  });

// Component
const { data } = useQuery(userQueryOptions(5));

// Prefetch
queryClient.prefetchQuery(userQueryOptions(5));
Enter fullscreen mode Exit fullscreen mode

❌ NOT THIS:

// Component
const { data } = useQuery({
  queryKey: ['users', id],
  queryFn: () => fetchUser(id),
});

// Different place - keys might drift!
queryClient.prefetchQuery({
  queryKey: ['user', id], // Typo! Different key
  queryFn: () => fetchUser(id),
});
Enter fullscreen mode Exit fullscreen mode

2. Structure Query Keys Hierarchically

// βœ… Good: Hierarchical, predictable
['posts']                                    // All posts
['posts', { status: 'published' }]          // Filtered posts
['posts', postId]                           // Single post
['posts', postId, 'comments']               // Post comments
['posts', postId, 'comments', commentId]    // Single comment

// ❌ Bad: Flat, unpredictable
['allPosts']
['publishedPosts']
['post-123']
['commentsForPost-123']
Enter fullscreen mode Exit fullscreen mode

3. Co-locate Query Definitions

src/
  queries/          ← All query definitions here
    users.queries.ts
    posts.queries.ts
    users.mutations.ts
    posts.mutations.ts
Enter fullscreen mode Exit fullscreen mode

4. Set Appropriate Stale Times

// Fast-changing data (stock prices, live scores)
staleTime: 0  // Always stale, always refetch

// Moderate (user feed, notifications)
staleTime: 1000 * 60  // 1 minute

// Slow-changing (user profile, settings)
staleTime: 1000 * 60 * 5  // 5 minutes

// Rarely changes (app config, translations)
staleTime: 1000 * 60 * 60 // 1 hour or Infinity
Enter fullscreen mode Exit fullscreen mode

5. Handle Loading States Properly

// βœ… Good: Different states for different scenarios
function PostList() {
  const { data, isPending, isError, isFetching } = useQuery(postsQueryOptions());

  if (isPending) return <Skeleton />; // First load
  if (isError) return <Error />;

  return (
    <div>
      {isFetching && <RefreshIndicator />} {/* Background refetch */}
      <Posts posts={data} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

6. Use Suspense for Cleaner Code

// βœ… Cleaner with Suspense
function PostDetail({ id }: { id: number }) {
  const { data: post } = useSuspenseQuery(postQueryOptions(id));
  // data is NEVER undefined!
  return <PostCard post={post} />;
}

function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <PostDetail id={5} />
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

7. Invalidate Strategically

// βœ… Invalidate related queries
mutation.onSuccess = () => {
  // Invalidate all posts queries
  queryClient.invalidateQueries({ queryKey: ['posts'] });

  // Invalidate specific post
  queryClient.invalidateQueries({ queryKey: ['posts', postId] });

  // Invalidate with filters
  queryClient.invalidateQueries({
    queryKey: ['posts'],
    predicate: query => query.state.data?.userId === userId,
  });
};
Enter fullscreen mode Exit fullscreen mode

8. Handle Optimistic Updates Correctly

// βœ… With rollback
const mutation = useMutation({
  onMutate: async (newData) => {
    await queryClient.cancelQueries({ queryKey: ['posts'] });
    const previous = queryClient.getQueryData(['posts']);

    queryClient.setQueryData(['posts'], newData);

    return { previous }; // Context for rollback
  },
  onError: (err, newData, context) => {
    queryClient.setQueryData(['posts'], context?.previous);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});
Enter fullscreen mode Exit fullscreen mode

9. Use Query Cancellation

// βœ… Cancel on unmount or new fetch
const fetchPosts = async ({ signal }: { signal: AbortSignal }) => {
  const response = await fetch('/posts', { signal });
  return response.json();
};

export const postsQueryOptions = () =>
  queryOptions({
    queryKey: ['posts'],
    queryFn: ({ signal }) => fetchPosts({ signal }),
  });
Enter fullscreen mode Exit fullscreen mode

10. Avoid Over-Fetching with select

// βœ… Only subscribe to what you need
function UserName({ userId }: { userId: number }) {
  const { data: name } = useQuery({
    ...userQueryOptions(userId),
    select: user => user.name,
  });

  return <span>{name}</span>;
}
Enter fullscreen mode Exit fullscreen mode

πŸ”„ Migration from v4

Key Breaking Changes

  1. Query keys must be arrays
// v4
queryKey: 'users'  // ❌ String not allowed

// v5
queryKey: ['users']  // βœ… Always array
Enter fullscreen mode Exit fullscreen mode
  1. cacheTime β†’ gcTime
// v4
cacheTime: 1000 * 60 * 10

// v5
gcTime: 1000 * 60 * 10
Enter fullscreen mode Exit fullscreen mode
  1. isLoading β†’ isPending
// v4
if (isLoading) return <Spinner />;

// v5
if (isPending) return <Spinner />;  // No cached data
// or
if (isLoading) return <Spinner />;  // First fetch (isPending && isFetching)
Enter fullscreen mode Exit fullscreen mode
  1. keepPreviousData β†’ placeholderData
// v4
keepPreviousData: true

// v5
import { keepPreviousData } from '@tanstack/react-query';
placeholderData: keepPreviousData
Enter fullscreen mode Exit fullscreen mode
  1. onSuccess/onError removed from queries
// v4
useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  onSuccess: (data) => toast.success('Loaded!'), // ❌ Removed
});

// v5 - Use useEffect instead
const query = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});

useEffect(() => {
  if (query.isSuccess) {
    toast.success('Loaded!');
  }
}, [query.isSuccess]);
Enter fullscreen mode Exit fullscreen mode
  1. Suspense: Use dedicated hooks
// v4
useQuery({ 
  suspense: true,  // ❌ Removed
});

// v5
useSuspenseQuery({  // βœ… Dedicated hook
  queryKey: ['users'],
  queryFn: fetchUsers,
});
Enter fullscreen mode Exit fullscreen mode

Migration Codemod

npx jscodeshift ./src \
  --extensions=ts,tsx \
  --parser=tsx \
  --transform=./node_modules/@tanstack/react-query/build/codemods/src/v5/remove-overloads/remove-overloads.js
Enter fullscreen mode Exit fullscreen mode

πŸ› Troubleshooting

Issue: Query Not Refetching

Problem:

const { data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});
// Data never updates!
Enter fullscreen mode Exit fullscreen mode

Solutions:

// 1. Check staleTime
staleTime: 0  // Make data immediately stale

// 2. Check refetch settings
refetchOnWindowFocus: true
refetchOnReconnect: true

// 3. Manual invalidation
queryClient.invalidateQueries({ queryKey: ['users'] });
Enter fullscreen mode Exit fullscreen mode

Issue: Infinite Refetching Loop

Problem:

useQuery({
  queryKey: ['users', { filter: someObject }],
  //                   ^^^ New object every render!
});
Enter fullscreen mode Exit fullscreen mode

Solution:

// Memoize objects in query keys
const filters = useMemo(() => ({ status: 'active' }), []);

useQuery({
  queryKey: ['users', filters],  // βœ… Stable reference
});
Enter fullscreen mode Exit fullscreen mode

Issue: TypeScript Errors with queryOptions

Problem:

const data = queryClient.getQueryData(userQueryOptions(5).queryKey);
// Type error: queryKey doesn't exist
Enter fullscreen mode Exit fullscreen mode

Solution:

// Add 'as const' to query keys
export const userQueryOptions = (id: number) =>
  queryOptions({
    queryKey: ['users', id] as const,  // βœ… as const
    queryFn: () => fetchUser(id),
  });
Enter fullscreen mode Exit fullscreen mode

Issue: Stale Data After Mutation

Problem:

// Created new post but list doesn't update
Enter fullscreen mode Exit fullscreen mode

Solutions:

// Option 1: Invalidate queries
onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['posts'] });
}

// Option 2: Update cache directly
onSuccess: (newPost) => {
  queryClient.setQueryData(['posts'], (old = []) => [...old, newPost]);
}

// Option 3: Refetch
onSuccess: () => {
  queryClient.refetchQueries({ queryKey: ['posts'] });
}
Enter fullscreen mode Exit fullscreen mode

πŸ“š Resources

Official Documentation

Learning Resources

Tools

Community


πŸŽ“ Key Takeaways

  1. Use queryOptions pattern for type-safe, reusable queries
  2. Set appropriate staleTime based on data volatility
  3. Leverage caching - avoid unnecessary refetches
  4. Optimistic updates for better UX
  5. Use Suspense for cleaner loading states
  6. Structure query keys hierarchically for better cache management
  7. Test with proper utilities - always wrap in QueryClientProvider
  8. Monitor with DevTools - understand what's happening
  9. Handle errors gracefully - component-level and global
  10. Keep queries co-located - maintain in dedicated files

πŸ‘¨β€πŸ’» Author

Rajat


⭐ Support

If this guide helped you:

  • Share with fellow developers
  • Follow for more content

Happy Querying! πŸš€

Made with ❀️ by Rajat

Top comments (0)