Build CoreOrdered learning track

Cross-Tree Communication

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 041

Cross-tree communication patterns in React, from lifted state and composition to context, URL state, external stores, server cache, command channels, portals, and failure-mode analysis.

8 min read1565 words
PrevNext
Lesson 41123 lesson track24–67 Build Core
#react#component-communication#cross-tree-communication#context+2 more

Part 041 — Cross-Tree Communication

Cross-tree communication muncul ketika dua component yang perlu berkoordinasi tidak berada dalam hubungan parent-child yang dekat.

Contoh umum:

Toolbar button membuka modal yang dirender jauh di AppShell
Table row memicu toast yang hidup di root provider
Form field membaca permission dari route boundary
Sidebar selection memengaruhi main panel dan breadcrumb
Wizard step memengaruhi footer action bar
Nested page ingin mengubah document title, command palette, or route search params

Problem ini sering diselesaikan dengan “pakai global store saja”. Itu sering bekerja di awal, lalu perlahan membuat dependency tidak terlihat, ownership kabur, dan test menjadi berat.

Cross-tree communication bukan izin untuk menghapus arsitektur. Ia adalah sinyal bahwa ada coordination boundary yang belum diberi bentuk yang tepat.


1. Mental Model

React data flow tetap sederhana:

parent renders child with props
child reports intent upward through callback
context makes scoped values available downward
external store lets many nodes subscribe to shared external source

Cross-tree communication terjadi ketika jalur alami props/callback terlalu panjang atau tidak merepresentasikan ownership yang benar.

The real question is not:

How can C talk to D?

The better question is:

What shared abstraction owns this coordination?

Possible answers:

route boundary
layout boundary
feature provider
external store
server-state cache
URL
modal/toast capability provider
state machine actor
command bus

2. Define Cross-Tree Precisely

Cross-tree does not always mean global.

A component pair can be cross-tree within:

a compound component
an application shell
a route segment
a feature module
a microfrontend island
a portal layer
a server/client boundary
a workflow actor tree

That distinction matters because the safest solution is usually the narrowest shared scope, not the widest available tool.

Rule:

Never promote state farther than its lifecycle requires.

If state dies with a modal, keep it in modal. If state dies with a route, keep it at route. If state is shareable/bookmarkable, put it in URL. If server owns it, put it in server cache. If it coordinates a global UI capability, expose a capability provider.


3. Communication Channels in React

Cross-tree communication usually uses one of these channels:

ChannelDirectionBest ForRisk
Lifted ownerdown props, up callbacksNearby coordinationProp drilling if too far
Composition slotparent injects child/behaviorLayout and extensibilityHidden coupling if overused
Contextdown scoped dependencyTheme, permissions, capabilities, feature servicesRerender fan-out, hidden dependencies
URL statebrowser-addressable stateFilters, tabs, search, paginationSerialization complexity
External storemany subscribersHigh fan-out client stateGlobal coupling, tearing if unsafe
Server-state cachemany readers of server dataRemote data, freshness, invalidationCache inconsistency if keys are weak
Command providerarbitrary caller to scoped hostToast, modal, command palette, confirmationTurns into event bus if undisciplined
Event buspublish/subscribeDecoupled integration eventsCausal opacity, leaks, ordering bugs
Imperative refdirect command to component instanceFocus, scroll, measurement, media controlBreaks declarative topology

The table is intentionally not ranked by popularity. It is ranked by architectural meaning.


4. Decision Ladder

Use this ladder before creating a global store.

1. Can the state stay local?
2. Can the closest common owner hold it?
3. Is the problem actually composition/slot injection?
4. Is the value a scoped dependency? Use Context.
5. Is the state route-addressable? Use URL.
6. Is the state server-owned? Use server-state cache.
7. Is it high fan-out client state? Use external store.
8. Is it a UI capability? Use capability provider.
9. Is it a true decoupled event? Use event bus with strict rules.
10. Is it an imperative DOM/component command? Use ref handle.

