ShaharAmir
← Back to Blog
TanStack Query8 min read

TanStack Query: The Complete Guide to Server State Management

Master TanStack Query (React Query) with practical examples. Learn data fetching, caching, mutations, and real-world patterns.

S
Shahar Amir

TanStack Query: The Complete Guide to Server State Management

TanStack Query

If you've ever struggled with loading states, cache invalidation, or keeping your UI in sync with server data, TanStack Query (formerly React Query) is about to become your best friend.

Why TanStack Query?

Traditional data fetching in React looks like this:

typescript
12345678910111213
// The old way - managing everything yourself 😫
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);

With TanStack Query:

typescript
12345
// The TanStack Query way - clean and powerful ✨
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json())
});

But it's not just about less code. TanStack Query gives you:

  • Automatic caching - data is cached and shared across components
  • Background refetching - keeps data fresh without loading spinners
  • Stale-while-revalidate - show cached data while fetching updates
  • Request deduplication - multiple components, single request
  • Optimistic updates - instant UI feedback
  • Retry logic - automatic retries on failure

Getting Started

Install TanStack Query:

bash
1
npm install @tanstack/react-query

Set up the QueryClient provider:

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

const queryClient = new QueryClient();

// Simulated API
const fetchUsers = async () => {
  await new Promise(r => setTimeout(r, 1000));
  return [
    { id: 1, name: 'Alice', role: 'Developer' },
    { id: 2, name: 'Bob', role: 'Designer' },
    { id: 3, name: 'Charlie', role: 'Manager' }
  ];
};

function UserList() {
  const { data: users, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
  });

  if (isLoading) return <div className="loading">Loading users...</div>;
  if (error) return <div className="error">Error loading users</div>;

  return (
    <div className="user-list">
      <h3>👥 Team Members</h3>
      {users?.map(user => (
        <div key={user.id} className="user-card">
          <strong>{user.name}</strong>
          <span>{user.role}</span>
        </div>
      ))}
    </div>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserList />
      <style>{`
        .loading { padding: 20px; color: #666; }
        .error { padding: 20px; color: #e53e3e; }
        .user-list { padding: 16px; }
        .user-list h3 { margin-bottom: 12px; }
        .user-card {
          display: flex;
          justify-content: space-between;
          padding: 12px;
          margin: 8px 0;
          background: #f7fafc;
          border-radius: 8px;
          border: 1px solid #e2e8f0;
        }
        .user-card span { color: #718096; font-size: 14px; }
      `}</style>
    </QueryClientProvider>
  );
}

Query Keys: The Foundation

Query keys uniquely identify your data. They can be simple strings or complex arrays:

typescript
1234567891011
// Simple key
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
// With parameters
useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) });
// With filters
useQuery({
queryKey: ['todos', { status: 'completed', page: 1 }],
queryFn: () => fetchTodos({ status: 'completed', page: 1 })
});

Pro tip: Structure your keys hierarchically for easy invalidation:

typescript
12345
// All these can be invalidated with queryClient.invalidateQueries({ queryKey: ['todos'] })
['todos'] // all todos
['todos', 'list'] // todo list
['todos', 'detail', 1] // specific todo
['todos', { status: 'active' }] // filtered todos

Mutations: Changing Data

For creating, updating, or deleting data, use useMutation:

import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';

const queryClient = new QueryClient();

// Simulated database
let todos = [
  { id: 1, text: 'Learn TanStack Query', done: true },
  { id: 2, text: 'Build awesome apps', done: false }
];

const fetchTodos = async () => {
  await new Promise(r => setTimeout(r, 300));
  return [...todos];
};

const addTodo = async (text: string) => {
  await new Promise(r => setTimeout(r, 300));
  const newTodo = { id: Date.now(), text, done: false };
  todos = [...todos, newTodo];
  return newTodo;
};

const toggleTodo = async (id: number) => {
  await new Promise(r => setTimeout(r, 200));
  todos = todos.map(t => t.id === id ? { ...t, done: !t.done } : t);
  return todos.find(t => t.id === id);
};

