Hooks cleaned up the component, not the data flow

Moving W93H from class lifecycles into React Hooks improved locality while leaving duplicate ownership, stale requests, and stream continuity unresolved.

My first substantial Hook conversion removed forty lines from a W93H component and made its most important bug harder to see.

The incident view loaded an initial timeline, subscribed to later events, applied filters, reflected selection in the URL, and announced updates for assistive technology. As a class, those responsibilities were scattered across lifecycle methods. React 16.8 let me group each concern in a custom Hook, and the component became an appealing table of contents:

const snapshot = useTimelineSnapshot(incidentId)
const liveEvents = useIncidentStream(incidentId)
const filters = useTimelineFilters()
useIncidentLocation(incidentId, filters)

The result looked modular. It still had two owners for timeline data, three representations of filter state, and a race when the selected incident changed quickly. The class had made the confusion verbose. Hooks made it composable.

Seven months earlier, I had adopted Hooks gradually because a new primitive would not decide state ownership for me. W93H turned that cautious sentence into a concrete failure.

The component had five kinds of state

I began by inventorying every value instead of translating methods one by one.

The incident view contained:

  1. Remote facts: the incident record, event snapshot, recovery conditions, and current revision supplied by W93H.
  2. Stream continuity: whether live delivery was connected, which revision had been observed, and whether a gap required a new snapshot.
  3. URL state: selected incident, event focus, filter query, and a few durable view options that should survive a shared link.
  4. Ephemeral interface state: an open detail disclosure, focus return target, and a transient announcement.
  5. Derived values: filtered events, grouped time intervals, visible condition counts, and labels calculated from the other state.

The class version mixed these categories in one state object. The first Hook conversion split them by mechanism: fetch, socket, URL, and local state. Neither organization answered which values had authority.

The useful split was ownership and lifetime. Remote facts belonged to one incident-timeline boundary. The URL owned only shareable navigation choices. Ephemeral interaction stayed local. Derived values were calculated. Stream continuity was part of remote-fact ownership because it determined whether those facts were trustworthy.

Once the categories were named, several state variables stopped deserving storage.

Derived state had become a miniature propagation system

The converted component stored raw events, filtered events, grouped rows, and an event count. Effects kept them aligned:

useEffect(() => {
  setFilteredEvents(applyFilters(events, filters))
}, [events, filters])
 
useEffect(() => {
  setRows(groupByDisplayInterval(filteredEvents))
}, [filteredEvents])
 
useEffect(() => {
  setVisibleCount(rows.length)
}, [rows])

Every value could be calculated from the one before it. Storing all four created intermediate renders where the chain described different moments. After a filter changed, the UI could briefly show the new control state with the old rows. The live-region effect could announce a count that was one render behind.

I had used effects as a propagation engine because the code looked explicit. The explicitness was misleading; the actual data flow lived in effect scheduling rather than in one calculation.

The replacement was ordinary render-time derivation:

const filteredEvents = applyFilters(timeline.events, filters)
const rows = groupByDisplayInterval(filteredEvents)
const visibleCount = rows.length

I measured before adding memoization. Filtering the bounded incident event set was cheap. Grouping corrected time intervals was more expensive for large synthetic scenarios, so I memoized that calculation using the events and time-projection version as inputs. The memo was a performance choice, not a second owner.

Removing the effects eliminated more code than converting the class had. More importantly, the visible rows could no longer lag behind the state that defined them.

Snapshot and stream were not independent data sources

useTimelineSnapshot fetched the current incident. useIncidentStream appended later events. The component combined them:

const events = [...snapshot.events, ...liveEvents]

That line contained several unanswered questions. What if an event appeared in the snapshot and stream? What if the stream connected before the snapshot completed? What if revision 45 arrived after revision 47? What if the stream disconnected long enough to lose events? What if a corrected snapshot removed or superseded an earlier projection?

The two Hooks exposed data as if concatenation were the composition rule. They actually represented two phases of one continuity protocol.

W93H snapshots carried a revision. Stream events carried the revision they produced and the preceding revision they expected. The state owner needed to establish a snapshot, accept only a continuous sequence after it, deduplicate stable event IDs, and mark itself stale when continuity broke.

I replaced the pair with useIncidentTimeline. Internally it still used HTTP and a stream, but callers received one state machine:

