Patrones Intermedios de postMessage — Request/response, message bus hooks, y enrutamiento multi-iframe
Receta
// Patrón Request/response: correlaciona respuestas con IDs únicos
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);
});
}Cuándo usarlo: Cuando los mensajes simples fire-and-forget no son suficientes. Necesitas pares request/response correlacionados, un hook reutilizable para comunicación postMessage, flujo de datos bidireccional, o enrutamiento de mensajes a iframes específicos en un layout multi-iframe.
Ejemplo Funcional
Un dashboard integra un widget de gráfico en un iframe. El padre envía actualizaciones de datos y recibe eventos de clic del gráfico.
Tipos de Mensaje Compartidos
// types/messages.ts — compartido entre padre e 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;
}Hook usePostMessage
// hooks/usePostMessage.ts
import { useEffect, useCallback, useRef } from "react";
type MessageHandler<T = unknown> = (
data: T,
event: MessageEvent
) => void;
interface UsePostMessageOptions {
/** Orígenes permitidos — los mensajes de otros orígenes se descartan silenciosamente */
allowedOrigins: string[];
/** Filtro opcional: solo maneja mensajes donde data.type coincide */
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) {
// Verificación de lista blanca de origen
if (!originsRef.current.includes(event.origin)) return;
// Filtro de tipo opcional
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);
}, []); // Estable: refs manejan actualizaciones sin re-suscribirse
// Ayudante Send
const send = useCallback(
(target: Window, message: unknown, origin: string) => {
target.postMessage(message, origin);
},
[]
);
return { send };
}Dashboard Padre
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: pide al gráfico la selección actual
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>
);
}Widget de Gráfico (en 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":
// aplicar opciones del gráfico...
break;
case "REQUEST_SELECTION":
// Responde con la selección actual, preservando correlationId
reply({
type: "SELECTION_RESPONSE",
correlationId: msg.correlationId,
payload: selected,
});
break;
}
}, [selected]),
{ allowedOrigins: [PARENT_ORIGIN] }
);
// Notifica al padre que estamos listos
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>Esperando datos...</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>
);
}Análisis Profundo
Request/Response con IDs de Correlación
- Fire-and-forget postMessage es insuficiente cuando necesitas un valor de retorno. La solución es adjuntar un
correlationIdúnico a cada solicitud y hacer que el respondedor lo devuelva. - Usa
crypto.randomUUID()para IDs. Está disponible en todos los navegadores modernos y es criptográficamente aleatorio. - Siempre implementa un timeout. El iframe puede nunca responder (se bloqueó, origen incorrecto, bloqueado por CSP). Sin un timeout, tu promise se cuelga para siempre.
- Limpia el listener de eventos en ambos caminos resolve y reject para evitar fugas de memoria.
Cómo Funciona el Hook usePostMessage
- El hook almacena el handler en un ref para que el cierre del effect nunca quede obsoleto. Esto significa que puedes referenciar el estado actual en tu handler sin re-suscribir el listener.
- La verificación de origen ocurre dentro del effect, usando un ref para la lista blanca, por lo que actualizar la lista blanca no destruye y re-agrega el listener.
- El helper
sendes un callback estable (envuelto enuseCallbackcon deps vacías) para que pueda pasarse como prop sin causar re-renders. - El filtro opcional
messageTypeste permite limitar el handler de un componente solo a tipos de mensaje relevantes, lo que mantiene los handlers enfocados.
Enrutamiento Multi-Iframe
- Cuando tu página integra múltiples iframes, cada listener de
messagese activa para cada postMessage de cada iframe. Necesitas enrutar mensajes al handler correcto. - Estrategia 1: Verificar
event.sourcecontra refs de iframe. Este es el enfoque más confiable. - Estrategia 2: Agregar un campo
sourceochannela tu protocolo de mensaje para que los handlers filtren por origen lógico. - Estrategia 3: Usar MessageChannel (cubierto en la guía avanzada) para crear un puerto dedicado por 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;
// Solo maneja mensajes de ESTE iframe específico
if (event.source !== iframeRef.current?.contentWindow) return;
handlerRef.current(event.data);
}
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [origin, iframeRef]);
}
// Uso: cada iframe obtiene su propio handler con scope
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" />
</>
);
}Serialización: Qué Se Puede y Qué No Se Puede Enviar
El algoritmo de structured clone soporta más tipos que JSON pero aún tiene límites:
| Soportado | No Soportado |
|---|---|
| Primitivos (string, number, boolean, null, undefined) | Funciones |
| Objetos planos y arrays | Nodos DOM (Element, Document, etc.) |
Date | Symbol |
Map, Set | WeakMap, WeakSet |
RegExp | Instancias de clase (se pierde el prototipo) |
ArrayBuffer, typed arrays (Uint8Array, etc.) | Objetos Error (en algunos navegadores) |
Blob, File, FileList | Getters, setters, property descriptors |
ImageBitmap, ImageData | Objetos Proxy |
| Objetos anidados con referencias circulares | Closures |
Detalles clave:
- Las instancias de clase pierden su prototipo. Una instancia
class User { getName() {} }se convierte en un objeto plano al otro lado. Solo las propiedades propias enumerables sobreviven. - Las referencias circulares se manejan. A diferencia de
JSON.stringify, structured clone puede serializar objetos con ciclos. ArrayBufferse copia por defecto. Usa el parámetrotransferpara transferir la propiedad (zero-copy) en su lugar. Ve la guía avanzada.- Objetos
Errortienen soporte de clonación inconsistente entre navegadores. Envía{ message: error.message, stack: error.stack }en su lugar.
Trampas
- Sin request/response incorporado. postMessage es fire-and-forget. Debes construir la correlación tú mismo. Olvidar el timeout en tu wrapper request/response causará fugas de promises.
- Cierre obsoleto en handlers. Si tu handler de mensaje referencia estado React directamente dentro de un
useEffect, leerás valores obsoletos. Usa un ref para la función handler (como se muestra en el hook) o incluye el estado en el array de dependencias (lo que re-suscribe el listener en cada cambio). - El orden de los mensajes está garantizado para mensajes enviados desde la misma fuente al mismo destino. Pero si tienes múltiples iframes enviando al padre, el orden de intercalación entre iframes no está garantizado.
contentWindowes null hasta que el elemento iframe está en el DOM y ha comenzado a cargar. Siempre verifica null antes de llamar apostMessage.- La navegación del iframe reinicia la conexión. Si el iframe navega a una nueva página,
contentWindowaún existe pero ahora apunta al nuevo documento. Cualquier estado en el documento anterior se ha ido. Necesitas que la nueva página envíe un mensajeREADYactualizado. - Los fallos de serialización son silenciosos. Si accidentalmente incluyes una función en tu payload de mensaje,
postMessagelanza unDataCloneError. Esto puede ser sorprendente porque el error ocurre en el lado del remitente, no del receptor. - Las extensiones del navegador inyectan iframes. Tu listener de
messageglobal puede recibir mensajes de iframes de extensión que no creaste. Siempre valida origen y forma del mensaje.
Alternativas
| Enfoque | Cuándo Usar |
|---|---|
| MessageChannel | Puerto bidireccional dedicado para exactamente dos endpoints (sin broadcast, sin verificación de origen necesaria después de la configuración) |
| BroadcastChannel | Fan-out del mismo origen a todas las pestañas y ventanas (sin cross-origin) |
| Comlink (librería) | RPC de alto nivel sobre postMessage para workers e iframes; oculta el protocolo completamente |
Parámetros de URL de iframe src | Configuración inicial única (sin comunicación continua) |
| Estado compartido vía servidor | Cuando los iframes están en dominios diferentes y necesitan estado compartido persistente |
Preguntas Frecuentes
¿Por qué necesitas IDs de correlación para patrones request/response de postMessage?
postMessagees fire-and-forget sin valor de retorno incorporado.- Un
correlationIdúnico (víacrypto.randomUUID()) te permite emparejar una respuesta con la solicitud original. - Sin él, no puedes saber qué respuesta pertenece a qué solicitud cuando múltiples están en vuelo.
¿Qué pasa si olvidas agregar un timeout a tu wrapper request/response?
- La promise se cuelga para siempre si el iframe nunca responde (se bloqueó, origen incorrecto, bloqueado por CSP).
- Siempre implementa un timeout que limpie el listener de eventos y rechace la promise.
¿Cómo el hook usePostMessage evita problemas de cierre obsoleto?
- El handler se almacena en un
useRef, actualizado en cada render víahandlerRef.current = handler. - El cierre de
useEffectlee del ref, por lo que siempre llama al handler más reciente sin re-suscribir el listener.
¿Cómo tipas el hook usePostMessage genéricamente en TypeScript?
export function usePostMessage<TIncoming = unknown>(
handler: (data: TIncoming, event: MessageEvent) => void,
options: { allowedOrigins: string[] }
) {
// TIncoming limita event.data dentro del handler
}¿Cuáles son tres estrategias para enrutar mensajes cuando tienes múltiples iframes?
- Verificar
event.sourcecontra refs de iframe para identificar qué iframe envió el mensaje. - Agregar un campo
sourceochannela tu protocolo de mensaje para enrutamiento lógico. - Usar
MessageChannelpara crear un puerto dedicado por iframe (cubierto en la guía avanzada).
Trampa: ¿Qué sucede con las instancias de clase cuando se envían a través de postMessage?
- Las instancias de clase pierden su prototipo durante structured cloning. Solo las propiedades propias enumerables sobreviven.
- Una instancia
class User { getName() {} }se convierte en un objeto plano en el lado receptor. - Envía objetos planos o reconstruye instancias en el receptor.
¿Se ordenan los mensajes cuando se envían desde múltiples iframes a un único padre?
- Los mensajes desde la misma fuente al mismo destino están garantizados de llegar en orden.
- Los mensajes desde diferentes iframes al mismo padre pueden intercalarse en cualquier orden.
¿Qué es un DataCloneError y cuándo ocurre?
- Se lanza en el lado del remitente cuando incluyes un valor no-clonable (como una función) en el payload del mensaje.
- Puede ser sorprendente porque el error ocurre en
postMessage(), no en el receptor. - Usa
structuredClone()localmente para probar si tu payload es clonable.
Trampa: ¿Por qué contentWindow se vuelve poco confiable después de que un iframe navega?
- Si el iframe navega a una nueva página,
contentWindowaún existe pero apunta al nuevo documento. - Todo el estado en el documento anterior se ha ido. Necesitas que la nueva página envíe un mensaje
READYactualizado.
¿Cómo haces el helper send de usePostMessage estable entre re-renders?
const send = useCallback(
(target: Window, message: unknown, origin: string) => {
target.postMessage(message, origin);
},
[] // empty deps = referencia estable
);¿Qué tipo TypeScript deberías usar para la opción de filtro messageTypes?
- Usa
string[]para los nombres de tipos de mensaje permitidos. - El hook filtra
event.data?.typecontra este array antes de invocar el handler. - Esto limita el handler de cada componente solo a mensajes relevantes.
¿Por qué tu listener de mensaje puede dispararse para mensajes que no enviaste?
- Las extensiones del navegador inyectan iframes y content scripts que pueden llamar a
window.postMessage. - Tu listener global de
messagerecibe mensajes de iframes de extensión. - Siempre valida tanto origen como forma del mensaje (el campo
type).
Relacionado
- postMessage Fundamentals — Anatomía de MessageEvent, conceptos básicos de validación de origen, protocolos tipados
- Advanced postMessage Patterns — MessageChannel, transferibles, arquitectura micro-frontend, capa RPC
- postMessage Security — Trampas de validación de origen, CSP, sandboxing
Videos Relacionados
How to Send Data From iframe To Parent Page — JavaScript postMessage Tutorial
Window postMessage() protocol using React - #1 JavaScript Tutorials