Appearance
TanStack Query (server state without reinventing it)
Caching, deduping, background refetch, mutations that invalidate the right keys. This is the piece I missed most when I first did React "from scratch".
Install + provider
bash
npm install @tanstack/react-querytsx
// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
)useQuery
tsx
import { useQuery } from '@tanstack/react-query'
type Todo = { id: number; title: string }
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error('Failed')
return res.json()
}
function TodoList() {
const { data, isPending, isError, error, refetch } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
if (isPending) return <p>Loading…</p>
if (isError) return <p>{error.message}</p>
return (
<>
<button type="button" onClick={() => refetch()}>Refresh</button>
<ul>
{data.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
</>
)
}Comments for the code above:
queryKeyidentity = cache bucket (include params:['todos', { status }])- Same key in two components → one network request (dedupe) 🙌
isPending/isErrorreplace a lot of handmade status enums
Params in the key
tsx
function User({ id }: { id: string }) {
const { data } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})
// …
}As you can see - when id changes, you automatically talk to a different cache entry.
useMutation + invalidate
tsx
import { useMutation, useQueryClient } from '@tanstack/react-query'
function AddTodo() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (title: string) =>
fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}).then((r) => {
if (!r.ok) throw new Error('nope')
return r.json()
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
return (
<button
type="button"
disabled={mutation.isPending}
onClick={() => mutation.mutate('New todo')}
>
Add
</button>
)
}Comments for the code above:
- Mutation writes to the server
invalidateQueriesmarks the list stale → refetch → UI catches up- Optimistic updates exist too - learn them when the list feels laggy
staleTime (stop refetch spam)
By default, Query treats data as stale immediately and may refetch on focus/remount. For relatively stable data:
tsx
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 60_000, // 1 minute
})Or set defaults on QueryClient.
Vue brain → Query brain
| Old habit | Better habit |
|---|---|
Pinia action fetchTodos into state.todos | useQuery({ queryKey: ['todos'], … }) |
Manual loading flags everywhere | isPending / isFetching |
| After POST, manually push into array | invalidateQueries (or optimistic update) |
Next: a light styling note, then testing.
Best,
-- ł.