Fine-grained component splitting and state ownership issues
After cutting a state into multiple local truths, the sequence becomes a probabilistic event
The symptoms of this bug online are very similar to “occasional”, but it is not random.
On the same page, a business state will exist in different forms in different components: URL parameters, parent component state, child component local state, cache returned by the request, and even derived values calculated by a certain selector. The finer the components are broken down, the more these “partial truths” become. As long as “who can write, who knows, and who is responsible for timing” is not converged into one rule first, the root cause of online error status will change from “a certain piece of code is written wrong” to “multiple pieces of code are written correctly, but the writing order is unstable.”
The most difficult thing about this kind of problem is troubleshooting, not repairing. Because it seems that every component is very reasonable: they are maintaining their own small piece of state, caching, and loading. But when combined, the system does not have a unique state owner, and the final performance is: just refresh, switch tabs, and try again. If you want to use logs to connect links, you will find that the same field comes from props, local cache, and request return packets. Who covers whom depends entirely on the rendering rhythm and request delay.
The judgment of this article is simple:
If the component splitting does not first converge on “who writes the state, who knows the details, and who is responsible for the timing”, the same state will be cut into multiple partial truths, and the update sequence will become a probabilistic event. Finally, the reuse benefits will be replaced by occasional error states, and the cost of repeated rendering and troubleshooting will occur.
Below I will use a very typical online error status troubleshooting to explain how to converge it step by step.
Scene: An “occasional” error state
The page is a list + top filter bar.
- There is query:
?tab=all&sort=latest&city=shon the URL - The top filter bar is split into multiple small components: Tab, Sort, drop-down City
- The list component makes a cache of the “last request result” to avoid flickering when switching filters.
User feedback is: When quickly switching Tab and Sort, sometimes the list will display “filter items of the new Sort”, but the list data is still “the results of the old Sort”. Clicking the same Sort again works fine.
At first glance, it looks like the interface is inconsistent, but the packet capture shows that the interface returns no problem, and the sort echo in the returned body is also correct. In other words, the server is right, and what is wrong is the “state displayed” on the front end.
First misjudgment: thought it was a request race condition
Intuition will make you suspect: A’s request is slow, B’s request is fast, B comes back first and renders the correct result, and then A comes back and overwrites the old result.
This type of race condition is indeed common, so we first add the requestId and discard the return packet: only the last request sent is accepted.
After going online, the problem eased a bit, but it didn’t disappear. Explain that “return packet coverage” is not the only channel.
The value of this step is: it cuts off a piece of a seemingly large problem space first. It is now certain that at least some of the faulty states are not caused by network order.
The second misjudgment: I thought it was an error in the cache logic.
Then let’s look at the cache of the list component.
The caching strategy is:
- Pass in
filtersfrom props - The list component internally uses
useRefto savelastGoodData - Trigger the request if
filterschanges - Continue to display
lastGoodDataduring the request, and then replace it when new data comes back
There is nothing wrong with this logic in “reducing flicker”, but it buries a premise: filters must be a stable and single source of truth. Otherwise it is easy to appear: filters has changed, but the list still uses the old lastGoodData, and on the surface it will be thought that it is just a cover-up during loading.
I thought it was because the filters object reference was unstable, causing the effect triggering timing to be confused. Changed to explicit serialization key: filtersKey = tab + sort + city.
Still no cure.
The real root cause: state ownership is shredded
After finally typing all the logs, the problem became clear:
- The Tab component only cares about
tab, it will: - When clicked,
setLocalTab(nextTab)will be highlighted immediately - Then
onChange(nextTab)notifies the parent component - Go to
setFilters({ ... })after receiving the parent component - Sort component also has the same pattern
- In order to support “refresh resumable”, the parent component will:
- First extract the initial filters from URL parse
- Then write it to state
- List components also receive:
filtersprops of parent component- and one
getCachedResult(filtersKey)from the cache module
In other words, a state has at least three sets of sources:
- Local state of subcomponent: used for immediate feedback interaction
- The state of the parent component: as page-level filters
- Cache module: used as a data display backend
There is no strict “write order” contract between them.
The link where the problem occurs is usually as follows:
- User points Sort
- The Sort component immediately updates its local state, and the UI displays “New Sort selected”
- The
filtersof the parent component has not had time to be updated (or it has been updated but will not be passed on until the next frame) - The list component will recalculate
filtersKeyat this time - But it does not count the new filters of the parent component
- Instead, a derived path mixes in the local value of Sort (such as through context or selector)
filtersKeychanged, so the list went to the cache module and fetched an old result that “looked matching”- When the request comes back, because of the requestId discarding policy, as long as it is not the last time, it will be discarded
- The final UI has a strange combination: the filter bar is the new value, and the list data comes from the old cache
This is “multiple pieces of code are written correctly, but the order is unstable.”
Component splitting cuts the state writing rights into pieces: the child component writes one copy first for instant feedback of interaction, the parent component writes another copy for playback, and the cache writes another copy for the sake of experience. Nothing wrong with anything, but the system lacks a unified state ownership.
How to stop talking: Decide ownership first, then talk about reuse
The solution to this type of problem is to write the state’s writing rights, derivation rules and cover-up strategies into an enforceable contract.
I ended up settling on three rules.
Rule 1: Page filters have only one writable source
Child components no longer maintain their own local filters state.
Immediate interactive feedback is provided by the parent component state, and the child component is only responsible for sending events and not storing values. That is:
- Subassembly:
onSelect(next) - Parent component:
setFilters(reduce(prev, action)) - Subcomponent display: read-only
value={filters.sort}
The cost of this is: the subcomponent will become “dumb” and more props will need to be passed in when reused. But what it exchanges is a certain writing path.
Rule 2: Derived values must explicitly indicate the source
All keys used for requests and caching are only allowed to be generated from filters.
It is forbidden to generate another set of keys directly from the context, selector or URL.
This may seem like a mystique, but it is to avoid “fields with the same name coming from different sources”. Once sort is allowed to come from both local state and parent state, you will encounter today’s “one set of UI and another set of data”.
Rule 3: The cache only displays the details and does not participate in status judgment.
The cache module only provides one capability: getLastGoodData(filtersKey).
It cannot determine what the current filtersKey is, let alone use the data of an old key as the “current result”.
The specific steps are:
- The list request status is clear:
currentFiltersKeyis passed from the parent component - Allowed when showing:
data = loading ? cache[currentFiltersKey] ?? null : result- but caching never affects filters back
This downgrades the cache from “participating in system status” to “purely displaying the bottom line”. It will sacrifice a little experience, for example, some switches will be empty for a while, but it will return certainty.
Counterexample: “Promote all states to parent component” will also fail
After listening to this, some people will say: Just lift all states to the top level.
The version I’ve seen fail is: the top layer does become the only source of truth, but it also bears:
- URL synchronization
- Local persistence
- Request throttling
- cache hit
- UI interactive state (hover, focus, panel open)
As a result, the top-level reducer becomes a business core, and any small interaction has to go through a bunch of logic, and the cost of modification skyrockets. In the end, everyone began to bypass it and secretly added local state back into subcomponents, and the system returned to “multiple local truths”.
So the key is to “clear which states require sole ownership”.
I generally judge it in one sentence: As long as it affects requests, cache keys, or cross-component consistency, it must be uniquely owned. Pure UI transients can be localized.
Applicable boundary: Not all component splits will cause pitfalls
This criticism is not about component splitting per se.
Splitting was originally intended to control complexity, but it has an implicit cost: the “boundary of state writing” must be additionally designed.
When the page satisfies any of the following conditions, this trap will easily appear:
- Has URL/persistent recovery
- There are concurrent requests or cancellations
- There is a cached display
- Have filters shared across components
On the other hand, if the page is very simple, the state does not affect the request, and there is no caching and recovery, the local state will not become a disaster.
Summary
Breaking down components into small pieces will not automatically bring reuse benefits; it will first create state ownership issues.
If you don’t first answer “Who can write, who knows the details, and who is responsible for timing”, the system will answer by itself: whoever renders first will have the final say, and whoever comes back late will cover it.
When “occasional” error states occur on the line, you will find that what is really expensive is to recover a state from multiple partial truths into a certain write link. \
读完之后,下一步看什么
如果还想继续了解,可以从下面几个方向接着读。