React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

postMessageiframemessage-busrequest-responsecustom-hookbrowser-apis

Intermediate postMessage Patterns — Request/response, message bus hooks, and multi-iframe routing

Recipe

// Request/response pattern: correlate replies with unique IDs
function sendRequest<T>(
  target: Window,
  origin: string,
  message: { type: string; payload?: unknown },
  timeoutMs = 5000
): Promise<T> {
  return new Promise((resolve, reject) => {
    const id = crypto.randomUUID();
 
    function handleReply(event: MessageEvent) {
      if (event.origin !== origin) return;
      if (event.data?.correlationId !== id) return;
      window.removeEventListener("message", handleReply);
      clearTimeout(timer);
      resolve(event.data.payload as T);
    }
 
    const timer = setTimeout(() => {
      window.removeEventListener("message", handleReply);
      reject(new Error(`postMessage timeout after ${timeoutMs}ms`));
    }, timeoutMs);
 
    window.addEventListener("message", handleReply);
    target.postMessage({ ...message, correlationId: id }, origin);
  });
}

When to reach for this: When simple fire-and-forget messages are not enough. You need correlated request/response pairs, a reusable hook for postMessage communication, bidirectional data flow, or routing messages to specific iframes in a multi-iframe layout.

Working Example

A dashboard embeds a chart widget in an iframe. The parent sends data updates and receives click events back from the chart.

Shared Message Types

// types/messages.ts — shared between parent and iframe
 
export type ParentToChart =
  | { type: "DATA_UPDATE"; correlationId?: string; payload: ChartData }
  | { type: "SET_OPTIONS"; correlationId?: string; payload: ChartOptions }
  | { type: "REQUEST_SELECTION"; correlationId: string };
 
export type ChartToParent =
  | { type: "CHART_CLICK"; payload: { seriesIndex: number; dataIndex: number; value: number } }
  | { type: "CHART_READY" }
  | { type: "SELECTION_RESPONSE"; correlationId: string; payload: SelectedPoint[] }
  | { type: "ERROR"; payload: { message: string } };
 
export interface ChartData {
  labels: string[];
  series: { name: string; values: number[] }[];
}
 
export interface ChartOptions {
  animate: boolean;
  showLegend: boolean;
  colorScheme: "default" | "warm" | "cool";
}
 
export interface SelectedPoint {
  seriesName: string;
  label: string;
  value: number;
}

usePostMessage Hook

// hooks/usePostMessage.ts
import { useEffect, useCallback, useRef } from "react";
 
type MessageHandler<T = unknown> = (
  data: T,
  event: MessageEvent
) => void;
 
interface UsePostMessageOptions {
  /** Allowed origins — messages from other origins are silently dropped */
  allowedOrigins: string[];
  /** Optional filter: only handle messages where data.type matches */
  messageTypes?: string[];
}
 
