Skip to content

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-query
tsx
// 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:

  • queryKey identity = cache bucket (include params: ['todos', { status }])
  • Same key in two components → one network request (dedupe) 🙌
  • isPending / isError replace 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
  • invalidateQueries marks 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 habitBetter habit
Pinia action fetchTodos into state.todosuseQuery({ queryKey: ['todos'], … })
Manual loading flags everywhereisPending / isFetching
After POST, manually push into arrayinvalidateQueries (or optimistic update)

Next: a light styling note, then testing.

Best,

-- ł.