How to communicate with sibling components in React

React encourages top-down data flow, making sibling communication a challenge. This article discusses when to lift state and when to sidestep React’s render cycle using a custom observable utility. We walk through a real-world example—scroll position syncing—demonstrating a performant, type-safe way to coordinate siblings without triggering excessive rerenders.

So how should we communicate between sibling components? The short answer: you don’t.

React enforces a top-down data flow. State lives at the top, props flow downward, and changes are pushed upward through callbacks. If two sibling components need to “talk” to each other, the typical solution is to lift the state up—placing it in the closest common ancestor. Each component receives relevant data via props and signals intent to update via callbacks.

This preserves React’s core principles and keeps components predictable. Here’s a simplified model:

Parent
 ├── Sibling A (receives data, sends updates)
 └── Sibling B (receives the same data)

This works well in most cases and aligns with how React is designed to operate.

When That Model Breaks Down

There are edge cases where lifting state leads to poor performance. A common example is scroll position synchronization. Let’s say you have two scrollable containers and want to sync their position. If you store the scroll position in React state, you're introducing rerenders on every scroll event—potentially dozens per second. For heavy components, this creates noticeable lag.

This isn’t a React bug—it’s simply not what the system is optimized for.

An Event-Based Escape Hatch

For scenarios like this, it's better to step outside React’s render cycle and rely on event-based communication.

A simple native EventTarget or any implementation of the Observer-pattern can act as a lightweight event bus. By creating it inside a parent component and passing it to children through props, you avoid global state and keep everything within React’s component tree—while still bypassing React’s rendering mechanism for high-frequency updates.

// observable.ts

export type Observable<T> = {
  subscribe(observer: (value: T) => void): () => void;
  emit(value: T): void;
};

export function observable<T>(): Observable<T> {
  const observers = new Set<(value: T) => void>();

  return {
    subscribe(observer) {
      observers.add(observer);
      return () => observers.delete(observer);
    },
    emit(value) {
      for (const observer of observers) {
        observer(value);
      }
    },
  };
}

In the parent:

import { observable, Observable } from './observable';

function ParentComponent() {
  const scrollChannel = useRef(observable<number>()).current;

  return (
    <>
      <ScrollList scrollSync={scrollChannel} />
      <ScrollList scrollSync={scrollChannel} />
    </>
  );
}

Each child component subscribes to scroll events and emits its own:

type Props = {
  scrollSync: Observable<number>;
};

function ScrollList({ scrollSync }: Props) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const unsubscribe = scrollSync.subscribe(scrollY => {
      ref.current?.scrollTo({ top: scrollY });
    });

    return unsubscribe;
  }, [scrollSync]);

  return (
    <div
      ref={ref}
      onScroll={e => scrollSync.emit(e.currentTarget.scrollTop)}
      style={{ overflowY: 'scroll', maxHeight: 200 }}
    >
      {/* scrollable content */}
    </div>
  );
}

The scroll updates are processed entirely outside React’s render cycle, making it ideal for high-frequency, non-visual state like this. And because the Observable is created within the component tree, the flow remains explicit and testable.

Choosing the Right Tool

This approach should remain the exception. Most communication between siblings is better handled by lifting state. But if the value being shared is ephemeral, frequently updated, or not relevant for rendering, then side channels like this become useful.

Good candidates include:

  • Scroll positions
  • Pointer coordinates
  • Temporary visibility toggles
  • Local UI metrics (like element widths or drag states)

The key is to avoid putting “React-external” data into React’s render cycle unless absolutely necessary.


Summary

Sibling communication in React doesn’t need special treatment—it usually means you’ve placed state too low in the component tree. Lift it up, and React’s unidirectional flow takes care of the rest.

For performance-critical cases, an event-driven approach using something like EventTarget gives you fine-grained control without sacrificing React’s architecture. It's not a replacement for React state—but it's a valuable complement when performance is crucial.