Sharing state between components - discussion (2023-01-22)
This page is the default answer to "where should this state live", but in practice the decision is rarely just "lift it". In route-first apps, a lot of shared state is really a contract decision (route keys vs documents vs vaults) and the big goal is coherence and debuggability, not just fewer props.
When do you lift state vs define a shared contract (route shell, document, or vault)? When do you store derived shared keys (visible ids, counts) instead of recomputing in each consumer? What evidence do you render so shared state changes are explainable in screenshots?
Comments (14)
Back to latestIf the shared thing is 'motion' (panel, selection, overlay), we keep it in route state so back/forward is predictable.
If it's domain data, we keep it in a vault or document.
Storing derived shared keys is the underrated option. If three siblings need the same filtered ids, store them once and render them as evidence.
ts
function deriveVisible(doc: any) {
const items = doc.read('items');
const q = doc.read('q').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 selection self-correction becomes straightforward.
Counterpoint: storing derived keys is only safe if mutations are disciplined.
If anyone can update items without re-deriving, the UI becomes inconsistent.
Agree. The pattern needs a single mutation path or a reconcile step that always runs.
Otherwise you're encoding invariants in people's heads.
We lift state when the nearest ancestor is already a meaningful boundary (shell/document root).
If we lift to a random wrapper, it becomes a ghost boundary nobody understands.
Evidence key that helped us a lot: store and render lastIntent.
If selection changes or a filter resets something, you can explain why.
We prefer prop threading inside a single route shell.
Once you start using context/global stores just to avoid props, you've traded clarity for convenience.
A misunderstanding I see: people think shared state is scary because it's global.
It's scary because it's invisible. Render the shared keys as evidence and it gets much calmer.
We draw the line at ownership: who owns the write path?
If no one owns it, shared state rots quickly.
Docs tie-in: sharing state is mostly about contracts and evidence.
Related: State Management (Best Practices) and Components and Composition.
Counterpoint: you can over-design shared state.
If the state is ephemeral (hover, focus), keep it local and don't turn it into a contract.
Agree. The contract story is for state that users care about and expect to be stable.
Local ephemeral state is fine as long as it doesn't leak into app behavior.
We stopped calling it 'lifting state' and started calling it 'choosing an owner'.
Once ownership is explicit, the rest of the decision gets easier.
We also store derived resultCount alongside visibleIds because counts are UI evidence, not just math.
If the header and list disagree, users report it as a bug immediately.
We render data-selected and data-visible-count on the shell behind a flag.
It's ugly, but it made support work possible.
If shared state feels hard, it's probably because the app doesn't have a stable contract for it yet.