TanStack Query 14 ⚛️ Custom Hooks — Abstracting query and mutation logic
A custom hook is a function whose name starts with use and that calls one or more React hooks inside it. In TanStack Query, the custom hook is the pattern that encapsulates the query key, the query function, and the options behind a named interface. The component that consumes the hook does not need to know the key, the options, or the structure of the response; it receives the data, the loading state, and the error state, and it renders them. The previous chapters used custom hooks incidentally—useProfile, useOrders, useTodo—without examining the pattern. This chapter examines the pattern itself: why it exists, how to write it, and the pitfalls that make a custom hook behave differently from an inline useQuery.
The pattern is not unique to TanStack Query. It is a React convention that applies to any hook, and the TanStack Query documentation recommends it because it centralizes the cache keys and the query functions . The hook is defined next to the API module or in a dedicated hooks directory, and the component imports the hook instead of calling useQuery directly. The result is a codebase where the cache keys are defined in one place, the query functions are tested independently, and the components are simpler to read.
Key point: A custom hook must call the TanStack Query hook unconditionally, at the top level, and it must not call it inside a condition, a loop, or a callback. The hook’s return value should be the result of the TanStack Query hook, or a well-defined subset of it, so that the component can destructure the properties it needs. The query key and the query function must be stable references, because a new function on every render can cause the query to refetch or to lose its cache entry.
Why custom hooks exist
The key-duplication problem.
A component that calls useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) defines the key inline. A second component that needs the same data defines the same key again, and a third component defines a slightly different key by mistake—['todo'] instead of ['todos']. The cache entries are separate, and the invalidation from a mutation only reaches one of them. The custom hook defines the key once, and every component that uses the hook shares the same key . The hook is the single source of truth for the cache entry.
The query-function problem.
The query function is often an API call that is used in multiple places. Defining it inline in the component means it is recreated on every render, which is harmless for the query’s execution but adds noise to the component. Extracting it to a module and calling it from the hook makes it testable in isolation and reusable across hooks. The hook is the composition point: it combines the key, the function, and the options into a single unit.
The component-complexity problem.
A component that calls useQuery directly must handle the three states—isPending, isError, isSuccess—and destructure the data and the error. The same three-state handling is repeated in every component that fetches data. The custom hook can return the raw result, and the component destructures the properties it needs, or the hook can return a curated object with the data and the flags that the component actually uses. The second form is more opinionated but reduces the boilerplate in the component.
a. A basic query hook
The simplest custom hook wraps useQuery and returns its result. The hook defines the key and the function, and the component calls the hook and destructures the result.
// hooks/useTodos.ts
import { useQuery } from '@tanstack/react-query'
export interface Todo {
id: number
title: string
completed: boolean
}
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
The useTodos hook encapsulates the key and the function. The component imports the hook and calls it, and the hook returns the full result object.
import { useTodos } from '../hooks/useTodos'
function TodoList() {
const { data, isPending, isError, error } = useTodos()
if (isPending) return <p>Loading...</p>
if (isError) return <p>Error: {error.message}</p>
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
The component does not know the key or the function. It knows that useTodos returns a result with data, isPending, isError, and error, and it renders accordingly . The hook is reusable in any component that needs the todo list, and the cache entry is shared because the key is the same.
The hook can also accept parameters and include them in the key.
export function useTodo(id: number) {
return useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
enabled: !!id,
})
}
The useTodo hook accepts an id, includes it in the key, and uses it in the function. The enabled option defers the fetch until the ID is defined, which is the dependent-query pattern from an earlier chapter . The key changes when the ID changes, so the component fetches the new todo and the cache entry is distinct for each ID.
b. A mutation hook with invalidation
The mutation hook wraps useMutation and includes the invalidation logic in the onSuccess callback. The component calls the hook and uses the mutate function, and the hook takes care of the cache update.
// hooks/useAddTodo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
async function addTodo(title: string): Promise<Todo> {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
The useAddTodo hook owns the mutation function and the invalidation. The component calls mutate and does not need to know that the ['todos'] query is invalidated after success . The hook is the single place where the cache-sync logic lives, and every component that adds a todo uses the same hook.
import { useAddTodo } from '../hooks/useAddTodo'
function AddTodoForm() {
const [title, setTitle] = useState('')
const mutation = useAddTodo()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
mutation.mutate(title, {
onSuccess: () => setTitle(''),
})
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button type="submit" disabled={mutation.isPending}>Add</button>
</form>
)
}
The component uses the mutation’s isPending flag to disable the button and the mutate function to trigger the write. The invalidation is hidden inside the hook, and the component does not need the useQueryClient call . The per-call onSuccess callback clears the input, which is a component-specific side effect that does not belong in the hook.
c. The return-value shape and the options passthrough
The hook can return the raw result of useQuery or a curated object. The raw result is flexible but exposes the entire API surface; the curated object is simpler but less flexible.
// Raw result: the component destructures what it needs
export function useTodos() {
return useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
}
// Curated result: only the data and the flags
export function useTodos() {
const { data, isPending, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return { todos: data, isPending, isError, error }
}
The raw result is the recommended default because it preserves the full API, including refetch, isFetching, and the other properties that a component may need later. The curated object is appropriate when the hook’s interface should be stable and the component should not depend on the TanStack Query result shape . A common compromise is to spread the result and add the renamed data.
export function useTodos() {
const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
return { ...query, todos: query.data }
}
The spread preserves the original properties, and the todos alias provides a more readable name. The component can use either data or todos, and the hook remains compatible with the TanStack Query API .
The hook can also accept a subset of the query options and pass them through, which allows the component to override the defaults without losing the key and function.
export function useTodos(options?: { staleTime?: number; enabled?: boolean }) {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
...options,
})
}
The ...options spread allows the component to set staleTime or enabled without redefining the key or the function . The pattern is useful for hooks that are used in different contexts with different freshness requirements, but it should be used sparingly because it dilutes the hook’s encapsulation.
Complete Example Session
// ============================================
// PART 1: THE API MODULE
// ============================================
export interface Todo {
id: number
title: string
completed: boolean
}
export async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function fetchTodo(id: number): Promise<Todo> {
const res = await fetch(`/api/todos/${id}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function addTodo(title: string): Promise<Todo> {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
// ============================================
// PART 2: THE QUERY HOOKS
// ============================================
import { useQuery } from '@tanstack/react-query'
export function useTodos() {
return useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
}
export function useTodo(id: number) {
return useQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id), enabled: !!id })
}
// ============================================
// PART 3: THE MUTATION HOOK
// ============================================
import { useMutation, useQueryClient } from '@tanstack/react-query'
export function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: addTodo,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
}
// ============================================
// PART 4: THE COMPONENT
// ============================================
import { useState } from 'react'
import { useTodos, useAddTodo } from '../hooks/todos'
function TodoPage() {
const { data, isPending, isError, error } = useTodos()
const mutation = useAddTodo()
const [title, setTitle] = useState('')
if (isPending) return <p>Loading...</p>
if (isError) return <p>Error: {error.message}</p>
return (
<div>
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button
onClick={() => mutation.mutate(title, { onSuccess: () => setTitle('') })}
disabled={mutation.isPending}
>
Add
</button>
</div>
)
}
The four parts demonstrate the custom-hook pattern: the API module, the query hooks, the mutation hook, and the component. The critical separation is between Parts 2 and 3 and Part 4: the hooks own the key, the function, and the invalidation, and the component owns only the rendering and the local UI state.
Quick Reference
Query Hook Pattern
| Element | Code |
|---|---|
| Hook name | use<Entity> |
| Key | ['entity'] or ['entity', id] |
| Function | Imported from an API module |
| Return | The useQuery result |
Mutation Hook Pattern
| Element | Code |
|---|---|
| Hook name | use<Verb><Entity> |
| Mutation function | Imported from an API module |
| Invalidation | queryClient.invalidateQueries in onSuccess |
| Return | The useMutation result |
Return-Value Shapes
| Shape | Example | Trade-off |
|---|---|---|
| Raw | return useQuery(...) | Full API, less abstraction |
| Curated | return { todos, isPending } | Simpler, less flexible |
| Spread | return { ...query, todos: query.data } | Both |
Best Practices
✅ Do This:
// Name the hook with the use prefix
export function useTodos() { ... } # ✅
// Define the key in one place
queryKey: ['todos'] # ✅
// Import the query function from an API module
queryFn: fetchTodos # ✅
// Return the full result
return useQuery({ ... }) # ✅
// Include the invalidation in the mutation hook
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }) # ✅
// Accept options for flexibility
export function useTodos(options = {}) { ... } # ✅
❌ Don’t Do This:
// Call useQuery inside a condition
if (condition) { useQuery(...) } // Rules-of-hooks violation # ❌
// Define the query function inline in the hook body
function useTodos() {
return useQuery({ queryFn: () => fetch('/api/todos') }) # ❌
}
// Return only the data and lose the flags
return data // The component cannot render the loading state # ❌
// Duplicate the key in multiple hooks
export function useTodos() { queryKey: ['todos'] }
export function useTodoCount() { queryKey: ['todos'] } # ❌
// Put component-specific side effects in the hook
onSuccess: () => setTitle('') // The hook does not own the state # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Rules-of-hooks error | Hook called conditionally | Call it unconditionally |
| Cache entry not shared | Key duplicated or mistyped | Define the key in the hook |
| Component cannot show loading | Only the data is returned | Return the full result |
| Invalidation missed | Mutation hook does not invalidate | Add the invalidation |
| Hook recreated on every render | Function defined inline | Import from a module |
| Side effects in the hook | Component state referenced | Keep side effects in the component |
Real-World Examples
1. Basic query hook
export function useTodos() { return useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) }
2. Parameterized hook
export function useTodo(id: number) { return useQuery({ queryKey: ['todo', id], queryFn: () => fetchTodo(id), enabled: !!id }) }
3. Mutation hook
export function useAddTodo() {
const qc = useQueryClient()
return useMutation({ mutationFn: addTodo, onSuccess: () => qc.invalidateQueries({ queryKey: ['todos'] }) })
}
4. Curated return
const q = useQuery(...); return { todos: q.data, isPending: q.isPending }
5. Spread return
const q = useQuery(...); return { ...q, todos: q.data }
6. Options passthrough
export function useTodos(options = {}) { return useQuery({ queryKey: ['todos'], queryFn: fetchTodos, ...options }) }
7. Dependent hook
export function useOrders(orgId: number) { return useQuery({ queryKey: ['orders', orgId], queryFn: () => fetchOrders(orgId), enabled: !!orgId }) }
8. Infinite hook
export function useFeed() { return useInfiniteQuery({ queryKey: ['feed'], queryFn: fetchFeed, initialPageParam: 0, getNextPageParam: (last) => last.nextCursor }) }
9. Hook with select
export function useTodoCount() { return useQuery({ queryKey: ['todos'], queryFn: fetchTodos, select: (data) => data.length }) }
10. Test the hook
const { result } = renderHook(() => useTodos(), { wrapper: QueryClientProvider })
Visual
Hook Composition
┌──────────────────────────────────────────────────────────────┐
│ THE HOOK OWNS THE KEY, FUNCTION, AND OPTIONS │
│ │
│ hooks/useTodos.ts │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ export function useTodos() { │ │
│ │ return useQuery({ │ │
│ │ queryKey: ['todos'], │ │
│ │ queryFn: fetchTodos, │ │
│ │ }) │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Component │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ const { data, isPending, isError } = useTodos() │ │
│ │ → does not know the key or the function │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Query Hook and Mutation Hook
┌──────────────────────────────────────────────────────────────┐
│ QUERY HOOKS READ; MUTATION HOOKS WRITE AND INVALIDATE │
│ │
│ useTodos: │
│ └── useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) │
│ │
│ useAddTodo: │
│ ├── useMutation({ mutationFn: addTodo }) │
│ └── onSuccess → invalidateQueries({ queryKey: ['todos'] }) │
│ │
│ The mutation hook owns the invalidation, │
│ and the query hook owns the read. │
└──────────────────────────────────────────────────────────────┘
Return-Value Shapes
┌──────────────────────────────────────────────────────────────┐
│ THREE WAYS TO RETURN │
│ │
│ Raw: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ return useQuery(...) │ │
│ │ → component destructures data, isPending, isError, ... │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Curated: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ return { todos, isPending, isError } │ │
│ │ → component uses the named fields │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Spread: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ return { ...query, todos: query.data } │ │
│ │ → component uses either data or todos │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Hook name prefix | use |
| Query hook | Wraps useQuery with key and function |
| Mutation hook | Wraps useMutation with invalidation |
| Key location | Defined once in the hook |
| Function location | Imported from an API module |
| Return shape | Raw, curated, or spread |
| Invalidation | onSuccess in the mutation hook |
| Options passthrough | Spread the options argument |
| Test wrapper | QueryClientProvider in renderHook |
Key takeaways:
- The custom hook centralizes the key and the function. The cache key is defined once, so every component that uses the hook shares the same cache entry, and the invalidation reaches every component. The query function is imported from an API module and tested independently .
- The mutation hook owns the invalidation. The
onSuccesscallback callsqueryClient.invalidateQueries, so the components that trigger the mutation do not need to know about the cache. The hook is the single place where the write and the cache update are coordinated. - The hook must call
useQueryoruseMutationunconditionally. The call cannot be inside a condition, a loop, or a callback, because the hooks must run in the same order on every render. The parameter that varies is passed to the hook as an argument, not used to decide whether to call the hook . - The return value can be raw, curated, or spread. The raw result is the most flexible, the curated object is the most stable, and the spread is a compromise. The choice depends on how much of the TanStack Query API the component needs and how much abstraction the hook should provide .
- The hook can accept options and pass them through. The
...optionsspread allows the component to override the defaults without redefining the key or the function. The pattern is useful for hooks that are used in different contexts with different freshness requirements. - The hook should not own component state. The per-call
onSuccesscallback and the local input state belong to the component, not the hook. The hook owns the query or mutation logic, and the component owns the UI state and the side effects.
Remember: A custom hook is the pattern that encapsulates the query key, the query function, and the options behind a named interface. The query hook wraps useQuery and returns the result, and the mutation hook wraps useMutation and includes the invalidation. The hook must be called unconditionally, the key must be defined once, and the function must be imported from an API module. The return value can be raw, curated, or spread, and the options can be passed through for flexibility. The component uses the hook and renders the data, and it does not need to know the key or the function. The result is a codebase where the cache keys are consistent, the query functions are testable, and the components are simpler to read.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!