This series has spent several posts taking things away from the browser.

Rendering moved to the server. Data fetching moved to the server. Mutations moved to the server, and post 7 established that they come back as a fresh tree rather than as JSON you have to merge yourself.

So what's left?

Less than you'd guess, and — the more useful part — not the things you'd guess.


useState is a default, not a decision

Most of us learned React in an order that made useState mean "state." It was the first hook you saw, it was in every tutorial, and everything that changed over time went in one.

That was reasonable when the browser owned everything. It isn't now.

State has four possible owners, and useState is one of them. Picking the wrong owner doesn't usually produce an error — it produces an app that's subtly wrong in ways nobody files a bug about. A filter that vanishes on refresh. A link that doesn't reproduce what the sender was looking at. Two values that disagree.


Owner 1: the server

Your data. Posts, users, prices, orders. It lives in a database and the server can read it.

If you're holding server data in useState, you have built a cache. Not metaphorically — an actual copy of remote data, in memory, on one machine, and now you own every hard problem that comes with one. When does it go stale? What happens if two tabs disagree? What refreshes it after a mutation?

Post 7 answered that last one: nothing needs to, because the mutation response already contains the new tree. The moment you copy that data into useState, you've opted out of the mechanism and taken the job back.

// ✗ a cache with no invalidation strategy
'use client'
function Posts() {
  const [posts, setPosts] = useState([])
  useEffect(() => { fetch('/api/posts').then(r => r.json()).then(setPosts) }, [])
}
// ✓ the server owns it
async function Posts() {
  const posts = await db.posts.findMany()
  return <PostList posts={posts} />
}

The second version has no loading state, no error state, no stale data and no effect. Those weren't features you gave up. They were costs you were paying for a copy you didn't need.


Owner 2: the URL

Anything that should survive a refresh, be shareable, or work with the back button.

Filters. Sort order. Pagination. Search queries. Which tab is active. Sometimes which item is selected.

The test is simple: if a user sent this page to a colleague, should the colleague see the same thing? If yes, it belongs in the URL. Put it in useState and you've built an app where the address bar lies.

// ✗ the URL says /products regardless of what you're looking at
const [category, setCategory] = useState('all')
// ✓ the URL is the state
const params = await searchParams
const category = params.category ?? 'all'

Back and forward work for free, because they were always going to — you just weren't using them.

Two honest costs. In an App Router app, changing a search param is a navigation, so it round-trips to the server unless you deliberately avoid that. And searchParams is a Promise in Next 16, which makes the segment dynamic. Neither is a reason to keep filters in local state; both are reasons to know what you're choosing.


Owner 3: the form

The DOM already stores what's in your inputs. It's been doing that since 1995.

// ✗ React re-implementing what the input already does
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
<input value={email} onChange={e => setEmail(e.target.value)} />
// ✓ read it when you need it
<form action={signIn}>
  <input name="email" />
  <input name="password" type="password" />
</form>

Post 7 covered where the values go — FormData, into the action. And useActionState holds the result, so the outcome of the submit isn't your state either.

Controlled inputs aren't wrong; they're just not the default. Reach for them when you need something during typing: live validation, a field that depends on another field, formatting as the user types, a character counter. If nothing needs to happen until submit, nothing needs to be in React.


Owner 4: ephemeral UI state

This is the real useState territory, and it's smaller than the space useState currently occupies.

Is the dropdown open. Is the tooltip showing. Which row is hovered. Is the accordion expanded.

Three properties, all of which have to hold:

  • Meaningless outside this component

  • Nobody should be able to link to it

  • It should disappear on refresh

That's it. That's what local state is for.


Where does this state actually live?

Worth being concrete, because it explains several things you already rely on without thinking.

React keeps a fiber for every component instance — a plain JavaScript object holding what React knows about that component. Attached to it is a linked list of hook records, and useState stores its value in one of those.

So state is an object on the JavaScript heap, in that tab's process. RAM, and nothing more durable.

Which is where its properties come from:

  • Refresh and it's gone. New heap, new fibers, initial values.

  • Two tabs have two separate states. Separate processes, separate heaps. They can't see each other and never could.

  • Nothing is persisted anywhere unless you wrote code to persist it.

  • It doesn't exist on the server. Post 3 covered the SSR pass — useState returns its initial value and nothing updates it, because there's no interaction and no heap that survives the request.

Context lives the same way — on the provider's fiber, and consumers read it by walking up the tree to find it. How a change to that value propagates is a Context question specifically, and it's covered where Context is.

The audit

Take a real client component and ask, for each piece of state, who should own it.

'use client'
function ProductBrowser() {
  const [products, setProducts] = useState([])
  const [category, setCategory] = useState('all')
  const [query, setQuery] = useState('')
  const [sortBy, setSortBy] = useState('newest')
  const [filtersOpen, setFiltersOpen] = useState(false)

  useEffect(() => {
    fetch(`/api/products?category=${category}&q=${query}&sort=${sortBy}`)
      .then(r => r.json())
      .then(setProducts)
  }, [category, query, sortBy])
}

Five pieces of state. One of them belongs here.

  • products — server-owned

  • category, query, sortBy — URL-owned, all three

  • filtersOpen — ephemeral, correctly placed

Re-file the other four and the component stops being a client component at all, except for the small piece that opens a panel. The effect disappears. The loading state disappears. The URL becomes honest.


Two things that aren't state

Derived values. If you can compute it from state you already have, computing it is not an optimization — it's a correctness requirement.

// ✗ two sources of truth that can disagree
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
// ✓ one source of truth
const [items, setItems] = useState([])
const total = items.reduce((sum, i) => sum + i.price, 0)

