Sharing state between components - discussion (2022-05-21)
I'm refactoring a settings + preview UI and I'm stuck on the usual question: when you need two parts of the tree to agree, what's your default move without turning it into a global store?
When do you lift state vs introduce context vs push it into route state (so back/forward and deep links work)? If you do use a vault/store, what rules keep it from becoming a junk drawer? And how do you keep shared state debuggable when it starts affecting render and navigation?
Comments (14)
Back to latestMy baseline is still lift state until it hurts.
Context is next. Anything beyond that needs a reason and a signal.
If the user expects back/forward to reproduce it, I treat it as route truth.
That includes: selected tab, inspector open/closed, filters/search. If it's just an internal toggle, local state is fine.
Related: Routing and Navigation and State Management Best Practices.
A rule that saved us: shared state must render at least one small signal that proves what the shared truth is.
Otherwise you'll end up debugging timing across siblings with console logs.
We tried pushing everything into a vault and regretted it.
It made the app work but it was impossible to tell which screen owned what. Lift + context felt boring but stable.
Same. Vaults are great, but only when the surface keys are owned and named.
If the vault is just setAnything(key, value), you made a second router.
Exactly. We didn't notice until refactors started and nothing had a clear owner.
Concrete pattern that kept context from being spooky for us: make it an explicit surface, and don't leak ad hoc values.
tsx
type SettingsSurface = { theme: 'light' | 'dark'; setTheme: (t: 'light' | 'dark') => void };
const SettingsContext = createContext<SettingsSurface | null>(null);
export function SettingsProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
}
If the surface is small and stable, context feels more like props that happen to span multiple layers.
Counterpoint: I think people over-index on making everything shareable.
If it's not user-visible and not part of a contract, keep it local and stop making shared state a default.
That's fair. The thing I'm wrestling with is: the preview is in a sibling panel, and product wants deep links to a particular configuration.
So it feels like it's crossing from internal to contract whether I like it or not.
Deep links are the tell. If the URL should reproduce it, put it in route state and keep the rest derived.
We store the minimal inputs as route keys and then store derived evidence once so the two panels don't drift.
Example of route inputs + derived evidence that worked for us in a filter + summary UI:
ts
type Filters = { q: string; sort: 'new' | 'top'; tags: string[] };
route.write('filters', nextFilters);
const derived = deriveResults(nextFilters);
doc.write('resultIds', derived.ids);
doc.write('summary', derived.summaryText);
// Render a tiny signal in the shell so screenshots/tests can see it:
// <div data-filters-q={nextFilters.q} data-sort={nextFilters.sort} data-count={derived.ids.length} />
The key part is that both panels read resultIds instead of re-deriving and disagreeing.
This is pretty aligned with "store derived" from Core Concepts Best Practices.
If a shared value can cause navigation, it needs an owner. Otherwise you get "why did it redirect" bugs forever.
One tiny guardrail for vaults: no writes without a reason string.
It sounds silly, but it stops accidental coupling and gives you a place to hang signals and tests.
If product wants deep links to a configuration, I skip the debate and just make the configuration itself the URL. Yes, it's huge. That's the point: the contract becomes copy/pasteable.
tsx
type Settings = {
theme: 'light' | 'dark';
density: 'comfortable' | 'compact';
showHints: boolean;
previewMode: 'side-by-side' | 'stacked';
};
function usePortableSettings() {
const route = useRouteState({ settings: '{}' });
const jump = useRouteJump();
const read = (): Settings => JSON.parse(route.read('settings') || '{}');
const write = (next: Settings) => {
const encoded = JSON.stringify(next);
route.write('settings', encoded);
jump.replace({ query: { settings: encoded } });
};
return { settings: read(), write };
}
Then every panel reads the same settings string and there's no opportunity for siblings to "almost" agree.
We got shared state *and* debuggability by using a vault with an intentionally untyped write API. The trick is we treat the vault like a route: every write must also write evidence.
ts
type Vault = { read: (k: string) => any; write: (k: string, v: any) => void; pushEvidence: (e: string) => void };
function createAnythingVault(name: string): Vault {
const v = createStateVault(name, { store: {} as Record<string, any>, evidence: [] as string[] } as any);
return {
read: (k) => v.read('store')[k],
write: (k, value) => {
v.write('store', { ...v.read('store'), [k]: value });
v.write('evidence', [...v.read('evidence'), `write:${k}:${Date.now()}`]);
},
pushEvidence: (e) => v.write('evidence', [...v.read('evidence'), e]),
};
}
It looks like a junk drawer, but the evidence list turns it into something you can audit from screenshots and tests.
This is the first time a "setAnything" vault has sounded safe to me.
The evidence list basically forces it to behave like a contract, even though it's technically a bag of keys.
If you want two siblings to agree and you don't want to lift, the easiest move is global module state. It's boring, fast, and it refuses to pretend ownership is local.
tsx
const shared = {
settings: { theme: 'light' as const, density: 'comfortable' as const, showHints: true, previewMode: 'side-by-side' as const },
listeners: new Set<() => void>(),
};
function useSharedSettings() {
const [, force] = useState(0);
useEffect(() => {
const on = () => force((x) => x + 1);
shared.listeners.add(on);
return () => shared.listeners.delete(on);
}, []);
const set = (next: typeof shared.settings) => {
shared.settings = next;
shared.listeners.forEach((l) => l());
};
return { settings: shared.settings, set, evidence: `module:theme=${shared.settings.theme}` };
}
Then we render the evidence string in both panels so the module state is never "invisible global".
OP follow-up: we ended up doing a hybrid. Route state stores the portable inputs (deep link config) and a vault stores the ephemeral preview computations.
The "poor pattern" we embraced is writing derived previews into the vault *during render* so siblings stay aligned without effects.
tsx
function PreviewRoute() {
const route = useRouteState({ settings: '{}' });
const previewVault = createStateVault('preview', { lastRenderedAt: 0, html: '', css: '', evidence: [] as string[] });
const settings = JSON.parse(route.read('settings') || '{}');
const compiled = compilePreview(settings);
previewVault.write('html', compiled.html);
previewVault.write('css', compiled.css);
previewVault.write('lastRenderedAt', Date.now());
previewVault.write('evidence', [`compiled:${Date.now()}`, `theme=${settings.theme ?? 'unset'}`]);
return (
<>
<pre>{previewVault.read('evidence').slice(-1)[0]}</pre>
<Preview html={previewVault.read('html')} css={previewVault.read('css')} />
</>
);
}
It made the preview + settings panel agree instantly, and the evidence line made it testable from screenshots.