~/writing/client-state-vs-server-state

you're using useState wrong. not because of the syntax.

2026-04-11·7 min read

FrontendArchitectureReact

At some point in most React codebases, there's a file with a hook that's grown to 200 lines. It has a useEffect that fetches. Another that refetches when something changes. A loading boolean. An error boolean. Maybe a lastUpdated ref someone added because the data was going stale. A useCallback wrapping the mutation function because it kept causing infinite loops.

Nobody planned for it to get that big. It just... accreted.

The root cause, almost every time: someone used useState to manage data that has a server-side lifecycle. Not because they didn't know what they were doing — but because React never told you there were two fundamentally different categories of state, and they need different solutions.


the distinction nobody teaches early enough

UI state is local, ephemeral, and owned entirely by the client. Whether a modal is open. Which tab is active. What's typed in a search field before the user hits enter. This state has no representation anywhere else. If the user closes the tab, it's gone, and that's correct.

Server state is a local copy of something that lives elsewhere. A list of users. An invoice. A product record. It has a URL. It can be fetched, mutated, and invalidated. Critically — it can be changed by someone else while your user is looking at it.

These are different problems. The first is a simple variable with a setter. The second is a cache synchronisation problem.

Most tutorials teach you useState + useEffect + axios for both, because it works well enough to finish the tutorial. In production, it works well enough until it doesn't — and when it breaks, it breaks in ways that are surprisingly hard to debug.


what "works fine" looks like before it doesn't

The pattern is familiar:

const [persons, setPersons] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
 
useEffect(() => {
  setLoading(true)
  axios
    .get('/api/persons')
    .then(response => {
      setPersons(response.data)
      setLoading(false)
    })
    .catch(err => {
      setError(err)
      setLoading(false)
    })
}, [])

This is fine. Write this for a learning project, write this for a small internal tool, nobody is going to stop you. But notice what you've already built: a loading state machine, an error state machine, and a cache of one — and you haven't handled any of the actually hard parts yet.

What happens when this component unmounts mid-fetch? (setLoading(false) runs on an unmounted component — React will warn you, and you'll add a cleanup flag.)

What happens when two components on the same page both need this data? (They both fetch independently, or you lift state up, or you reach for Context, or you discover that Context re-renders everything on every update.)

What happens when you mutate and need the list to reflect the change? (You either refetch everything, or you manually splice the local array and pray it matches what the server now has.)

Each of these is solvable. Engineers solve them every day. The question is why you're solving them by hand when the problem space is well-understood enough that it has dedicated libraries.


the same code with the right tool

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import axios from 'axios'
 
const fetchPersons = () => axios.get('/api/persons').then(r => r.data)
 
const App = () => {
  const queryClient = useQueryClient()
 
  const { data: persons, isLoading, error } = useQuery({
    queryKey: ['persons'],
    queryFn: fetchPersons,
  })
 
  const addPerson = useMutation({
    mutationFn: (newPerson) => axios.post('/api/persons', newPerson),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['persons'] })
    },
  })
 
  if (isLoading) return <div>loading...</div>
  if (error) return <div>something went wrong</div>
 
  return (
    // ...
  )
}

The loading and error states are gone from your component because the library manages them. The "use response.data not your local object" footgun is gone because invalidating the query just refetches from the server — your UI always reflects the actual server state. The unmount cleanup is handled. The deduplication across components is handled — if two components query ['persons'] simultaneously, one request goes out.

What you get for free on top of that: background refetching when the user re-focuses the tab, configurable stale times, optimistic updates with automatic rollback if the mutation fails, devtools that show you exactly what's in the cache.

This isn't a small wrapper around useEffect. It's a different mental model: your component subscribes to server data rather than owning it.


when this actually matters

If you're building a toy project, a portfolio piece, or working through a course — the useState + useEffect pattern is fine and arguably better, because understanding what the library is doing for you requires understanding what you'd have to do without it.

But if you're working on a real product and you have a growing useEffect with multiple dependencies, a proliferating number of loading/error booleans, or you find yourself manually keeping two components' data in sync — you're not dealing with a code quality problem. You're dealing with a category error. You've reached for a UI state primitive to solve a cache synchronisation problem.

The fix isn't to write better useEffect logic. It's to use something built for the actual problem.


TanStack Query is the most common choice. SWR is lighter if you don't need mutations. If you're on a GraphQL stack, Apollo Client has the same model baked in. The specific library matters less than internalising the distinction: UI state and server state are not the same problem, and conflating them is where the complexity quietly comes from.

Once that clicks, a lot of previously mysterious React bugs start making sense retroactively.

← back to writing~/writing/client-state-vs-server-state