Sharing state between components - discussion (2023-01-10)
This page is the default answer to "where should this state live?" but I still see teams oscillate between prop threading, context, and ad hoc globals. I'm curious what people's *practical* rules are when the goal is stability and debuggability, not just reducing props.
When do you lift state, and when do you store derived shared keys (counts, visibility lists) instead? Where do you draw the line between "prop threading is fine" and "we need a shared contract"? How do you keep shared state changes legible (evidence in the UI) so debugging doesn't become guesswork?
Comments (18)
Back to latestMy rule: lift to the nearest common ancestor *only* if the ancestor is already a meaningful boundary.
If you lift to a random wrapper, you've just created a ghost boundary.
Prop threading is fine until the props become a moving bag of unrelated concerns.
At that point I'd rather define a route contract (shell + document) than keep passing 15 keys through 8 layers.
Stored derived keys are the underrated option here.
If three siblings need the same filtered ids, don't let them each re-derive it. Store it once and render it as evidence.
Here's a simple example of "store shared derived state" rather than lifting a bunch of intermediates:
ts
function deriveVisible(doc: any) {
const items = doc.read('items');
const q = doc.read('query').trim().toLowerCase();
const visibleIds = q ? items.filter((x: any) => x.title.toLowerCase().includes(q)).map((x: any) => x.id) :
items.map((x: any) => x.id);
doc.write('visibleIds', visibleIds);
doc.write('resultCount', visibleIds.length);
}
Then any panel can read visibleIds and stay consistent.
Counterpoint: storing derived state can turn into a consistency burden if you forget to re-derive.
If your app doesn't have a disciplined mutation path, lifting state might be safer.
Agree. Storing derived keys only works if you centralize mutations or have a render-visible derive step.
Otherwise you're just encoding invariants in people's heads.
Context works best for me when it's truly a shared baseline (theme, policy, user) not when it's a dumping ground for feature state.
Once feature state is in context, you lose the ability to see where updates come from.
Practical legibility trick: render data-selected-id or a short "current selection" line in the shell.
Shared state isn't scary if you can *see* what it is in the DOM.
I used to think "lifting state" was always the answer, but it falls apart when the shared state is really "route posture".
Then you want it in a route shell so back/forward semantics are predictable.
Docs tie-in: a lot of this is just "make shared things explicit".
Related: Components and Composition and State Management (Best Practices).
A misunderstanding I see a lot: people lift state, then they start passing down callbacks, and it turns into a "controller" component.
If you're doing that, it's worth asking if you actually want a document boundary instead.
My heuristic for "prop threading is fine": if the props are stable and named like a contract, keep it.
If it's a grab bag that changes weekly, define a shared surface (scope/route/doc) so the contract stops moving.
For me the killer is when siblings disagree on derived formatting (counts, labels, badges).
Centralizing those derived keys is less about performance and more about coherence.
Another practical trick: name state transitions as intents and store the last intent as evidence.
Then "why did it change" has an answer.
Example of a "shared contract" boundary that still feels component-y:
tsx
function SelectionShell({ children }: { children: React.ReactNode }) {
const doc = useLiveState('selection', { selectedId: null, lastIntent: null });
return <section data-selected={String(doc.read('selectedId'))}>{children}</section>;
}
It's not about eliminating props, it's about making the shared thing visible and stable.
Counterpoint: if a shared state is truly ephemeral (hover, focus, transient UI), lifting or storing can be overkill.
Sometimes letting the leaf own it is more readable.
Late note: a lot of sharing-state bugs are really naming bugs.
If the key isn't named like a domain concept, every component uses it slightly differently.
What the article doesn't say (but matters): shared state needs an ownership story.
If nobody owns it, it rots.
The most helpful thing we did was render evidence for shared state. Everything else followed from that.