New React docs made the old mental model visible
The 2023 React documentation launch exposed how often I had used effects for derived state and taught lifecycle-shaped habits after Hooks changed the model.
The new React documentation did not change an API in P6X4. It changed the names I had been using for familiar mistakes.
By March 2023, I had written Hooks for years. I used function components, reducers, memoization, transitions, Suspense, and server rendering. I still explained many components through the lifecycle of an imagined class: this effect runs after mount, this one responds to updates, and this cleanup stands in for unmount.
The Hooks-first documentation made that mental translation visible. It emphasized rendering as calculation, state as a snapshot, events as interactions, and effects as synchronization with external systems.
I audited K81R's early interface and found effects doing work that was neither external nor synchronization.
The first effect repaired state I had duplicated
The search form stored query, normalizedQuery, and isExact as separate state. An effect recalculated the latter two whenever query changed:
useEffect(() => {
setNormalizedQuery(normalize(query))
setIsExact(looksLikeIdentifier(query))
}, [query])The component rendered once with old derived values, then the effect queued another render. Tests occasionally observed the mismatch.
Both values were pure calculations from current props and state. I moved them into render:
const normalizedQuery = normalize(query)
const isExact = looksLikeIdentifier(query)When normalization became expensive under a large alias table, I measured it before adding memoization. The main correction was conceptual: derived data did not need a synchronization mechanism.
Effects had become a general “after” hook
Several components used effects because work should happen “after the user does something.”
Selecting a passage set state; an effect watched selection and logged a review event. Clicking Generate set a flag; an effect watched the flag and submitted the evidence set. Copying a citation changed copiedId; an effect wrote to the clipboard.
These were event consequences. Keeping them in the event handler made the causal relationship explicit and prevented unrelated state restoration or remounting from repeating them.
Effects were appropriate for synchronizing with external systems whose desired state followed rendering: a browser event subscription, an imperative visualization, or a connection tied to visible scope. A user action did not need to be rediscovered by observing state.
The distinction reduced both code and duplicate behavior.
K81R's result component incremented a diagnostic counter while rendering. It seemed harmless because the counter lived in an external debug store. Concurrent rendering could prepare, abandon, and repeat renders, making the count a record of scheduler behavior rather than visible results.
I moved observation to explicit boundaries. Server retrieval recorded a query result event when the operation completed. Client visibility metrics, where justified, used an intersection observer subscription with stable identity and deduplication.
The render function calculated UI from inputs. It did not announce that the UI had definitely been committed or seen.
React's ability to call render more than once was not the reason purity became correct. It made the existing category error easier to observe.
State was a snapshot, not a mutable local variable
One generation handler set the selected evidence and immediately read selection to construct a request. It sent the prior snapshot.
I had known this behavior and still wrote code as though a setter changed the variable already in scope.
The fix was to construct the next value and use it deliberately:
const nextSelection = addPassage(selection, passage)
setSelection(nextSelection)
submitEvidence(nextSelection)In the real interface I separated selection from generation so the combined handler disappeared. The example remained a useful test of the mental model: each render sees one snapshot; event code closes over that snapshot.
Functional updates handled changes based on prior state. They did not make the current closure mutate.
Reducers made transitions visible
The evidence-set editor had several booleans: isSaving, isDirty, hasConflict, isGenerating, and hasError. Combinations existed that made no sense.
I moved the local editor state into a reducer with events such as:
passage-addedcontext-expandedsave-requestedsave-acceptedsave-conflictedgeneration-requested
The reducer did not own server workflow truth. It described local interaction and mapped accepted server results into state. Durable evidence-set revisions still lived on the server.
Reducer transitions made impossible combinations testable and reduced effects that tried to keep booleans in sync.
The documentation's emphasis on extracting state logic helped me see a component as a state transition system rather than a collection of lifecycle reactions.
When the selected document changed, an effect reset the open heading, citation draft, and passage expansion. The component first rendered the new document with the old local state, then reset after commit.
I considered whether the state should be preserved across documents. For selection tied to document identity, it should not. Giving the editor subtree a document-revision key let React treat it as a different instance. For a filter that should persist, I lifted or retained it intentionally.
The effect had concealed an identity decision. Keys and component position expressed that decision directly.
I stopped using “reset on prop change” as a reflex and asked which entity the state belonged to.
Event subscriptions needed current logic without resubscription churn
The keyboard-navigation effect depended on the entire result array and selection. It removed and added a global listener on every result update.
I narrowed the external synchronization and moved result-specific logic into stable component event flow. The listener handled only global shortcuts that truly belonged at the window boundary. Where it needed current state, I used a ref updated during a controlled path rather than reconstructing every subscription casually.
More importantly, ordinary arrow navigation moved onto the focused result list itself. The DOM and component already owned the event; a global effect had been unnecessary.
The best dependency array fix was often deleting the effect's responsibility.
Cleanup was symmetry, not an unmount callback
I had described cleanup as “what runs when the component unmounts.” It also runs before an effect resynchronizes after dependencies change, and development checks can expose missing symmetry.
For a K81R source-preview observer, setup registered the exact element and callback; cleanup unregistered the same pair. For a document connection experiment, setup opened a scoped connection and cleanup closed that connection. Neither assumed one mount over the application's lifetime.
A useful test mounted, changed scope, unmounted, and mounted again. At every step there was exactly one active subscription for the current scope and no leaked resource.
Thinking in setup/cleanup cycles produced stronger code than narrating birth and death of a component.
Fetching in effects exposed route waterfalls
The early client interface mounted a result page, fetched document metadata in one effect, then mounted a preview that fetched the passage in another. The network graph reflected component order rather than data dependency planning.
I moved route-level reads to the server-first boundary being evaluated in Next.js and passed supported views into interactive clients. Where client fetching remained appropriate, a data layer handled caching, cancellation, deduplication, and race conditions instead of repeating ad hoc effects.
The change was not “effects are bad.” Fetching is synchronization with an external system. The question was whether a component effect was the best architecture for a route's critical data.
K81R's evidence pages benefited from loading authority and source revision before the client subtree appeared.
An effect that synchronized an imperative code viewer depended on source, language, theme, selection, lineNumbers, and a callback recreated each render. The linter's dependency warnings felt noisy.
I separated responsibilities. Viewer creation synchronized the external instance with its container. Source and language updated through explicit adapter methods. Selection belonged to React state and an event bridge. Theme came from a stable external preference subscription.
After decomposition, each effect had a small, explainable dependency set. I did not suppress the linter to preserve a mental model the code contradicted.
Dependency pain often indicated that one effect was pretending several processes were one.
Memoization followed measurement
The audit initially replaced effects with calculations, and I reflexively wrapped each calculation in useMemo. That recreated complexity through another mechanism.
Most normalization and result-label calculations were cheap. I left them direct. I memoized expensive parsing only after profiling and when inputs had stable identity. I also checked whether moving work to ingestion or the server eliminated repeated client calculation more effectively.
Memoization was a performance tool, not the default form of derived state. Correctness could not depend on the cache remaining.
The simpler render code was easier to read and usually fast enough.
I extracted a useCorpusGeneration Hook for subscribing to the active index generation. The value changed outside React and several components needed a consistent snapshot.
The Hook owned subscription setup, cleanup, and snapshot reading. Consumers did not know which browser channel or event source supplied updates. Tests could replace the external store.
I did not create useSearchFormLogic merely to move a large component elsewhere. Reuse followed a coherent external relationship, not a desire to hide ordinary calculations.
A custom Hook could package a mental model. It should make that model clearer.
Server and client boundaries sharpened effect use
In a Server Component, browser effects do not exist. Data loading and secure source access happen on the server; interactive synchronization begins only in client components.
This made each client boundary a budget. Adding one for a small interaction also brought its state, effects, and dependencies into the browser. I kept source rendering and evidence metadata server-side and used client components for selection, filtering, and copy behavior.
The separation did not make server code automatically pure or secure. It made browser synchronization an explicit capability rather than the default location for all application logic.
The new React mental model aligned with P6X4's broader work: choose the smallest boundary that expresses the actual responsibility.
Development behavior that repeated setup exposed hidden assumptions. Instead of disabling the check, I converted failures into lifecycle scenarios.
One effect registered a shortcut with an inline callback and attempted to remove a different callback during cleanup. Another started a timer and cleared only the latest ID. A third sent an observational event without a stable identity.
The scenarios asserted no duplicate listener, no live timer after cleanup, and no consequential external action merely from remounting. Fixes made production behavior safer under navigation and future rendering changes too.
The framework check did not invent the leak. It made a fragile effect meet a less forgiving schedule.
Teaching changed the code review vocabulary
I rewrote my own review prompts:
- Can this value be calculated during render?
- Did a user event cause this work directly?
- Which external system is being synchronized?
- Is setup symmetrical with cleanup?
- Which entity owns this state?
- Would remounting or interruption duplicate a consequence?
- Does the route need a data architecture rather than a fetch effect?
These questions were more precise than “Is the dependency array correct?”
They also made reviews less framework-mystical. The code either derived, handled an event, or synchronized an external system. Ambiguous cases deserved design work.
The documentation became an architecture input
Documentation is often treated as reference for API syntax. The 2023 React site acted more like a model of the system.
Its structure and examples made old lifecycle language harder to carry unconsciously into Hooks. Reading it did not automatically fix P6X4; the audit, scenarios, and measurements did. It supplied better categories for seeing what the code was doing.
I recorded the documentation launch as part of the archive because it changed practice without a migration or new dependency.
The audit removed more than it added
K81R lost derived-state effects, flag-sync effects, reset effects, global event listeners, and client fetch waterfalls. It gained one reducer, a few explicit event handlers, narrower external subscriptions, and server-first route data.
The interface behaved more predictably under rapid queries, document changes, and remounting. Tests asserted state transitions and external synchronization rather than render timing.
No benchmark percentage captured the improvement. The code could explain why each remaining effect existed.
The old mental model had been functional
Lifecycle-shaped thinking had produced working software. It was not foolish. It had also encouraged me to see effects as scheduled callbacks after render and state as mutable over time inside a component.
Hooks and concurrent rendering rewarded a different framing: render calculates a snapshot; events describe interactions; effects keep external systems aligned; identity determines state preservation.
The new docs made the gap between those models visible enough to audit.
The valuable change in 2023 was not learning another Hook. It was deleting code that existed only because I had asked React to repair state after rendering it incorrectly.
K81R stored query filters in component state and used one effect to push them into the URL. Another effect watched navigation and copied URL values back into state. A timing guard tried to prevent the two directions from looping.
I decided which source was authoritative. Shareable search scope—query, project, date, and status—belonged to the route. The component derived its initial and navigated view from route state. Ephemeral UI details such as an open filter popover stayed local.
Submitting a filter change created a navigation event with the next URL. No effect observed state and tried to synchronize it afterward.
This improved back-button behavior and removed a race that could restore an old filter after a rapid query.
External stores needed consistent snapshots
The active corpus generation changed outside React when ingestion completed. My first Hook subscribed in an effect and set local state from a callback. A change between render and effect setup could be missed.
I used the external-store pattern designed for reading a current snapshot and subscribing consistently. The server snapshot was explicit so hydration did not invent a different generation. Components reacted to one coherent identity rather than each maintaining its own effect and timestamp.
This was a legitimate synchronization problem. The newer mental model did not eliminate effects or subscriptions; it made the external boundary and consistency requirement explicit.
A search navigation used a transition to keep the input responsive. The component displayed isPending as “Indexing.” That label was wrong: React was preparing a render, while the server might already have completed retrieval, and corpus ingestion was an entirely different process.
I renamed the local state “Updating results” and sourced “Indexing corpus” from the durable ingestion record. Generation and evidence saving had their own accepted server states.
This prevented renderer scheduling from becoming product vocabulary. Concurrency remained behind the interface, as the 2022 audit had required.
Form state had an acceptance boundary
The evidence-set editor optimistically added a passage and used an effect to persist whenever local selection changed. Rapid additions created overlapping saves and unclear conflict handling.
I made editing and acceptance explicit. Local changes updated the draft reducer. A bounded save event sent expected revision, operations, and stable request identity. The server returned a new evidence-set revision or a conflict that the UI could reconcile.
Autosave could still trigger after idle time, but it called the same command protocol and displayed local-versus-accepted state. The effect scheduled an event; it did not define what saving meant.
The distinction made offline and retry behavior possible later without treating component state as authority.
Tests stopped counting renders
Several old tests asserted that a component rendered twice or that an effect fired once after mount. Those counts described an implementation schedule and broke under development checks or batching.
I replaced them with outcomes: derived labels never disagreed with the query, one active external subscription existed for the current corpus, navigation preserved URL state, a remount did not duplicate a consequential request, and cleanup released resources.
Profiler traces still measured expensive work. Correctness tests protected user-visible state and external contracts.
The change made the suite compatible with a renderer allowed to prepare and discard work without turning every internal attempt into an observable event.