Mermaid version:


5. Pattern 1 — Closest Shared Owner

This is still the default answer.

function CaseWorkspace() {
  const [selectedCaseId, setSelectedCaseId] = useState<string | null>(null);

  return (
    <WorkspaceLayout
      sidebar={
        <CaseList
          selectedCaseId={selectedCaseId}
          onSelectCase={setSelectedCaseId}
        />
      }
      content={
        <CaseDetail caseId={selectedCaseId} />
      }
    />
  );
}

The sibling components never talk directly. The shared owner coordinates them.

This is correct when:

state is needed by a small subtree
state lifecycle matches that subtree
updates are not extremely frequent
owner can express the transition clearly

Bad sign:

<App
  selectedCaseId={selectedCaseId}
  onSelectCase={setSelectedCaseId}
  activeFilter={activeFilter}
  onFilterChange={setActiveFilter}
  draftComment={draftComment}
  onDraftCommentChange={setDraftComment}
  modalState={modalState}
  onModalChange={setModalState}
/>

This is not cross-tree communication anymore. This is missing boundaries.

Refactor by splitting ownership:

route state -> route component
modal state -> modal provider or modal host
filter state -> URL/search params or feature owner
draft state -> local form owner
server data -> query cache

6. Pattern 2 — Composition Slot as Communication

Sometimes cross-tree communication is actually a composition problem.

Instead of this:

function Page() {
  return (
    <>
      <Header />
      <Content />
    </>
  );
}

function Content() {
  // wants to modify Header actions somehow
}

Make the page own the composition:

function CasePage() {
  const actions = <CaseActions />;

  return (
    <PageShell header={<Header actions={actions} />}>
      <CaseDetail />
    </PageShell>
  );
}

If the content needs to contribute to header actions dynamically, use a route-level boundary:

function CaseRoute() {
  const [headerActions, setHeaderActions] = useState<ReactNode>(null);

  return (
    <HeaderContributionContext.Provider value={setHeaderActions}>
      <PageShell header={<Header actions={headerActions} />}>
        <CaseDetail />
      </PageShell>
    </HeaderContributionContext.Provider>
  );
}

But be careful. Dynamic contribution APIs can become invisible layout mutation. Prefer explicit composition where possible.


7. Pattern 3 — Context for Scoped Dependency

Context is good when the data is naturally scoped to a subtree.

Examples:

theme
density
direction
current route permissions
form field registration
modal service
analytics service
feature commands

A safe context should answer:

what is the provider scope?
what is the value lifecycle?
who may update it?
what rerenders when it changes?
is this data or capability?

Example: scoped permission dependency.

type PermissionContextValue = {
  canApprove: boolean;
  canEscalate: boolean;
  canAssign: boolean;
};

const PermissionContext = createContext<PermissionContextValue | null>(null);

export function usePermissions() {
  const value = useContext(PermissionContext);
  if (!value) {
    throw new Error('usePermissions must be used inside PermissionProvider');
  }
  return value;
}

function CaseRoute({ permissions }: { permissions: PermissionContextValue }) {
  return (
    <PermissionContext.Provider value={permissions}>
      <CasePage />
    </PermissionContext.Provider>
  );
}

Consumer:

function EscalateButton() {
  const { canEscalate } = usePermissions();

  return (
    <button disabled={!canEscalate}>
      Escalate
    </button>
  );
}

This is not “global auth state”. It is route-scoped capability data.


8. Pattern 4 — Capability Provider

Capability provider is a specialized context pattern.

It passes a thing you can do, not a blob of state.

Example: toast.

type ToastCommand = {
  kind: 'success' | 'error' | 'info';
  message: string;
};

type ToastApi = {
  show(command: ToastCommand): void;
};

const ToastContext = createContext<ToastApi | null>(null);

export function useToast() {
  const api = useContext(ToastContext);
  if (!api) {
    throw new Error('useToast must be used inside ToastProvider');
  }
  return api;
}