type TimelineState =
  | { status: 'loading'; incidentId: string }
  | { status: 'ready'; incidentId: string; revision: number; events: Event[] }
  | {
      status: 'stale'
      incidentId: string
      revision: number
      events: Event[]
      reason: 'stream-gap' | 'reconnect' | 'projection-change'
    }
  | { status: 'failed'; incidentId: string; message: string }

The component no longer knew whether data came from a snapshot or stream. It knew whether the timeline was current enough to display as live.

A reducer made continuity testable

The timeline state had legal transitions that were awkward to express through independent setters. A reducer received protocol events:

type TimelineEvent =
  | { type: 'SELECT'; incidentId: string }
  | { type: 'SNAPSHOT'; incidentId: string; revision: number; events: Event[] }
  | { type: 'APPEND'; incidentId: string; from: number; to: number; event: Event }
  | { type: 'GAP'; incidentId: string; expected: number; received: number }
  | { type: 'FAIL'; incidentId: string; message: string }

The reducer rejected data for an incident other than the currently selected one. A snapshot could replace loading or stale state only if its identity matched. An append had to name the current revision as from; otherwise the state became stale and requested a new snapshot. Duplicate event identities did not produce duplicate rows.

This was not a global store or an argument that every component needed a reducer. It was a local protocol with states and transitions worth naming.

Pure reducer tests could replay the difficult sequences without a browser:

  • Snapshot 12, append 13, duplicate append 13.
  • Snapshot 12, append 14 with a missing revision.
  • Select B while a snapshot for A remains in flight.
  • Reconnect with local revision 18 and server revision 22.
  • Projection-version change while source events remain the same.

The Hook coordinated effects around that reducer. React managed rendering; the reducer managed the domain transition rules.

Cleanup did not settle request order

The first fetch effect used a familiar flag:

useEffect(() => {
  let active = true
 
  fetchTimeline(incidentId).then((result) => {
    if (active) setSnapshot(result)
  })
 
  return () => {
    active = false
  }
}, [incidentId])

This prevented a result from updating after the effect had been cleaned up. It did not make the request itself stop, and it did not encode result identity in the domain state. A refactor that moved the setter elsewhere could reintroduce the race.

The failure appeared when I selected incident A, then B, while A's request was slow. B loaded first. A returned later. Depending on where the activity flag lived, the older snapshot could replace the newer one or update a cache under an ambiguous key.

I gave every request an explicit incident identity and a request token. The reducer accepted the result only if both matched current state. Where the request path supported AbortController, cleanup also aborted superseded fetches to reduce wasted work.

Cancellation was an optimization and a signal to cooperative code. Identity was the correctness rule. A response already delivered, a cache callback, or a transport without cancellation still had to be rejected if obsolete.

That distinction survived beyond React: stop work when possible, but never make correctness depend on stopping every old message.

The subscription Hook returned a cleanup function that closed the prior connection when incidentId changed. I initially considered that sufficient isolation.

A message could already be queued in the browser event loop. A reconnect timer could fire near cleanup. The server could have sent an event before observing the close. The old callback might run after the component had selected another incident.

Every message already carried incident ID and revision. The reducer validated both. Cleanup still closed the connection promptly, but a late callback became harmless input rather than an impossible event.

I also separated connection state from timeline truth. connected did not mean current. A stream could be connected after losing revisions. disconnected did not mean the visible snapshot was false; it meant the snapshot was no longer advancing and needed a stale marker after a bounded interval.

On reconnect, the client sent its last accepted revision. The server could replay a bounded gap or instruct it to fetch a new snapshot. The UI kept existing events visible with an explicit stale state instead of clearing the page into a spinner.

T04P had taught me in 2013 that motion was not freshness. Hooks did not change the protocol. They gave the protocol a better local home once I stopped treating a socket as a second array of data.

URL state had three owners

The filter query existed in React state, the URL search parameters, and browser history. The first conversion synchronized them through two effects:

useEffect(() => setFilters(readFilters(location)), [location])
useEffect(() => writeFilters(history, filters), [history, filters])

This loop needed flags to distinguish an update read from the URL from one that should be written back. Default-value normalization changed object identity and could produce redundant history entries. The initial render briefly used default filters before the effect read the URL.

