How would you pass data between sibling components in React without using Redux?
Lift state up to the nearest common parent and pass via props. For widely-shared state across many siblings: Context API. For frequent updates with selectors: Zustand / Jotai. For server state shared between components: React Query (same cache key). For URL-driven sharing: search params via useSearchParams. For cross-tab: localStorage events or BroadcastChannel.
Multiple patterns; pick by frequency, scope, and lifetime of the shared state.
1. Lift state up (default)
The common ancestor owns the state. Pass down via props.
function Parent() {
const [filter, setFilter] = useState('');
return (
<>
<SearchInput value={filter} onChange={setFilter} />
<ResultsList filter={filter} />
</>
);
}When sharing is local (two or three siblings), this is best. No library, no abstraction.
2. Context
When many disparate siblings (and their descendants) need the same value:
const FilterContext = createContext<{ filter: string; setFilter: (f: string) => void } | null>(null);
function Layout({ children }: { children: ReactNode }) {
const [filter, setFilter] = useState('');
const value = useMemo(() => ({ filter, setFilter }), [filter]);
return <FilterContext.Provider value={value}>{children}</FilterContext.Provider>;
}Pros: no prop drilling. Cons: every consumer re-renders on update.
3. Zustand / Jotai (frequent updates)
For state that updates often:
import { create } from 'zustand';
const useFilter = create<{ filter: string; set: (f: string) => void }>(set => ({
filter: '',
set: f => set({ filter: f }),
}));
function SearchInput() {
const filter = useFilter(s => s.filter);
const set = useFilter(s => s.set);
return <input value={filter} onChange={e => set(e.target.value)} />;
}
function ResultsList() {
const filter = useFilter(s => s.filter);
// ...
}Selectors mean each component subscribes only to what it needs.
4. React Query (server state)
If the 'shared data' is server-derived:
function ComponentA() {
const { data } = useQuery({ queryKey: ['user', id], queryFn: fetchUser });
}
function ComponentB() {
const { data } = useQuery({ queryKey: ['user', id], queryFn: fetchUser });
}Both components see the same cached data automatically — single network request, two consumers.
5. URL search params
If state should survive refresh and be shareable via URL:
const [params, setParams] = useSearchParams();
const filter = params.get('q') ?? '';
setParams({ q: 'react' });Both siblings reading the same param share state. Bonus: bookmarkable, shareable.
6. localStorage / BroadcastChannel (cross-tab)
For state shared across tabs/windows:
function useSharedFilter() {
const [v, setV] = useState(() => localStorage.getItem('filter') ?? '');
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === 'filter') setV(e.newValue ?? '');
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const set = (next: string) => { setV(next); localStorage.setItem('filter', next); };
return [v, set] as const;
}BroadcastChannel is the modern alternative for same-origin tabs.
7. Custom event bus (rare, usually wrong)
const bus = new EventTarget();
bus.dispatchEvent(new CustomEvent('filter', { detail: 'react' }));
bus.addEventListener('filter', e => ...);Bypasses React's data flow — only reach for this when truly cross-cutting (e.g., toast notifications from anywhere).
Decision tree
| Scenario | Solution |
|---|---|
| 2-3 siblings, local state | Lift up |
| Many descendants, rarely updates | Context |
| Many descendants, frequent updates | Zustand / Jotai |
| Server-derived | React Query (shared cache key) |
| Should survive refresh + be shareable | useSearchParams |
| Cross-tab | localStorage + storage event / BroadcastChannel |
Anti-patterns
- Prop drilling through 5 levels when context would help.
- Custom event bus when lift-up or context works.
- Storing server state in Zustand when React Query handles it.
- Storing URL state in component state when search params would.
Senior framing
The interesting question isn't 'how do siblings share state' but 'who owns this state'. Server state goes to React Query, URL state goes to the URL, local UI state lifts to the common ancestor, cross-cutting client state goes to a small store. The Redux question is usually a misframe — siblings sharing state is rarely the right reason to introduce a store.
Follow-up questions
- •When is Context worse than Zustand for shared state?
- •Why use React Query for shared server data instead of lifting state?
- •How do search params become a state-sharing primitive?
Common mistakes
- •Prop drilling through many levels instead of lifting once.
- •Lifting state too far up — re-renders cascade.
- •Using Context for state that updates many times per second.
Performance considerations
- •Lift-up re-renders the common ancestor and all its children on each update — fine for small subtrees, expensive for big ones. Context cascades to all consumers. Zustand/Jotai limit re-renders via selectors.
Edge cases
- •Cross-tab sync needs storage event or BroadcastChannel — Context alone doesn't span tabs.
- •URL state has length limits.
- •Multiple useQuery hooks with the same key dedupe automatically — useful for sharing.
Real-world examples
- •Search-and-filter UIs (filter state lifts to page-level), shopping carts (Zustand or React Query), themes/auth (Context), URL-driven filters (search params). Most apps mix several techniques.