Provider:

function ToastProvider({ children }: { children: ReactNode }) {
  const [toasts, setToasts] = useState<Array<ToastCommand & { id: string }>>([]);

  const api = useMemo<ToastApi>(() => ({
    show(command) {
      setToasts(current => [
        ...current,
        { ...command, id: crypto.randomUUID() },
      ]);
    },
  }), []);

  return (
    <ToastContext.Provider value={api}>
      {children}
      <ToastViewport toasts={toasts} />
    </ToastContext.Provider>
  );
}

Caller:

function SaveButton() {
  const toast = useToast();

  async function handleSave() {
    await save();
    toast.show({ kind: 'success', message: 'Saved' });
  }

  return <button onClick={handleSave}>Save</button>;
}

The caller does not own toast rendering. It only sends a command to a scoped capability.

This is useful because the caller and host are far apart:


9. Capability Provider vs Event Bus

A capability provider has a named API.

toast.show({ kind: 'success', message: 'Saved' });
modal.confirm({ title: 'Delete case?' });
commandPalette.open();
analytics.track({ name: 'case_saved' });

An event bus often has generic string events.

bus.emit('toast', payload);
bus.emit('modal:open', payload);
bus.emit('case:saved', payload);

The provider API is easier to type, test, refactor, and restrict.

Use capability provider when there is an obvious host/service.

Use event bus only when:

publisher and subscriber are intentionally decoupled
there may be multiple subscribers
the event represents something that happened, not a command to a single host
lifecycle and cleanup are strictly controlled
ordering semantics are documented

This distinction becomes central in Part 042 and Part 045.


10. Pattern 5 — URL State

URL is the correct cross-tree channel when the state is part of navigation identity.

Examples:

selected tab
search text
filter conditions
sort order
pagination cursor
view mode
entity id

If state should survive reload, be shareable, or support browser back/forward, URL is often better than context or store.

function CaseSearchRoute() {
  const [searchParams, setSearchParams] = useSearchParams();

  const filters = parseFilters(searchParams);

  function updateFilters(next: CaseFilters) {
    setSearchParams(serializeFilters(next));
  }

  return (
    <CaseSearchPage
      filters={filters}
      onFiltersChange={updateFilters}
    />
  );
}

Cross-tree benefit:

FilterPanel reads filters
ResultTable reads filters
Breadcrumb reads filters
ExportButton reads filters
Browser history tracks filters

The URL becomes the shared owner.

But URL state must be serialized carefully:

stable key names
versioned encoding for complex filters
default value normalization
avoid encoding sensitive state
avoid huge state blobs

Bad URL state:

?state={massive-json-dump}
?token=secret
?draftComment=private-text

Good URL state:

?status=open&assignee=me&page=2&sort=updated_desc

11. Pattern 6 — Server-State Cache

If the state is owned by the server, cross-tree communication should often happen through a cache layer, not direct component messaging.

Example:

CaseHeader shows case status
CaseTimeline shows latest action
CaseActionPanel mutates case status
CaseList row also displays case status

The action panel should not manually notify every component. It should mutate server state and invalidate/update the relevant cache entries.

Conceptual flow:

The communication channel is not component-to-component. It is server-state invalidation.

This prevents:

manual fan-out updates
stale duplicated data
hidden imperative callbacks
inconsistent views after mutation

12. Pattern 7 — External Store

External store is appropriate when many distant components need to read/write shared client state with finer subscription control than Context.

Examples:

selection model for a large table
collaborative editor local presence
client-side command palette registry
feature-wide UI store
workspace layout state
multi-panel drag state

The store exists outside React; components subscribe to slices.

Conceptual store:

type WorkspaceState = {
  selectedPanelId: string | null;
  sidebarCollapsed: boolean;
};

type Listener = () => void;