I decided that shareable filter state belonged to the location. The component derived filters from the current location during render. User actions produced a new location. Browser navigation naturally produced a new render through the routing boundary.

Ephemeral choices that did not belong in a shared link stayed in local state and were not synchronized at all. The selected disclosure row, for example, became a fragment only when deep linking justified it; hover and focus never did.

The boundary eliminated the bidirectional mirror. There was one authoritative representation and an explicit command to change it.

Not every external store could be read this simply, and React did not offer a dedicated primitive for every external-source case. For this bounded router integration, a small subscription at the routing boundary and parsed values passed down kept the contract understandable.

Custom Hooks had hidden a distributed owner

The early names followed mechanisms: useFetch, useSocket, useQueryParams, and useAnnouncements. Each Hook was tidy. Their combination asked the component to be the real integration layer.

That arrangement was attractive because small utilities looked reusable. In practice, the meaningful invariants crossed them. Snapshot revision governed stream acceptance. URL selection governed request identity. Timeline freshness governed which announcement was accurate. The domain did not respect the mechanism boundaries.

I moved toward domain Hooks whose public interface matched an owned responsibility:

const timeline = useIncidentTimeline(incidentId)
const filters = useIncidentFilters(location)
const view = deriveTimelineView(timeline, filters, timeProjection)
useTimelineAnnouncement(view.announcement)

Transport helpers remained reusable inside useIncidentTimeline, but the component could not accidentally combine the snapshot from one owner with the stream from another.

The custom Hook name was not proof of good encapsulation. I inspected what state it owned, what protocol it enforced, and whether two instances could make conflicting claims about the same domain.

The best extraction made an invariant harder to violate. A merely convenient extraction moved code behind a friendly function call.

Dependencies revealed responsibility leaks

The exhaustive-dependencies lint rule found effects that captured values without declaring them. My first response was mechanical: add the missing dependency, then wrap functions in useCallback when the effect restarted too often.

W93H showed why that could become memoization theater. One subscription effect depended on incident ID, filter object, time projection, announcement preferences, dispatch, and a logging callback. Stabilizing all those identities would not make them one coherent subscription concern.

The effect was doing too much. Stream subscription depended on incident ID and the dispatch boundary. Filtering and time projection belonged to render derivation. Announcements observed the resulting view. Logging belonged at protocol transitions rather than every render callback.

After separating responsibilities, the dependency list became small because the design had become small:

useEffect(() => {
  return stream.subscribe(incidentId, dispatch)
}, [stream, incidentId, dispatch])

I did not suppress the rule or pretend an empty array meant “once.” An effect belongs to a particular render and its captured values. If honest dependencies caused harmful repetition, I reconsidered the effect's boundary before reaching for stable wrappers.

Memoization remained useful where identity was part of a child performance contract or a measured calculation. It stopped serving as adhesive for unrelated ownership.

Loading booleans allowed impossible combinations

The converted component exposed loading, error, events, connected, and stale as independent values. Nothing prevented this state:

loading = false
error = null
events = undefined
connected = true
stale = true

The UI accumulated branches to interpret combinations that should never exist.

The discriminated timeline state made representable conditions explicit. Loading did not have events. Ready did. Stale retained the last coherent event set and a reason. Failed could optionally evolve later to include a last-known snapshot, but the choice would be in the type rather than an accidental leftover value.

The view component switched on the status. TypeScript helped ensure every case had a product decision: show a skeleton only before the first snapshot, show stale data with its last revision during a gap, or show a recoverable failure with a retry action.

This did more for correctness than reducing lifecycle boilerplate. The state model removed combinations instead of teaching the render function to survive them.

The correction was not “effects are bad.” W93H needed to synchronize with systems outside React.

The timeline Hook established and cleaned up a stream subscription. The routing boundary listened to browser history. The announcement Hook scheduled live-region updates with cancellation so a rapid event burst did not produce an unusable queue of speech. A focus effect moved focus after an incident switch when the triggering interaction required it.

Each effect named an external relationship and the render values that identified it. Cleanup described what became obsolete. Domain state handled messages that could still arrive despite cleanup.

I separated effects whose timing requirements differed. Focus that needed to occur before a visible paint used the appropriate layout timing cautiously. Network subscriptions and announcements used ordinary effects. I did not reach for layout effects merely to make code run sooner; blocking paint was a cost.

