Back to React
React
medium
mid

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.

7 min read·~5 min to think through

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.

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

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

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

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

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

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

tsx
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

ScenarioSolution
2-3 siblings, local stateLift up
Many descendants, rarely updatesContext
Many descendants, frequent updatesZustand / Jotai
Server-derivedReact Query (shared cache key)
Should survive refresh + be shareableuseSearchParams
Cross-tablocalStorage + 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.

Senior engineer discussion

Senior framing: ask 'where does this state belong' first. The sharing technique falls out of the answer. Server state never belongs in client state. URL state belongs in the URL. Auth belongs in Context. The interesting decisions are the ones in between.

Related questions