Hooks arrived; ownership did not

React 16.8 offered a better way to compose stateful behavior, but adopting it safely still required deliberate state boundaries and gradual evidence.

React 16.8 made an old source of friction feel suddenly optional.

Before Hooks, a stateful concern that needed lifecycle behavior usually lived in a class component, a render-prop component, or a higher-order component. Each approach could be sound. Composing several of them could also leave a small piece of interface wrapped in layers whose main purpose was moving stateful behavior into reusable shapes.

Hooks promised something more direct: use state and other React features from a function component, then extract related behavior into custom Hooks without adding another visible component to the tree. The examples were concise enough to make an immediate rewrite feel rational.

I did not rewrite J05N or the new W93H interface. I converted one bounded component, wrote down what became clearer, and delayed the larger decision until the code had survived ordinary changes.

The hesitation was not distrust of the release. Hooks were stable in React 16.8, and the React team explicitly supported gradual adoption. The hesitation came from a different question: if syntax for stateful composition had improved, had I also improved the ownership of the state?

The answer was not contained in the API.

The ceremony had been doing several jobs

The first candidate was a filter panel in J05N's component catalogue. It stored a text query, synchronized selected categories with the URL, measured the result region for a compact layout, and restored focus after a mobile disclosure closed.

The class version was not elegant. Initialization occurred in the constructor, subscriptions in componentDidMount, updates in componentDidUpdate, cleanup in componentWillUnmount, and event handlers throughout the class. One concern could be spread across four methods because lifecycle stage, rather than purpose, determined where the code belonged.

That scattering was a real cost. Adding URL synchronization required remembering how it interacted with initial state, browser navigation, and cleanup. The class made related lines distant.

Yet the ceremony had also made timing visible. A method named componentWillUnmount was an unmistakable place to inspect cleanup. A state update was visibly attached to an instance. Removing ceremony could improve locality while also making repeated synchronization effortless to add.

I wanted the conversion to preserve the lifecycle questions even when the lifecycle method names disappeared:

  • What starts this work?
  • Which values define its identity?
  • What makes its result obsolete?
  • How is it stopped or ignored?
  • Which state is owned here, and which is derived?

Hooks changed how those answers were expressed. They did not make the questions obsolete.

The first conversion was intentionally boring

I chose a component without server data, streaming updates, or shared mutable state. Its local query and disclosure state mapped directly to useState. A layout measurement used useLayoutEffect because the result affected what could be painted without a visible jump. Browser history synchronization used useEffect because it coordinated with an external system after rendering.

The initial function looked smaller:

function FilterPanel({ categories }: Props) {
  const [query, setQuery] = useState('')
  const [open, setOpen] = useState(false)
  const selected = useCategoriesFromLocation()
  const results = filterCategories(categories, query, selected)
 
  useRestoreFocus({ open, triggerRef })
  useMeasuredLayout({ containerRef, resultCount: results.length })
 
  return <FilterPanelView {...viewProps} />
}

The reduction in lines was pleasant and not my acceptance criterion. I looked for whether each concern could be read as one unit, whether cleanup remained adjacent to setup, and whether the component exposed fewer incidental layers in React's tree.

It did. The custom Hooks also became testable through small host components and the browser behaviors they controlled. I did not yet attempt to abstract a general useEverything layer or convert the rest of the catalogue.

One successful conversion proved that the API could improve this component. It did not prove that every class represented debt.

Custom Hooks reused behavior, not state

The phrase “share stateful logic” was easy to misunderstand. Two components calling the same custom Hook do not automatically share one state instance. Each call participates in the state of its own component.

That property was useful in J05N. useRestoreFocus could encode the same focus contract for a dialog and a disclosure without creating a global focus manager. Each caller supplied its own trigger and open state. The shared part was the behavior and its invariants, not a hidden singleton.

I wrote the boundary explicitly:

type RestoreFocusOptions = {
  wasOpen: boolean
  isOpen: boolean
  trigger: React.RefObject<HTMLElement>
}

The Hook did not decide whether a component should be open. It observed a transition supplied by the owner and performed the external focus operation when the contract required it.

My first draft instead let the Hook own open, expose toggle, capture the trigger, and listen for Escape. It seemed conveniently reusable and quietly swallowed the component's interaction model. A dialog and a disclosure had different dismissal rules, initial focus behavior, and background constraints. The abstract Hook made those differences options until its signature became a small framework.

I narrowed it to one reason to exist. Custom Hooks made extraction easy; good extraction still required a coherent responsibility.

Rules of Hooks were structural, not stylistic