The narrower role made effects easier to review. The question became “what external system is being synchronized, and what invalidates this synchronization?” If the answer was “one React value should resemble another,” I usually removed stored state instead.

The first Hook tests rendered each custom Hook in isolation and asserted its state updates. They passed while the integrated component still raced.

After changing ownership, tests followed the contracts:

  • Reducer tests replayed snapshot, stream, gap, selection, and failure transitions.
  • Protocol tests used a controllable server boundary to vary response and message order.
  • Component tests changed incidents rapidly and verified that obsolete data never appeared.
  • Browser tests exercised history navigation, shared filter links, keyboard focus, stale-state presentation, and live announcements.
  • Canary incidents verified the deployed snapshot-to-stream path across real schema versions.

I retained focused tests for transport helpers, but success there no longer stood in for data-flow correctness.

One regression test selected A, delayed its response, selected B, delivered B, then delivered A. The expected result was not merely “no warning.” The UI remained on B, the reducer ignored A's obsolete token, and the diagnostic event recorded the discarded response.

Another test created a stream gap. Existing rows stayed visible, the stale label appeared, a fresh snapshot restored continuity, and the interface did not announce late events twice.

The tests became stories about ownership under reordering rather than demonstrations that useEffect had executed.

The class version had not been innocent

It would have been easy to blame Hooks for the bugs. The class already stored derived rows, combined a fetch with socket updates, mirrored URL state, and accepted late responses. Lifecycle methods spread the responsibilities far enough apart that their overlap was difficult to see.

Hooks improved locality. That improvement exposed some mistakes and made other mistakes easier to package. A custom Hook could place setup beside cleanup while still giving two modules authority over the same timeline. A dependency array could make captured inputs visible while the effect itself remained unnecessary.

I left stable class components in J05N alone unless nearby work justified conversion. W93H's correction was not a mandate to finish a syntax migration. It was a review method that applied to both models:

  • Identify the authoritative owner.
  • Separate remote, shareable, ephemeral, and derived state.
  • Model asynchronous identity and ordering explicitly.
  • Represent legal domain states rather than boolean combinations.
  • Use effects for external synchronization.
  • Make stale or unknown state visible.

A class could satisfy those conditions. A function with Hooks could violate all of them.

My early adoption notes had asked what started an effect, which inputs defined its identity, and how obsolete work was cleaned up. Those questions remained useful. W93H added a preceding one: should this boundary be performing the work independently at all?

Both the snapshot Hook and stream Hook had defensible setup, cleanup, and dependencies. Reviewing them separately would not reveal that their outputs formed one revision protocol. The defect existed between two locally respectable abstractions.

I added an ownership sketch to substantial state changes. It named the authoritative source for each durable value, the projections derived from it, the commands allowed to change it, and the boundary that reconciled asynchronous messages. The sketch was usually a few lines, not an architecture document. Its purpose was to reveal two arrows pointing toward the same state.

Code review also began from a user-visible sequence rather than a Hook inventory: select an incident, receive a snapshot, lose the stream, navigate elsewhere, receive an obsolete response, reconnect. Walking that sequence forced the abstractions to meet. A component could look elegant in a static render and fail only when time rearranged its messages.

The added question made the checklist less React-specific and more durable: local correctness does not establish coherent ownership across boundaries.

The final component was shorter for a better reason

After the redesign, the incident component became smaller again:

function IncidentTimelinePage({ incidentId, location }: Props) {
  const timeline = useIncidentTimeline(incidentId)
  const filters = useIncidentFilters(location)
  const view = deriveTimelineView(timeline, filters)
 
  useTimelineAnnouncement(view.announcement)
 
  return <IncidentTimeline view={view} />
}

The important reduction was not line count. The component no longer reconciled two data owners or synchronized representations through effects. Each value had a defined source, and the timeline Hook enforced continuity before exposing remote facts as current.

The code still carried complexity because the problem contained complexity. Reconnection, late events, clock projection, and URL navigation did not disappear. They moved into boundaries where their invariants could be named and tested.

Hooks had fulfilled their promise: related stateful behavior could be organized and reused without wrapper components or split lifecycle methods. The failure had come from asking that organizational improvement to settle architecture automatically.

Cleaner syntax is valuable. Clear ownership is what made the syntax tell the truth.