function createWorkspaceStore(initial: WorkspaceState) {
  let state = initial;
  const listeners = new Set<Listener>();

  return {
    getSnapshot() {
      return state;
    },
    subscribe(listener: Listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    setState(updater: (state: WorkspaceState) => WorkspaceState) {
      state = updater(state);
      listeners.forEach(listener => listener());
    },
  };
}

Then React integrates through a safe subscription hook.

function useWorkspaceSnapshot() {
  return useSyncExternalStore(
    workspaceStore.subscribe,
    workspaceStore.getSnapshot,
    workspaceStore.getSnapshot,
  );
}

This pattern will be expanded in Parts 060–066.

For now, the rule is:

Use external store when you need shared client state with independent subscriptions.
Do not use it merely to avoid thinking about ownership.

13. Pattern 8 — Portal Communication

Portals complicate intuition because the DOM location changes, but the React owner tree remains the source of truth.

Typical overlay architecture:

The modal may render into document.body, but its state can still be owned by ModalProvider.

Example capability API:

type ModalApi = {
  confirm(options: ConfirmOptions): Promise<boolean>;
};

Usage:

function DeleteButton({ caseId }: { caseId: string }) {
  const modal = useModal();
  const toast = useToast();

  async function handleDelete() {
    const confirmed = await modal.confirm({
      title: 'Delete case?',
      description: 'This cannot be undone.',
    });

    if (!confirmed) return;

    await deleteCase(caseId);
    toast.show({ kind: 'success', message: 'Case deleted' });
  }

  return <button onClick={handleDelete}>Delete</button>;
}

The component does not know where the modal lives. It only knows a capability.

Caveat:

Promise-returning modal APIs are convenient but can hide workflow state.
For complex workflows, model the flow explicitly with reducer/state machine.

14. Pattern 9 — Command Channel

A command channel is narrower than an event bus.

It exposes commands to a known domain/service.

Example:

type WorkspaceCommand =
  | { type: 'openPanel'; panelId: string }
  | { type: 'closePanel'; panelId: string }
  | { type: 'focusSearch' };

type WorkspaceCommands = {
  dispatch(command: WorkspaceCommand): void;
};

Provider:

const WorkspaceCommandContext = createContext<WorkspaceCommands | null>(null);

function WorkspaceProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(workspaceReducer, initialWorkspace);

  const commands = useMemo<WorkspaceCommands>(() => ({
    dispatch,
  }), []);

  return (
    <WorkspaceCommandContext.Provider value={commands}>
      <WorkspaceStateContext.Provider value={state}>
        {children}
      </WorkspaceStateContext.Provider>
    </WorkspaceCommandContext.Provider>
  );
}

A deep component can dispatch intent without reading all state.

function SearchShortcutButton() {
  const { dispatch } = useWorkspaceCommands();

  return (
    <button onClick={() => dispatch({ type: 'focusSearch' })}>
      Focus search
    </button>
  );
}

Why split command context and state context?

command identity can stay stable
command users do not rerender for state changes
readers and writers can be governed separately

This is especially useful for large layout/workspace boundaries.


15. Cross-Tree Communication as Ports and Adapters

A production React app benefits from thinking in ports.

UI component -> intent port -> owner/provider/store -> transition -> new snapshot

Instead of giving components access to implementation details:

const { setModalState, setToasts, setSidebarCollapsed, setSelectedCase } = useAppContext();

Give them narrow ports:

const modal = useModal();
const toast = useToast();
const workspace = useWorkspaceCommands();

This creates auditability:

What can this component do?
What is it allowed to read?
What transitions can happen?
Where is the side effect boundary?

16. Build From Scratch: Scoped Command Host

Let us build a small command host for a route-level action bar.

Goal:

Deep content can contribute actions to route header/footer.
The route owns the slot.
Cleanup happens automatically on unmount.
The API is scoped to the route, not global.

16.1 Define the contract

type ActionContribution = {
  id: string;
  node: ReactNode;
  order?: number;
};

type ActionRegistry = {
  register(action: ActionContribution): () => void;
};

16.2 Create strict context