Hooks had two constraints that looked simple: call them only at the top level, and call them only from React function components or custom Hooks. The constraints protected the ordering React used to associate calls with state across renders.

I added the Hooks lint rules before converting more components. Waiting for a bug would have been a poor way to learn that a conditional return had shifted state associations.

The lint rule also changed how I modeled optional behavior. This was invalid in spirit as well as syntax:

if (syncToUrl) {
  useUrlFilters(filters)
}

The stable structure was to call the Hook and let its effect decide whether external synchronization was active:

useUrlFilters({ enabled: syncToUrl, filters })

That did not mean burying every branch inside a Hook. If two modes represented genuinely different components, separating the components could make the structure clearer. The rule pushed conditionality into explicit inputs or component boundaries instead of letting runtime control flow rearrange state machinery.

I treated violations as architectural feedback. The goal was not merely to appease a linter; it was to make the component's state structure stable enough for React and a reader to follow.

Effects were for synchronization with something external

The broadest early temptation was to use an effect whenever one value should change after another.

If a query changed, an effect could calculate filtered results and place them in state. If a category changed, another effect could clear the current page. If the result count changed, a third effect could build the status message.

That style recreated an event system inside the component. Each render triggered effects, effects scheduled updates, and intermediate renders represented combinations that had never been meaningful. The dependency arrays became a fragile graph of propagation.

I adopted a narrower working definition: an effect synchronizes React with something React does not own. Browser history, a subscription, a timer, imperative focus, or a third-party widget could justify an effect. A value that could be calculated from props and state usually could not.

The filter results stayed a render-time calculation:

const results = filterCategories(categories, query, selected)
const status = describeResults(results.length, query)

When a new query meant the current page should return to the first page, I modeled the page transition in the event that changed the query rather than waiting for an effect to notice afterward. For more complicated transitions, a reducer could express the relationship directly.

This was a guideline rather than a slogan. Some synchronization between React state and an external source still needed careful effects. The important distinction was that Hooks had made effects available, not mandatory.

Dependency arrays exposed captured time

An effect's function sees the props and state from the render that created it. That simple fact explained several bugs I had previously described vaguely as “stale callbacks.”

A timer established once with an empty dependency array would keep using the values captured by that first render unless its logic read from another stable mechanism. Adding every referenced value could restart the timer on each change. Omitting values could preserve an old world indefinitely.

The dependency array was not a manual scheduling wish list. It described which reactive inputs the effect used. If including the honest dependencies caused unacceptable behavior, the design of the effect needed reconsideration.

For the result announcement, I initially delayed a live-region message with a timer so typing did not cause a burst of speech:

useEffect(() => {
  const timer = window.setTimeout(() => {
    setAnnouncement(describeResults(results.length, query))
  }, 250)
 
  return () => window.clearTimeout(timer)
}, [results.length, query])

The cleanup was not incidental. It meant a new query invalidated the prior pending announcement. The dependencies made that identity legible.

Where dependency lists became large, I did not immediately wrap every function in useCallback. I checked whether the effect joined too many responsibilities or whether an unstable object had been constructed at the wrong boundary. Memoization could stabilize identity; it could not explain ownership for me.

Cleanup described invalidation, not only unmounting

Class lifecycle vocabulary had encouraged me to think of cleanup mainly as unmount behavior. Effects also clean up before they run again because a dependency changed.

That made setup and invalidation adjacent:

useEffect(() => {
  const unsubscribe = history.listen(onLocationChange)
  return unsubscribe
}, [history, onLocationChange])

If history or the callback identity changed, the old subscription was removed before the new one was established. The cleanup answered which external relationship belonged to this render.

I tested that sequence deliberately. React development behavior and future changes could make fragile setup apparent, but the contract should not depend on a subscription leaking harmlessly. A custom Hook needed to tolerate establishment, cleanup, and re-establishment without accumulating listeners.

Timers, document event listeners, media-query listeners, and observers all gained the same review question: what exact work becomes obsolete when these dependencies change?

That wording was more useful than “does it clean up on unmount?” It connected lifecycle to identity.

A reducer could name transitions without becoming a store

The filter component's two booleans and text value did not require a reducer. Another J05N example—a multistep disclosure with validation—did.

Several useState calls allowed combinations the interface should never display: submitting while closed, showing a success message beside unresolved validation errors, or restoring focus before the submission result had arrived. A reducer encoded events and legal transitions in one place.

type Event =
  | { type: 'OPEN' }
  | { type: 'EDIT'; field: string; value: string }
  | { type: 'SUBMIT' }
  | { type: 'SUCCEED' }
  | { type: 'FAIL'; errors: Errors }
  | { type: 'CLOSE' }

