Appearance
Fetching data the hard way (so the easy way makes sense)
You can load data with
useEffect+useState. You will also reinvent caching, races, and retries. I've done it. You've probably done it in Vue too.
The minimal pattern
tsx
type Todo = { id: number; title: string }
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([])
const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle')
useEffect(() => {
const controller = new AbortController()
setStatus('loading')
fetch('/api/todos', { signal: controller.signal })
.then((r) => {
if (!r.ok) throw new Error('bad status')
return r.json()
})
.then((data: Todo[]) => {
setTodos(data)
setStatus('idle')
})
.catch((err) => {
if (err.name === 'AbortError') return
setStatus('error')
})
return () => controller.abort()
}, [])
if (status === 'loading') return <p>Loading…</p>
if (status === 'error') return <p>Could not load.</p>
return (
<ul>
{todos.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
)
}Comments for the code above:
- Abort on unmount / re-run - avoids setting state after goodbye
- Three pieces of state already (data, status, and soon: pagination, filters…)
- No shared cache - open the same page twice, fetch twice
What gets messy fast
- Deduping identical requests across components
- Stale data after a mutation (create todo → list still old)
- Retries / backoff
- Keeping "is fetching" vs "is showing cached data" straight
- Prefetching on hover / route
In Vue land you might have stuffed some of this into Pinia. Same trap - a client store is not a server-state cache.
Where I'd put what
| Kind of state | Tool |
|---|---|
| Is the sidebar open? | useState / Zustand |
| Who is logged in? (client session) | Context / Zustand |
| List of todos from the API | TanStack Query (next chapter) |
| Form draft before submit | useState |
As you can see - splitting server state from client state is the unlock. Next chapters: Zustand for client global state, TanStack Query for server cache.
Best,
-- ł.