const ActionRegistryContext = createContext<ActionRegistry | null>(null);

function useActionRegistry() {
  const registry = useContext(ActionRegistryContext);
  if (!registry) {
    throw new Error('useActionRegistry must be used inside ActionRegistryProvider');
  }
  return registry;
}

16.3 Provider owns the registry

function ActionRegistryProvider({ children }: { children: ReactNode }) {
  const [actions, setActions] = useState<ActionContribution[]>([]);

  const registry = useMemo<ActionRegistry>(() => ({
    register(action) {
      setActions(current => {
        const withoutSame = current.filter(item => item.id !== action.id);
        return [...withoutSame, action].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
      });

      return () => {
        setActions(current => current.filter(item => item.id !== action.id));
      };
    },
  }), []);

  return (
    <ActionRegistryContext.Provider value={registry}>
      <RouteShell actions={actions.map(action => action.node)}>
        {children}
      </RouteShell>
    </ActionRegistryContext.Provider>
  );
}

16.4 Deep component contributes action

function useActionContribution(action: ActionContribution) {
  const registry = useActionRegistry();

  useEffect(() => {
    return registry.register(action);
  }, [registry, action]);
}

But action is an object. If created inline, it changes every render.

Better:

function CaseDetailActions({ caseId }: { caseId: string }) {
  const action = useMemo<ActionContribution>(() => ({
    id: `approve-${caseId}`,
    order: 10,
    node: <ApproveCaseButton caseId={caseId} />,
  }), [caseId]);

  useActionContribution(action);

  return null;
}

This is a valid cross-tree pattern when used sparingly.

But if every page region mutates every other region through registries, the architecture becomes implicit and hard to debug.


17. Anti-Pattern: AppContext as Service Locator

Bad:

const app = useAppContext();

app.setCurrentCase(case);
app.setModal({ type: 'delete-case' });
app.setToast({ message: 'Saved' });
app.analytics.track('case_saved');
app.router.navigate('/cases');

This makes every component able to do almost anything.

Problems:

unclear authority
hard to test
accidental rerender fan-out
weak type boundary
no transition audit
no feature ownership
no clear migration path

Better:

const modal = useModal();
const toast = useToast();
const analytics = useAnalytics();
const caseCommands = useCaseCommands();

Even better:

function useApproveCaseFlow(caseId: string) {
  const modal = useModal();
  const toast = useToast();
  const approveCase = useApproveCaseMutation();

  return async function approve() {
    const confirmed = await modal.confirm({ title: 'Approve case?' });
    if (!confirmed) return;

    await approveCase.mutateAsync({ caseId });
    toast.show({ kind: 'success', message: 'Case approved' });
  };
}

Now cross-tree communication is hidden behind a workflow hook with explicit dependencies.


18. Cross-Tree Read vs Cross-Tree Write

Separate reads and writes.

Cross-tree read examples:

read theme
read current permission
read selected workspace
read feature flags
read query data

Cross-tree write examples:

open modal
enqueue toast
mutate server data
dispatch workflow event
update URL param

Reads and writes have different performance and authority concerns.

Pattern:

const WorkspaceStateContext = createContext<WorkspaceState | null>(null);
const WorkspaceCommandsContext = createContext<WorkspaceCommands | null>(null);

Avoid:

const WorkspaceContext = createContext<{
  state: WorkspaceState;
  setState: Dispatch<SetStateAction<WorkspaceState>>;
} | null>(null);

Raw setter access is too broad. It lets random children violate invariants.


19. Choosing the Right Scope

Scope examples:

CoordinationSuggested Scope
Field label/input/error linkageFormField provider
Tabs trigger/contentTabs root provider
Route header actionsRoute shell provider
Modal/toastApp shell provider
Case workflow transitionCase route/workflow provider
Table selectionTable provider or feature store
Query filtersURL/search params
Remote entity dataServer-state cache
Theme/directionApp or design-system provider
Permission snapshotRoute/feature provider