Using useReducer did not make the state globally managed, and it did not require a library. It simply gave a local domain a vocabulary of transitions.

I kept side effects outside the reducer. The reducer was deterministic: given prior state and an event, it returned next state. Submission remained an external operation coordinated at the boundary. That separation made transition tests straightforward and stopped the reducer from becoming a hidden command bus.

Hooks made the local reducer easy to colocate. The design value came from naming the events, not from replacing one state API with another.

Function components and Hooks made consuming React context convenient. It was tempting to place a large application state object in one provider and expose custom Hooks for every field.

I avoided that move in the first adoption. Context answered how a value could be made available below a tree boundary. It did not answer who should own the value, how frequently it should change, or which consumers should update together.

J05N's theme and stable interaction configuration fit context because they had clear tree scope and relatively infrequent transitions. The filter query did not need to become global simply because several descendants used it; the filter panel could own it and pass a focused interface downward.

For W93H, which would soon combine snapshots, streams, filters, and URL state, I deferred the decision. A convenient useW93H() Hook returning an entire mutable application object would have hidden more than it revealed.

The API had lowered the mechanical cost of access. It had not lowered the cognitive cost of shared state.

The class tests had sometimes reached into instances, called methods, or asserted intermediate state. Function components removed those escape hatches. That was mostly an improvement.

I exercised the filter panel through rendered controls, keyboard interaction, URL changes, and focus outcomes. The tests described what a person or browser observed rather than which lifecycle method had run.

Custom Hooks with pure state transitions kept a narrower seam. Reducers and calculation functions were tested directly because their contracts did not depend on React rendering. Hooks that coordinated browser APIs were tested through a small host component in an environment that could provide the real API or a faithful boundary double.

I avoided snapshotting the entire component tree as evidence of correctness. Removing render-prop wrappers would make such snapshots change dramatically while behavior stayed the same. The conversion itself demonstrated why structural output was a noisy proxy for interaction contracts.

The test suite became less interested in whether code looked like a Hook implementation and more interested in whether the focus, URL, announcement, and transition rules still held.

“Use Hooks for new code” was easy. Deciding when to convert an existing class required more discipline.

I used three reasons:

  1. Nearby work already required substantial change, and the conversion made that change easier to reason about.
  2. Stateful behavior had a clear reuse case that existing patterns expressed awkwardly.
  3. A lifecycle split was actively causing defects or preventing a coherent test.

Line count, novelty, and visual inconsistency were not sufficient reasons. Stable class components could remain classes. A mixed codebase was less harmful than a migration that changed behavior and abstraction at once without product value.

Each conversion stayed in its own change when practical. That made it possible to compare behavior and revert the structural change independently of a feature. I tracked warnings, bundle output, interaction tests, and browser behavior rather than declaring success when TypeScript compiled.

The stopping rule protected the archive from becoming an API fashion project. J05N existed to preserve interaction contracts across applications, not to make every source file resemble the latest documentation.

After several bounded conversions, the benefits were clear.

Related setup and cleanup could live together. Reusable stateful behavior no longer needed wrapper components that obscured the rendered tree. Function components could remain the default as requirements grew instead of being rewritten into classes. Small domain reducers and focused custom Hooks made some state machines easier to read and test.

Hooks also created new failure shapes. Effects could proliferate. Dependency mistakes could preserve stale values or repeat external work. A custom Hook could conceal a sprawling state owner behind a friendly name. Context consumption could spread shared updates widely. None of these invalidated the model; they defined the discipline adoption required.

My initial conclusion was deliberately limited: React 16.8 gave me a better set of primitives for organizing stateful behavior. It did not decide the boundaries of the product, the identity of asynchronous work, or the source of truth for data.

Seven months later, a W93H component would make that limitation much harder to ignore. Its Hook conversion removed visible ceremony while preserving two timeline owners and a request race. That was not evidence that the February adoption had failed. It was evidence that the original caution had been correct and still insufficient.

A new primitive does not inherit the old decisions

I had expected the most important migration work to be translating lifecycle methods into effects. The more valuable work was deciding which lifecycle behavior should exist at all.

Some derived state disappeared. Some subscriptions moved behind narrow domain boundaries. Some class components stayed untouched. Some reusable behavior became simpler without becoming global. The codebase did not emerge uniformly modern, but its state decisions became more explicit.

Hooks arrived with a persuasive answer to how React code could compose stateful behavior. Ownership remained a question about the application: which boundary has authority, which values are independent, which work is current, and which states are legal.

No import could answer that. The API gave me better sentences. I still had to decide what the system was trying to say.