The first version has a bug waiting in it: any code path that updates items without updating total. The second version cannot have that bug. Post 2 covered why the recomputation almost certainly doesn't matter.

Values that change without affecting the screen. A timer ID, a previous value you're comparing against, a DOM node, a flag saying something already ran.

If reading it doesn't change what's rendered, it's a ref. useState for these means re-rendering to store a value nobody displays.


useReducer, for state that moves together

When several values must change as a unit, separate useState calls let them drift into combinations that should be impossible — loading and error both true, data present while still loading.

A reducer makes the transitions atomic and gives them names. dispatch({ type: 'submit_failed' }) says what happened; three setState calls in a row say what you did about it.

This isn't Redux-in-miniature. There's no store and nothing is global. It's the same local state with the transitions written down.


Context is not a state manager

Context is dependency injection for the tree. It answers "how does something deep down get a value from up high without threading it through ten components."

It's good at values that rarely change: theme, locale, a configured client, the current user. It's bad at values that change often, because every consumer re-renders when the value changes — and that's not a tuning problem, it's what Context does.

One RSC-specific point worth having straight, because people get it backwards.

A Context provider has to be a Client Component. That leads to a common fear: putting a provider near the root makes your whole app client-side.

It doesn't — for the reason post 5 gave. The provider's children arrive as children, which is a prop, not an import. No import edge, no path into the client graph.

// app/layout.tsx — still a Server Component
export default function Layout({ children }) {
  return <ThemeProvider>{children}</ThemeProvider>
}

ThemeProvider is client. Everything inside children is whatever it was already. The boundary doesn't spread downward through props — only through imports.

Does a context change re-render the whole tree?

No — but the useful answer is more precise than that, because two different mechanisms get blamed for the same symptom.

One: the provider re-rendered. If the provider component's own state changed, it re-renders, and its children re-render as a normal parent-child consequence. This has nothing to do with context specifically. It's avoidable the usual ways — passing children through as a prop, or memoizing.

Two: the context value changed. Now every component calling useContext for that context re-renders. Not the whole subtree — only consumers. But here's the part that matters: memo does not stop this. Context propagation deliberately bypasses memoization, because a memoized component that ignored a context change would render stale data.

That second mechanism is the real argument against putting frequently-changing values in Context. It isn't that it re-renders too much — it's that consumers have no way to opt out. A value that changes on every keystroke re-renders every consumer on every keystroke, and no amount of memoization at the call site will help. If that's the situation you're in, the value doesn't belong in Context; it belongs in a store built for granular subscription — which is exactly the question the next post takes up.


useSyncExternalStore, the honest escape hatch

Sometimes a value lives outside React and React needs to read it: online/offline status, a media query, the contents of localStorage, a WebSocket, a store from a library.

These have something in common — nobody told React they changed. React finds out only if something explicitly tells it.

The obvious approach is an effect that subscribes and calls setState:

// ✗
const [isOnline, setIsOnline] = useState(true)
useEffect(() => {
  const on = () => setIsOnline(true)
  const off = () => setIsOnline(false)
  window.addEventListener('online', on)
  window.addEventListener('offline', off)
  return () => { /* remove both */ }
}, [])

This mostly works, and it fails in two specific ways.

Tearing. React can render in pieces, pausing between components. If the external value changes during a render pass, components rendered before the change read the old value and components rendered after read the new one. One frame, two answers, from the same source. That's tearing, and it's invisible in testing and maddening in production.

Server rendering. window doesn't exist on the server. So the initial value is a guess — here, true — and if the guess is wrong, hydration mismatches.

The built-in solves both:

const isOnline = useSyncExternalStore(
  (callback) => {
    window.addEventListener('online', callback)
    window.addEventListener('offline', callback)
    return () => {
      window.removeEventListener('online', callback)
      window.removeEventListener('offline', callback)
    }
  },
  () => navigator.onLine,   // read the current value in the browser
  () => true                // read it during server render
)

Three arguments, and each maps to one of the problems:

  • subscribe — how React gets told the value changed. It hands you a callback; you call it whenever the source changes.

  • getSnapshot — how React reads the current value right now, synchronously. Because it's a synchronous read rather than a copy in state, React can force every component in a pass to see the same value. That's the tearing fix.

  • getServerSnapshot — what the value is during server rendering, where navigator doesn't exist. This is the argument that confuses people: it isn't doing anything with your server. It's answering "what should this be when there's no browser," so the SSR pass and hydration agree.

Worth knowing for two reasons. It's the correct primitive for browser APIs, which is most of what people reach for effects to do. And it's what modern state libraries subscribe through — so when you look at one, you're largely looking at ergonomics layered over this.

Post 9 comes back to this. Most of what people write effects for is this problem, and this hook is the answer to it.


So what's actually left?

Run the audit across a normal CRUD application and the answer is: surprisingly little, and almost none of it global.

Data is the server's. Anything shareable is the URL's. Form values are the form's. What remains is a scatter of small, local, ephemeral flags — open, hovered, expanded — that were never global to begin with.

That's not an argument that global client state is unnecessary. It's an argument that most of what people put in a global store was never client state at all, and that once you stop miscategorising, the pile is much smaller than the tooling around it suggests.

But it isn't zero. Some applications genuinely have a lot of client state, with complicated transitions between pieces of it — editors, canvases, dashboards, anything with undo/redo, anything that works offline. For those, "just use useState" stops being an answer and a store becomes a real question.

That question deserves its own post: why a store has one of them rather than many, what problem the single-store design was actually solving, and how to tell whether you have that problem. Next time.


If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.