The narrowest scope wins unless there is a concrete reason to widen it.


20. Failure Modes

20.1 Global store used as wiring shortcut

Symptom:

State is global only because two distant components needed it once.

Consequence:

lifecycle becomes app-wide
state survives longer than expected
unrelated screens observe or mutate it

Fix:

move state to route/provider scope
or encode in URL/server cache if lifecycle requires it

20.2 Context with high-frequency updates

Symptom:

mouse position, drag state, scroll position, resize metrics in context

Consequence:

large subtree rerenders too often

Fix:

external store with selective subscription
localize state
throttle updates
use DOM/ref for non-render-critical values

20.3 Command provider becomes event bus

Symptom:

commands.dispatch({ type: 'anything', payload: unknown });

Consequence:

no domain boundary
weak auditability
large switch statements
unclear ownership

Fix:

split capability APIs
use typed domain commands
move workflow into reducer/machine

20.4 Hidden layout mutation

Symptom:

Deep child modifies header/footer/sidebar through contribution registry.

Consequence:

layout cannot be understood from route component
cleanup bugs can leave stale actions

Fix:

prefer explicit slot composition
use registry only when dynamic contribution is essential
ensure cleanup on unmount

20.5 Cross-tree async race

Symptom:

Deep component triggers command; host state changes after caller unmounts.

Consequence:

stale promise continuation
modal resolves into unmounted workflow
lost updates

Fix:

AbortController
mounted guard for non-critical UI continuation
state machine actor lifecycle
idempotent commands

21. Review Checklist

Before approving cross-tree communication code, ask:

What is being communicated: data, intent, command, event, or capability?
Is this state local, route, URL, server, external, or workflow state?
What is the smallest correct owner?
Does the channel make authority explicit?
Does the reader subscribe only to what it needs?
Can this update violate invariants?
Can it leak after unmount?
Is cleanup deterministic?
Is ordering important?
Is the contract typed and named by domain intent?
Can we test the interaction without rendering the whole app?

If the answer is unclear, the pattern is not ready.


22. Practical Refactor Recipes

Recipe A — Prop drilling to scoped context

Before:

<App permission={permission}>
  <Layout permission={permission}>
    <Page permission={permission}>
      <Panel permission={permission}>
        <Button permission={permission} />
      </Panel>
    </Page>
  </Layout>
</App>

After:

<PermissionProvider value={permission}>
  <Layout>
    <Page />
  </Layout>
</PermissionProvider>

Use only when many descendants truly need the same scoped value.

Recipe B — AppContext to capability providers

Before:

const { setToast, setModal, setSidebarOpen } = useAppContext();

After:

const toast = useToast();
const modal = useModal();
const workspace = useWorkspaceCommands();

Recipe C — Shared filters to URL

Before:

const { filters, setFilters } = useSearchStore();

After:

const filters = parseFilters(searchParams);
const setFilters = updateSearchParams;

Recipe D — Remote entity duplication to query cache

Before:

const [caseRecord, setCaseRecord] = useState<CaseRecord | null>(null);

Across multiple components.

After:

const caseQuery = useCaseQuery(caseId);
const approveCase = useApproveCaseMutation();

Mutation updates or invalidates query cache.


23. Summary

Cross-tree communication is not one technique. It is an ownership problem.

Good React architecture does not ask:

How do I make distant components talk?

It asks:

Who owns the thing being coordinated?
What is the lifecycle of that thing?
What channel expresses that lifecycle with the least coupling?

Use props/callbacks when the owner is close. Use composition when the parent should assemble structure. Use Context for scoped dependencies and capabilities. Use URL for addressable navigation state. Use server-state cache for server-owned data. Use external store for high fan-out client state. Use command providers for scoped UI capabilities. Use event bus only when the event is truly decoupled and lifecycle-safe.

Cross-tree communication is production-safe only when authority, ownership, lifecycle, and failure modes are explicit.


References

Lesson Recap

You just completed lesson 41 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.