Back to React
React
easy
junior

How do sibling components communicate in React 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.

6 min read·~10 min to think through

"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.

tsx
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:

tsx
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:

tsx
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.

tsx
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 (useImperativeHandle exposing 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

tsx
function Cart() {
  const [items, setItems] = useState<Item[]>([]);
  const total = items.reduce((s, i) => s + i.price, 0);
  return (
    <>
      <ItemList items={items} onRemove={(id) => setItems(prev => prev.filter(i => i.id !== id))} />
      <CartSummary total={total} count={items.length} />
    </>
  );
}
Lifting state up — the default

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.

Senior engineer discussion

Senior signal: knowing the decision tree (lift → context → store → URL → query cache) and avoiding Redux for problems that have simpler solutions.

Related questions