export function usePostMessage<TIncoming = unknown>(
  handler: MessageHandler<TIncoming>,
  options: UsePostMessageOptions
) {
  const handlerRef = useRef(handler);
  handlerRef.current = handler;
 
  const originsRef = useRef(options.allowedOrigins);
  originsRef.current = options.allowedOrigins;
 
  const typesRef = useRef(options.messageTypes);
  typesRef.current = options.messageTypes;
 
  useEffect(() => {
    function onMessage(event: MessageEvent) {
      // Origin allowlist check
      if (!originsRef.current.includes(event.origin)) return;
 
      // Optional type filter
      const types = typesRef.current;
      if (types && types.length > 0) {
        const msgType = event.data?.type;
        if (!types.includes(msgType)) return;
      }
 
      handlerRef.current(event.data as TIncoming, event);
    }
 
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, []); // Stable: refs handle updates without re-subscribing
 
  // Send helper
  const send = useCallback(
    (target: Window, message: unknown, origin: string) => {
      target.postMessage(message, origin);
    },
    []
  );
 
  return { send };
}

Parent Dashboard

import { useRef, useState, useCallback } from "react";
import { usePostMessage } from "./hooks/usePostMessage";
import type { ParentToChart, ChartToParent, ChartData } from "./types/messages";
 
const CHART_ORIGIN = "https://charts.example.com";
 
function Dashboard() {
  const chartRef = useRef<HTMLIFrameElement>(null);
  const [chartReady, setChartReady] = useState(false);
  const [lastClick, setLastClick] = useState<string | null>(null);
 
  const { send } = usePostMessage<ChartToParent>(
    useCallback((data, event) => {
      switch (data.type) {
        case "CHART_READY":
          setChartReady(true);
          break;
 
        case "CHART_CLICK":
          setLastClick(
            `Series ${data.payload.seriesIndex}, ` +
            `point ${data.payload.dataIndex}: ${data.payload.value}`
          );
          break;
 
        case "SELECTION_RESPONSE":
          console.log("Selected points:", data.payload);
          break;
 
        case "ERROR":
          console.error("Chart error:", data.payload.message);
          break;
      }
    }, []),
    { allowedOrigins: [CHART_ORIGIN] }
  );
 
  function sendData(data: ChartData) {
    if (!chartRef.current?.contentWindow) return;
    const msg: ParentToChart = { type: "DATA_UPDATE", payload: data };
    send(chartRef.current.contentWindow, msg, CHART_ORIGIN);
  }
 
  // Request/response: ask the chart for current selection
  async function getSelection() {
    if (!chartRef.current?.contentWindow) return;
    try {
      const result = await sendRequest<{ payload: unknown }>(
        chartRef.current.contentWindow,
        CHART_ORIGIN,
        { type: "REQUEST_SELECTION" },
        3000
      );
      console.log("Selection:", result);
    } catch (err) {
      console.error("Selection request timed out");
    }
  }
 
  return (
    <div>
      <h1>Dashboard</h1>
      <div style={{ display: "flex", gap: 8 }}>
        <button
          disabled={!chartReady}
          onClick={() =>
            sendData({
              labels: ["Jan", "Feb", "Mar"],
              series: [{ name: "Revenue", values: [100, 150, 130] }],
            })
          }
        >
          Send Data
        </button>
        <button disabled={!chartReady} onClick={getSelection}>
          Get Selection
        </button>
      </div>
      {lastClick && <p>Last click: {lastClick}</p>}
      <iframe
        ref={chartRef}
        src={`${CHART_ORIGIN}/chart-widget`}
        title="Chart Widget"
        style={{ width: "100%", height: 400, border: "1px solid #e2e8f0" }}
      />
    </div>
  );
}

Chart Widget (in iframe)

import { useEffect, useCallback, useState } from "react";
import { usePostMessage } from "./hooks/usePostMessage";
import type { ParentToChart, ChartToParent, ChartData, SelectedPoint } from "./types/messages";
 
const PARENT_ORIGIN = "https://dashboard.example.com";
 
function ChartWidget() {
  const [data, setData] = useState<ChartData | null>(null);
  const [selected, setSelected] = useState<SelectedPoint[]>([]);
 
  const { send } = usePostMessage<ParentToChart>(
    useCallback((msg, event) => {
      const reply = (response: ChartToParent) => {
        (event.source as Window).postMessage(response, event.origin);
      };
 
      switch (msg.type) {
        case "DATA_UPDATE":
          setData(msg.payload);
          break;
 
        case "SET_OPTIONS":
          // apply chart options...
          break;
 
        case "REQUEST_SELECTION":
          // Respond with current selection, preserving correlationId
          reply({
            type: "SELECTION_RESPONSE",
            correlationId: msg.correlationId,
            payload: selected,
          });
          break;
      }
    }, [selected]),
    { allowedOrigins: [PARENT_ORIGIN] }
  );
 
  // Notify parent we are ready
  useEffect(() => {
    if (!window.parent || window.parent === window) return;
    const msg: ChartToParent = { type: "CHART_READY" };
    window.parent.postMessage(msg, PARENT_ORIGIN);
  }, []);
 
  function handleBarClick(seriesIndex: number, dataIndex: number, value: number) {
    const clickMsg: ChartToParent = {
      type: "CHART_CLICK",
      payload: { seriesIndex, dataIndex, value },
    };
    window.parent.postMessage(clickMsg, PARENT_ORIGIN);
  }
 
  if (!data) return <p>Waiting for data...</p>;
 
  return (
    <div style={{ padding: 16 }}>
      <h3>Chart Widget</h3>
      {data.series.map((series, si) => (
        <div key={series.name}>
          <h4>{series.name}</h4>
          <div style={{ display: "flex", gap: 4, alignItems: "flex-end", height: 200 }}>
            {series.values.map((val, di) => (
              <div
                key={di}
                onClick={() => handleBarClick(si, di, val)}
                style={{
                  width: 40,
                  height: `${(val / Math.max(...series.values)) * 100}%`,
                  background: "#3b82f6",
                  cursor: "pointer",
                  display: "flex",
                  alignItems: "flex-end",
                  justifyContent: "center",
                  color: "white",
                  fontSize: 12,
                  paddingBottom: 4,
                }}
              >
                {val}
              </div>
            ))}
          </div>
          <div style={{ display: "flex", gap: 4 }}>
            {data.labels.map((label) => (
              <div key={label} style={{ width: 40, textAlign: "center", fontSize: 11 }}>
                {label}
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

Deep Dive

Request/Response with Correlation IDs

  • Fire-and-forget postMessage is insufficient when you need a return value. The solution is to attach a unique correlationId to each request and have the responder echo it back.
  • Use crypto.randomUUID() for IDs. It is available in all modern browsers and is cryptographically random.
  • Always implement a timeout. The iframe may never respond (crashed, wrong origin, blocked by CSP). Without a timeout, your promise hangs forever.
  • Clean up the event listener in both the resolve and reject paths to prevent memory leaks.

How the usePostMessage Hook Works

  • The hook stores the handler in a ref so the effect closure never goes stale. This means you can reference current state in your handler without re-subscribing the listener.
  • Origin checking happens inside the effect, using a ref for the allowlist, so updating the allowlist does not tear down and re-add the listener.
  • The send helper is a stable callback (wrapped in useCallback with empty deps) so it can be passed as a prop without causing re-renders.
  • The optional messageTypes filter lets you scope a component's handler to only relevant message types, which keeps handlers focused.

Multi-Iframe Routing

  • When your page embeds multiple iframes, every message listener fires for every postMessage from every iframe. You need to route messages to the right handler.
  • Strategy 1: Check event.source against iframe refs. This is the most reliable approach.
  • Strategy 2: Add a source or channel field to your message protocol so handlers can filter by logical source.
  • Strategy 3: Use MessageChannel (covered in the advanced guide) to create a dedicated port per iframe.
function useIframeMessage(
  iframeRef: React.RefObject<HTMLIFrameElement | null>,
  origin: string,
  handler: (data: unknown) => void
) {
  const handlerRef = useRef(handler);
  handlerRef.current = handler;
 
  useEffect(() => {
    function onMessage(event: MessageEvent) {
      if (event.origin !== origin) return;
      // Only handle messages from THIS specific iframe
      if (event.source !== iframeRef.current?.contentWindow) return;
      handlerRef.current(event.data);
    }
    window.addEventListener("message", onMessage);
    return () => window.removeEventListener("message", onMessage);
  }, [origin, iframeRef]);
}
 
// Usage: each iframe gets its own scoped handler
function MultiIframePage() {
  const chartRef = useRef<HTMLIFrameElement>(null);
  const formRef = useRef<HTMLIFrameElement>(null);
 
  useIframeMessage(chartRef, "https://charts.example.com", (data) => {
    console.log("From chart:", data);
  });
 
  useIframeMessage(formRef, "https://forms.example.com", (data) => {
    console.log("From form:", data);
  });
 
  return (
    <>
      <iframe ref={chartRef} src="https://charts.example.com/widget" title="Chart" />
      <iframe ref={formRef} src="https://forms.example.com/widget" title="Form" />
    </>
  );
}

Serialization: What Can and Cannot Be Sent

The structured clone algorithm supports more types than JSON but still has limits:

SupportedNot Supported
Primitives (string, number, boolean, null, undefined)Functions
Plain objects and arraysDOM nodes (Element, Document, etc.)
DateSymbol
Map, SetWeakMap, WeakSet
RegExpClass instances (prototype is lost)
ArrayBuffer, typed arrays (Uint8Array, etc.)Error objects (in some browsers)
Blob, File, FileListGetters, setters, property descriptors
ImageBitmap, ImageDataProxy objects
Nested objects with circular referencesClosures

Key details:

  • Class instances lose their prototype. A class User { getName() {} } instance becomes a plain object on the other side. Only own enumerable properties survive.
  • Circular references are handled. Unlike JSON.stringify, structured clone can serialize objects with cycles.
  • ArrayBuffer is copied by default. Use the transfer parameter to transfer ownership (zero-copy) instead. See the advanced guide.
  • Error objects have inconsistent cloning support across browsers. Send { message: error.message, stack: error.stack } instead.

Gotchas

  • No built-in request/response. postMessage is fire-and-forget. You must build correlation yourself. Forgetting the timeout in your request/response wrapper will cause promise leaks.
  • Stale closures in handlers. If your message handler references React state directly inside a useEffect, you will read stale values. Use a ref for the handler function (as shown in the hook) or include state in the dependency array (which re-subscribes the listener on every change).
  • Message ordering is guaranteed for messages sent from the same source to the same target. But if you have multiple iframes sending to the parent, the interleaving order between iframes is not guaranteed.
  • contentWindow is null until the iframe element is in the DOM and has started loading. Always null-check before calling postMessage.
  • Iframe navigation resets the connection. If the iframe navigates to a new page, contentWindow still exists but now points to the new document. Any state in the old document is gone. You need the new page to send a fresh READY message.
  • Serialization failures are silent. If you accidentally include a function in your message payload, postMessage throws a DataCloneError. This can be surprising because the error happens on the sender side, not the receiver.
  • Browser extensions inject iframes. Your global message listener may receive messages from extension iframes you did not create. Always validate origin and message shape.

Alternatives

ApproachWhen to Use
MessageChannelDedicated bidirectional port for exactly two endpoints (no broadcast, no origin checking needed after setup)
BroadcastChannelSame-origin fan-out to all tabs and windows (no cross-origin)
Comlink (library)High-level RPC over postMessage for workers and iframes; hides the protocol entirely
iframe src URL paramsOne-time initial config (no ongoing communication)
Shared state via serverWhen iframes are on different domains and need persistent shared state

FAQs

Why do you need correlation IDs for postMessage request/response patterns?
  • postMessage is fire-and-forget with no built-in return value.
  • A unique correlationId (via crypto.randomUUID()) lets you match a response to the original request.
  • Without it, you cannot tell which response belongs to which request when multiple are in flight.
What happens if you forget to add a timeout to your request/response wrapper?
  • The promise hangs forever if the iframe never responds (crashed, wrong origin, blocked by CSP).
  • Always implement a timeout that cleans up the event listener and rejects the promise.
How does the usePostMessage hook avoid stale closure issues?
  • The handler is stored in a useRef, updated on every render via handlerRef.current = handler.
  • The useEffect closure reads from the ref, so it always calls the latest handler without re-subscribing the listener.
How do you type the usePostMessage hook generically in TypeScript?
export function usePostMessage<TIncoming = unknown>(
  handler: (data: TIncoming, event: MessageEvent) => void,
  options: { allowedOrigins: string[] }
) {
  // TIncoming narrows event.data inside the handler
}
What are three strategies for routing messages when you have multiple iframes?
  • Check event.source against iframe refs to identify which iframe sent the message.
  • Add a source or channel field to your message protocol for logical routing.
  • Use MessageChannel to create a dedicated port per iframe (covered in the advanced guide).
Gotcha: What happens to class instances when sent through postMessage?
  • Class instances lose their prototype during structured cloning. Only own enumerable properties survive.
  • A class User { getName() {} } instance becomes a plain object on the receiving side.
  • Send plain objects or reconstruct instances on the receiver.
Are messages ordered when sent from multiple iframes to a single parent?
  • Messages from the same source to the same target are guaranteed to arrive in order.
  • Messages from different iframes to the same parent can interleave in any order.
What is a DataCloneError and when does it occur?
  • It is thrown on the sender side when you include a non-cloneable value (like a function) in the message payload.
  • It can be surprising because the error happens at postMessage(), not on the receiver.
  • Use structuredClone() locally to test if your payload is cloneable.
Gotcha: Why does contentWindow become unreliable after an iframe navigates?
  • If the iframe navigates to a new page, contentWindow still exists but points to the new document.
  • All state in the old document is gone. You need the new page to send a fresh READY message.
How do you make the send helper from usePostMessage stable across re-renders?
const send = useCallback(
  (target: Window, message: unknown, origin: string) => {
    target.postMessage(message, origin);
  },
  [] // empty deps = stable reference
);
What TypeScript type should you use for the messageTypes filter option?
  • Use string[] for the allowed message type names.
  • The hook filters event.data?.type against this array before invoking the handler.
  • This scopes each component's handler to only relevant messages.
Why might your message listener fire for messages you did not send?
  • Browser extensions inject iframes and content scripts that can call window.postMessage.
  • Your global message listener receives messages from extension iframes.
  • Always validate both origin and message shape (the type field).