How do siblings communicate without prop drilling or Redux?
Lift shared state to the closest common ancestor and pass down. For deep trees, use Context (small slices) or a tiny store like Zustand. Don't use refs/imperative handles unless you really mean to escape React's data flow.
"Pass data between siblings without Redux" is shorthand for "you don't yet have a parent that owns this state — figure out where to put it." React's flow is unidirectional: data flows down via props, events flow up via callbacks. Siblings communicate through their shared ancestor.
Pattern 1 — Lift state up.
function Parent() {
const [filter, setFilter] = useState("");
return (
<>
<FilterInput value={filter} onChange={setFilter} />
<List filter={filter} />
</>
);
}FilterInput and List are siblings. The shared state (filter) lives in the closest common ancestor. This is the answer 80% of the time. Reach for fancier patterns only when this becomes painful (deep trees, many consumers).
Pattern 2 — Context for "ambient" state.
When the shared state is needed by many descendants and prop drilling becomes ridiculous, use createContext:
const FilterCtx = createContext<{ filter: string; setFilter: (v: string) => void } | null>(null);
function Parent({ children }: { children: React.ReactNode }) {
const [filter, setFilter] = useState("");
return <FilterCtx.Provider value={{ filter, setFilter }}>{children}</FilterCtx.Provider>;
}
function useFilter() { const c = useContext(FilterCtx); if (!c) throw new Error(); return c; }
function FilterInput() { const { filter, setFilter } = useFilter(); return <input value={filter} onChange={e => setFilter(e.target.value)} />; }
function List() { const { filter } = useFilter(); /* … */ }The trade-off is that any context value change re-renders every consumer. Split contexts by concern (or use a store) when you have many independent slices.
Pattern 3 — A tiny store (Zustand / Jotai / Valtio).
When state is shared across many far-apart components, a top-level store is cleaner than threading context through every layer. Zustand is the simplest:
const useFilters = create<{ filter: string; setFilter: (v: string) => void }>((set) => ({
filter: "",
setFilter: (v) => set({ filter: v }),
}));
function FilterInput() {
const filter = useFilters(s => s.filter);
const setFilter = useFilters(s => s.setFilter);
return <input value={filter} onChange={e => setFilter(e.target.value)} />;
}Per-slice subscription means components only re-render when their slice changes. No prop drilling.
Pattern 4 — URL state.
If the data should be shareable / persistent across reloads (filters, search query, tab selection), use the URL. The router becomes the source of truth; siblings read params/query independently.
const [params, setParams] = useSearchParams();
const filter = params.get("q") ?? "";This is the underrated answer. URL state survives refresh, deep-links, and is testable.
Pattern 5 — Custom event bus.
For loosely-coupled cross-tree signals (toast triggered from anywhere, route-change broadcast), a tiny pub-sub works. Already covered in the dedicated emitter question.
Anti-patterns to avoid.
- Refs into siblings via parent. Possible (
useImperativeHandleexposing methods), but you've left React's data flow. Reserve for genuine imperative APIs (focus, play video, scroll). - Mutating a shared object without setState. Won't trigger re-renders.
- Reading from window / globals. Hard to test, no React reactivity.
- Lifting state higher than necessary. If only two leaves need it, don't put it on the App.
Decision tree.
- Two siblings only → lift to their parent.
- Many components in a subtree → Context, scoped to that subtree's provider.
- Many components across the whole app → Zustand (or similar).
- Should survive reload / be shareable → URL.
- Asynchronous server data → TanStack Query / SWR (separate cache, separate concern).
Common interview follow-up. "What if the shared state is asynchronous server data?" Then it's not really client state — push it to a server-state library (TanStack Query) and let both siblings call useQuery(['filter']) with the same key. They'll get the same cached data without lifting state at all.
Code
Follow-up questions
- •When is Context the wrong choice?
- •Why is URL state underrated for shared data?
- •How does Zustand avoid re-rendering all consumers on every change?
- •What's the difference between client state and server state?
Common mistakes
- •Reaching for Redux/Zustand for state that two siblings could share via a common parent.
- •Putting all state in one giant Context — every component re-renders on any change.
- •Using refs to imperatively call sibling methods.
- •Storing server data in client state instead of a query cache.
Performance considerations
- •Context value change re-renders all consumers — split by concern or use a store.
- •Zustand selectors with shallow equality skip unchanged subscribers.
- •URL state changes via router → only components reading the param re-render.
Edge cases
- •Provider value object literal recreated on every render → all consumers re-render. Memoize the value.
- •Two providers of the same Context — nearest wins (rare but legal).
- •Concurrent rendering may tear context reads — useSyncExternalStore handles for stores.
Real-world examples
- •Theme provider, auth user, feature flag context, search params via Next.js router, TanStack Query cache.