function TodoApp() {
  const [input, setInput] = useState('');
  const queryClient = useQueryClient();

  const { data: todoList, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  });

  const addMutation = useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
      setInput('');
    }
  });

  const toggleMutation = useMutation({
    mutationFn: toggleTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (input.trim()) addMutation.mutate(input.trim());
  };

  if (isLoading) return <div>Loading...</div>;

  return (
    <div className="todo-app">
      <h3>✅ Todo List</h3>
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={e => setInput(e.target.value)}
          placeholder="Add a todo..."
          disabled={addMutation.isPending}
        />
        <button type="submit" disabled={addMutation.isPending}>
          {addMutation.isPending ? 'Adding...' : 'Add'}
        </button>
      </form>
      <ul>
        {todoList?.map(todo => (
          <li
            key={todo.id}
            onClick={() => toggleMutation.mutate(todo.id)}
            className={todo.done ? 'done' : ''}
          >
            {todo.done ? '✓' : '○'} {todo.text}
          </li>
        ))}
      </ul>
      <style>{`
        .todo-app { padding: 16px; font-family: system-ui; }
        .todo-app h3 { margin-bottom: 16px; }
        .todo-app form { display: flex; gap: 8px; margin-bottom: 16px; }
        .todo-app input { flex: 1; padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; }
        .todo-app button { padding: 8px 16px; background: #4f46e5; color: white; border: none; border-radius: 6px; cursor: pointer; }
        .todo-app button:disabled { opacity: 0.5; }
        .todo-app ul { list-style: none; padding: 0; }
        .todo-app li { padding: 12px; margin: 4px 0; background: #f7fafc; border-radius: 6px; cursor: pointer; transition: all 0.2s; }
        .todo-app li:hover { background: #edf2f7; }
        .todo-app li.done { text-decoration: line-through; color: #a0aec0; }
      `}</style>
    </div>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <TodoApp />
    </QueryClientProvider>
  );
}

Optimistic Updates

Don't make users wait! Update the UI immediately and rollback if something fails:

typescript
123456789101112131415161718192021222324252627282930
const updateTodo = useMutation({
mutationFn: updateTodoApi,
// Optimistically update before the server responds
onMutate: async (newTodo) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map(t => t.id === newTodo.id ? newTodo : t)
);
// Return context for rollback
return { previousTodos };
},
// Rollback on error
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
// Refetch after success or error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});

Dependent Queries

Sometimes queries depend on each other:

typescript
123456789101112
// First, get the user
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => getUserByEmail(email)
});
// Then, get their projects (only when user exists)
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => getProjectsByUser(user!.id),
enabled: !!user?.id // Only run when user.id exists
});

Pagination & Infinite Queries

For paginated data:

typescript
12345678910111213141516
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 1
});
// Render all pages
{data?.pages.map(page =>
page.posts.map(post => <Post key={post.id} {...post} />)
)}
// Load more button
<button onClick={() => fetchNextPage()} disabled={!hasNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>

Smart Defaults & Configuration

Configure global defaults:

typescript
1234567891011
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // Data fresh for 5 minutes
gcTime: 1000 * 60 * 30, // Keep unused data for 30 minutes
retry: 3, // Retry failed requests 3 times
refetchOnWindowFocus: true, // Refetch when tab regains focus
refetchOnReconnect: true // Refetch when network reconnects
}
}
});

Per-query configuration:

typescript
1234567
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
staleTime: Infinity, // Never consider stale (good for rarely-changing data)
refetchOnMount: false, // Don't refetch when component mounts
refetchInterval: 30000 // Poll every 30 seconds
});

DevTools

Don't forget the DevTools for debugging:

bash
1
npm install @tanstack/react-query-devtools

tsx
12345678910
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

Best Practices

1. Create Custom Hooks

typescript
123456789101112131415
// hooks/useUsers.ts
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
}
export function useUser(id: number) {
return useQuery({
queryKey: ['users', id],
queryFn: () => fetchUser(id),
enabled: !!id
});
}

2. Centralize Query Keys

typescript
123456789101112
// lib/queryKeys.ts
export const queryKeys = {
users: {
all: ['users'] as const,
detail: (id: number) => ['users', id] as const,
filtered: (filters: Filters) => ['users', filters] as const
},
posts: {
all: ['posts'] as const,
byUser: (userId: number) => ['posts', { userId }] as const
}
};

3. Handle Loading & Error States Gracefully

tsx
12345678
function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, isError, error, refetch } = useUser(userId);
if (isLoading) return <Skeleton />;
if (isError) return <ErrorCard error={error} onRetry={refetch} />;
return <Profile user={data} />;
}

TanStack Query vs Other Solutions

FeatureTanStack QuerySWRRedux + RTK Query
Caching✅ Excellent✅ Good✅ Good
DevTools✅ Built-in❌ None✅ Redux DevTools
Mutations✅ First-class⚠️ Basic✅ First-class
TypeScript✅ Excellent✅ Good✅ Excellent
Bundle Size12KB4KB25KB+
Learning CurveLowVery LowMedium

Conclusion

TanStack Query transforms how you handle server state in React. No more manual loading states, stale data, or cache invalidation headaches. It's the missing piece that makes data fetching actually enjoyable.

Start small — replace one useEffect fetch with useQuery. Once you feel the difference, you'll never go back.

---

Ready to level up? Check out the official TanStack Query docs for advanced patterns like prefetching, SSR, and testing.

#react#tanstack-query#typescript#data-fetching

Stay Updated 📬

Get the latest tips and tutorials delivered to your inbox. No spam, unsubscribe anytime.