Concurrency should stay behind the interface
React 18 gave P6X4 new scheduling and streaming capabilities; the interface still needed to speak in product states rather than renderer states.
React 18 made concurrency part of the stable release, and my first mistake was to make it visible.
I had been waiting for a better way to keep P6X4's document browser responsive while expensive filtering and detail views updated. The new release provided automatic batching, transitions, deferred values, improved Suspense behavior, and streaming server rendering. Those capabilities addressed real problems.
My prototype also acquired pending badges, skeletons, spinners, dimmed panels, and status text until the page seemed to be narrating the scheduler.
The implementation was concurrent. The experience was preoccupied.
A renderer state is not automatically a product state
P6X4's document browser had three meaningful user activities:
- Narrowing a collection to find a document.
- Opening a document revision and its evidence.
- Requesting an external effect such as rendering or delivery.
Each activity had different consistency and feedback requirements. My first React 18 pass treated them as instances of the same technical fact: some work was pending.
That collapsed important distinctions.
A filter transition could preserve the old result list while a new one was computed. A document request could reveal a stable frame while secondary evidence streamed. A delivery command could not be represented by a dimmed button and eventual UI update; it needed a durable intent identity and explicit accepted, uncertain, or completed state.
Concurrency could improve all three, but it did not give them one interaction model.
The filter was allowed to be temporarily behind
The collection view combined a fast text field with a result list that could be expensive to render under synthetic load. Initially, every keystroke updated the input, filter state, URL, result query, selection, and count in one urgent render.
The input felt sticky because the browser could not distinguish typing from recalculating the list.
I wrapped the result-state update in startTransition while keeping the controlled input update urgent:
function onQueryChange(next: string) {
setInputValue(next)
startTransition(() => {
setQuery(next)
})
}The code was the easy part. The contract was harder: what could remain visible while the query changed?
The old results were still truthful as results for the previous query. They became misleading only if the interface implied they matched the new text. I kept the list in place, marked the results region as updating, and preserved its geometry. A small status beside the result count said “Updating results” after a short delay; it did not cover every row with a skeleton.
The input's accessible name and value remained stable. The results region used aria-busy to communicate an update without repeatedly announcing every intermediate keystroke. Focus stayed in the field. The previous selection remained until the new result set either preserved or invalidated it.
That was a product decision about temporary staleness. startTransition merely let the renderer implement it.
A deferred value needed a visible relationship
I also tried useDeferredValue for the query passed into the result list. It was useful when the source value belonged to a component I did not want to reorganize immediately.
The deferred value introduced two simultaneous truths:
const deferredQuery = useDeferredValue(query)
const isBehind = query !== deferredQueryThe input showed the current query. The list corresponded to the deferred query. If I ignored isBehind, the interface silently connected fresh controls to stale content.
I resisted fading the entire list to half opacity. It reduced readability and made ordinary typing feel like failure. Instead, the result summary kept the relationship explicit: “Results for ‘invoice’” could remain while the input already contained “invoice draft.” If the delay crossed a threshold, a quiet update label appeared.
Fast transitions showed no status at all. Feedback should represent a perceptible wait, not prove that an API was used.
Interruptibility did not mean discardability
The collection filter was safe to interrupt. Intermediate queries had no durable effect. If a person typed har and immediately completed harbor, React could abandon rendering results for the earlier value.
P6X4's delivery action was the opposite. Once accepted, it represented a durable request that could not disappear because a component unmounted or a render was interrupted.
I kept the action protocol outside the concurrent rendering model:
- The client generated or received a stable intent identifier.
- The server validated authority and recorded the request transactionally.
- The response returned the durable workflow identity.
- The UI rendered from that stored state.
- Repeated submission referred to the same intent rather than creating another effect.
A transition could make navigation or surrounding UI responsive while the request was being acknowledged. It could not be the only representation of “sending.”
This distinction prevented a subtle category error. React can interrupt rendering work. The application cannot casually interrupt a delivery that an external adapter may already have accepted.
Automatic batching changed timing assumptions
React 18's automatic batching reduced renders for state updates across more asynchronous contexts. Most P6X4 components simply became more efficient. A few tests exposed that I had accidentally relied on intermediate renders.
One status component separately updated phase, message, and canRetry. Its effect watched phase and inferred whether the intermediate combination represented a failure. Under the old timing, tests happened to observe a render between updates. With broader batching, the intermediate state did not appear.
The component was wrong before the upgrade. Related fields described one state transition and should have been updated as one reducer action:
dispatch({
type: "request-failed",
message,
retryPolicy,
})I replaced timing-sensitive effects with explicit state transitions and assertions about externally meaningful states. The fix made the code less dependent on how React scheduled renders.
When I genuinely needed the DOM updated synchronously before measuring it, I isolated that rare case rather than scattering forced flushes around the application. Most “needs immediate update” comments were really missing state design.
Development checks around mounting and effects surfaced another problem. A detail component sent an audit-style “document viewed” request in an effect that I had mentally treated as a one-time event.
Mounting is not a durable user intent. Effects can run again as components are remounted during development checks, routes change, or dependencies evolve. The request duplicated because its semantics were weak, not because the framework was misbehaving.
I split the needs:
- Ephemeral observation used a deduplicated analytics record with an explicit session and view identity.
- Consequential actions remained in event handlers and server protocols with stable request identity.
- Data synchronization effects were safe to set up and clean up repeatedly.
The test I added mounted, unmounted, and mounted the component again. External subscriptions had one active listener, temporary resources were released, and no consequential workflow was created merely by appearing on screen.
Concurrency work made lifecycle assumptions less ignorable.
Suspense boundaries needed semantic owners
For the document route, I initially placed a Suspense boundary around every asynchronous component. The page streamed quickly and assembled itself like a dashboard receiving bad news.
The title appeared, then the metadata card, then an audit skeleton changed height, then a delivery panel arrived and moved the actions. Each backend query had become a visible loading island.
I regrouped boundaries around concepts the reader could understand:
- The document identity and primary revision formed the initial frame.
- Evidence history was one secondary region.
- Delivery status and actions formed one operational region.
- Recommendations were optional and did not block anything else.
Within a region, several queries could resolve before the boundary revealed it. The user did not need to know that evidence came from two tables and a projection. The fallback preserved the region's final geometry and named the content when the wait became noticeable.
This produced fewer boundaries than data sources. That was intentional.
Streaming improved the first useful moment
React 18's server rendering APIs made it possible to send a shell without waiting for every part of the route. I measured three moments in the lab:
- When response bytes began.
- When the document identity became readable.
- When the requested task became possible.
The first metric improved dramatically with a minimal shell, but a blank frame plus placeholders did not help a person recognize the document. I moved stable identity, navigation, and revision summary into the early content. The page could then answer “Am I in the right place?” before slower evidence arrived.
The primary action did not always need to wait for recommendations or history. It did need current authority and workflow status. Those requirements, not query duration, determined the boundary.
I tested with artificial latency, CPU slowdown, narrow viewports, keyboard navigation, and reduced motion. A fast local network hid most sequencing problems. Slow conditions made hierarchy visible.
Receiving HTML early did not mean every visible control was ready.
I watched for controls that looked interactive before their handlers could respond. The worst example was a delivery button rendered in the early shell while the client bundle and current workflow state were still hydrating. A fast click appeared to do nothing.
I either moved necessary state into the server-rendered contract or rendered the control in a clearly unavailable state until the action could be handled. The page did not need a global “hydrating” label, but it could not make promises through affordances it was not ready to keep.
Progressive enhancement helped for ordinary navigation and form submission. A real form action could work before optional client behavior arrived. For client-only interactions, the loading design accounted for the gap.
Streaming shifted the order of arrival. It did not remove the need to reason about readiness.
Error boundaries described recovery scope
Loading boundaries were only half the structure. A secondary recommendation failure should not replace the document identity. An authority failure on the document should not leave an apparently active delivery panel.
I paired Suspense and error boundaries according to recovery scope:
- Optional recommendations could fail locally with a retry or absence message.
- Evidence history could fail while preserving the document, but its unavailable state remained visible.
- Loss of document access invalidated the operational regions below it.
- A delivery mutation failure rendered from the durable workflow record rather than resetting the entire route.
This was not just component placement. It expressed which facts remained trustworthy after each failure.
The same boundary map informed logging. An error record included the product region and document revision, not only the component stack.
Every pending flag tempted me to show something immediately. That made quick interactions flicker.
I introduced a simple feedback policy:
- Under a perceptual threshold, preserve continuity without a label.
- After the threshold, show a stable, local indication.
- For longer waits, explain what remains possible and whether the work can continue in the background.
- For durable actions, show the recorded state even if the route changes.
The exact thresholds were tested rather than treated as universal constants. The important part was that feedback reflected user consequence.
A filter update could quietly finish. A document render lasting seconds deserved a status tied to the workflow. An ambiguous delivery timeout deserved neither an endless spinner nor an optimistic success message; it needed an unknown outcome state and a query path.
The scheduler knew that work was pending. Only the application knew what the wait meant.
Tests targeted transitions, not implementation trivia
I wrote scenarios around observable guarantees:
- Typing remained responsive under an expensive result render.
- Old results were never labelled as matching a new query.
- Keyboard focus survived replacement of the result list.
- A streamed route exposed document identity before optional evidence.
- Layout did not jump when a delayed region appeared.
- Optional failure did not erase authoritative content.
- Remounting did not duplicate a consequential effect.
- A delivery request remained traceable after navigation.
I kept a smaller set of tests for reducer states and contracts. I avoided assertions that a specific number of renders occurred. Scheduling is an implementation capability; user-visible invariants were the contract I wanted to protect.
Performance traces helped confirm that urgent input was no longer blocked, but they were evidence alongside interaction tests, not a substitute for them.
Concurrency made good boundaries more valuable
The new capabilities did not make architecture disappear. They rewarded a clear distinction between stable facts, optional projections, interruptible calculations, and durable effects.
P6X4 could stream document identity early because the identity had one authority. It could defer filtering because a prior result set remained interpretable. It could isolate an optional failure because the recommendation had no control over the document. It could not treat delivery as render work because Z29C's earlier lessons had given external effects a durable protocol.
Each successful use of concurrency depended on a decision made outside React.
By the end of the experiment, P6X4 used transitions, deferred values, automatic batching, Suspense, and streaming. The page looked calmer than the synchronous version.
That was the measure I cared about.
The input responded immediately. Results admitted when they belonged to an earlier query. The document frame arrived with useful identity. Secondary regions appeared without rearranging the task. Durable actions survived route and process changes. Failures remained inside boundaries that matched their consequences.
None of those outcomes required the person using the page to understand concurrent rendering.
Upgrade order protected the experiment
I did not combine the React upgrade, streaming rollout, data-layer rewrite, and P6X4 consolidation into one change. That would have made every improvement impossible to attribute and every regression difficult to isolate.
First, I upgraded the client and server rendering entry points while preserving behavior. I recorded existing interaction traces, hydration warnings, focus paths, and route timings. Automatic batching and development checks surfaced assumptions, but the product states stayed the same.
Second, I introduced transitions only on the expensive collection route. The feature flag could switch between urgent and transitional updates for the same data path. I compared input latency, stale-result labelling, and selection behavior under deterministic CPU work.
Third, I enabled streaming on one read-only document route. Its server response contained no consequential mutation and its regions already had explicit authority. A conventional render remained available during the evaluation window.
Only after those paths behaved well did I align data loading with the new boundaries. The order felt slow compared with a fresh demo. It let me distinguish a framework capability from an architecture change I had chosen to make alongside it.
I kept a compatibility note beside every experiment: the stable React 18 API used, assumptions about server integration, fallback behavior, and the condition for removal. This was especially important because surrounding framework support was still evolving. I wanted the archive to remember what was stable in March 2022 and what belonged to my integration.
Accessibility was part of the scheduling contract
Visual continuity could hide disruptive accessibility behavior.
When a transition replaced result rows, a screen reader might receive a burst of announcements if every item lived inside an aggressive live region. If the selected row disappeared, keyboard focus could fall back to the document body. If a fallback replaced a heading, the page outline could change during reading.
I kept live announcements small and intentional. The input described what would update. A polite result summary announced the settled count after typing paused; it did not narrate intermediate renders. Result containers preserved headings and landmark identity across pending states. Selection used stable document keys rather than array positions.
For streamed regions, the server-rendered heading and description were present in the initial structure even when details were pending. The fallback was not an anonymous grey rectangle. When content arrived, it extended a known region instead of creating a new navigation target above the reader's current position.
I tested with the keyboard from a cold load, with JavaScript delayed, and with motion reduction enabled. A concurrency feature was not complete if only the pointer path on a warm cache felt faster.
These checks changed boundary placement. A small technical boundary that caused focus and headings to churn was combined with its surrounding region even when that delayed a few bytes.
React 18 gave the application more freedom in when work could be prepared, interrupted, and revealed. My responsibility was to spend that freedom on continuity, not turn every scheduling decision into a visual event.
Concurrency belonged in the implementation. The interface still belonged to the task.