RPC and events
Try it
Section titled “Try it”This iframe is a separate page. The buttons call its methods; its buttons call this page and emit events. Everything goes over the real handshake.
Describe both sides
Section titled “Describe both sides”A contract names one side’s methods and the events it emits. Share it between both code bases:
import type { Side } from 'react-iframe-kit';
export type ParentSide = Side<{ methods: { navigate(path: string): void; getUser(): User }; events: { themeChanged: 'light' | 'dark'; closed: void };}>;
export type ChildSide = Side<{ methods: { setTheme(theme: 'light' | 'dark'): void }; events: { submitted: { id: string } };}>;- Every remote method returns a promise:
getUser(): Useris called asawait remote.getUser(). - An event with a
voidpayload is emitted without one:emit('closed'). - Values are copied with the structured clone algorithm. So
Side<>makes functions in arguments, return values or payloads a compile error, andthen/toJSONcan’t be method names. Some losses can’t be expressed in types: class instances arrive as plain objects, and getters and symbol keys are dropped.
Generic arguments always come in the order <Remote, Local>: the other side first.
Parent
Section titled “Parent”import { useIframeEvent, useIframeRPC } from 'react-iframe-kit';
const { remote, emit, status, error } = useIframeRPC<ChildSide, ParentSide>(ref, { origin: 'https://widget.example.com', // optional: defaults to the origin of src methods: { navigate, getUser }, timeout: 10_000, // per call, from send to result connectTimeout: 30_000, // how long a call may wait for the connection});
await remote.setTheme('dark');emit('themeChanged', 'dark');
useIframeEvent<ChildSide, 'submitted'>(ref, 'submitted', ({ id }) => save(id));status is 'idle' (no iframe element yet), 'connecting', 'connected', 'timeout'
or 'error'. On 'error', error holds the configuration problem, such as
RIK_ORIGIN_CONFLICT or RIK_METHOD_CONFLICT. Hooks never throw.
'timeout' means the iframe has been 'connecting' for longer than connectTimeout,
which is where to show “the widget didn’t load” instead of a spinner. It isn’t final: if
the handshake completes later, status moves on to 'connected'. For an iframe with
loading="lazy" the time counts from its first load, so an iframe below the fold
doesn’t time out before it starts loading. Queued calls still count from the moment they
were made; pass a longer connectTimeout (or Infinity) for lazy iframes you call early.
if (status === 'timeout') return <a href={widgetUrl} target="_blank">Open the widget</a>;Without React:
import { connectToParent } from 'react-iframe-kit/child';
const parent = connectToParent<ParentSide, ChildSide>({ allowedOrigins: ['https://app.example.com'], // required methods: { setTheme }, autoResize: true,});
const user = await parent.remote.getUser();const off = parent.on('themeChanged', applyTheme);parent.emit('submitted', { id });await parent.whenConnected();parent.dispose();With React:
import { useParent, useParentEvent } from 'react-iframe-kit/child/react';
const { remote, emit, status } = useParent<ParentSide, ChildSide>({ allowedOrigins: ['https://app.example.com'], methods: { setTheme },});useParentEvent<ParentSide, 'themeChanged'>('themeChanged', applyTheme);Several connectToParent/useParent calls on one page share a single connection. Their
methods are merged, and registering one name twice is RIK_METHOD_CONFLICT.
Behaviour worth knowing
Section titled “Behaviour worth knowing”-
Calls before connecting are queued, in order with events, and flushed on connect. A queued call rejects with
RIK_TIMEOUT(phase: 'connect') afterconnectTimeout. -
timeoutstarts when the call is actually sent, so a slow iframe load doesn’t eat into it. -
Per call:
withOptions(remote.method, { timeout, signal }).timeout: Infinityis allowed, e.g. for “open a dialog and resolve with the choice”. An aborted signal rejects withsignal.reason.import { withOptions } from 'react-iframe-kit';const choice = await withOptions(remote.confirm, { timeout: Infinity })('Delete?'); -
Errors thrown by a remote method arrive as
RemoteError. Itscausehas the remotename,message,codeand owndata:try {await remote.save(draft);} catch (error) {if (error instanceof RemoteError && error.cause.code === 'E_CONFLICT') retry();} -
Losing the connection (the iframe reloads or navigates) rejects the calls that were already sent with
RIK_CONNECTION_LOST. Queued calls wait for the next connection. -
Events aren’t buffered on the receiving side: an event with no handler registered is dropped.
-
remoteandemitkeep the same identity across renders, and the latestmethodsand handlers are always used without reconnecting. -
Large binary data:
transfer(value, [buffer])moves anArrayBuffer,MessagePortor similar instead of copying it.import { transfer } from 'react-iframe-kit';await remote.upload(transfer(buffer, [buffer])); -
Debugging:
debug: trueon any hook orconnectToParentlogs every protocol message. In development each line starts with a summary, and a result names the call it answers and how long it took:react-iframe-kit ← result #q9x7c1 ok (getUser, 12 ms). -
Redux DevTools: with the browser extension installed,
connectReduxDevTools()fromreact-iframe-kit/devtoolsshows every message on the page as an action you can search, filter and inspect, whether or notdebugis on. For your own tooling,onProtocolMessage(listener)gives you the same stream.import { connectReduxDevTools } from 'react-iframe-kit/devtools';if (import.meta.env.DEV) connectReduxDevTools();
Validating what arrives
Section titled “Validating what arrives”A contract is only types: nothing checks, at runtime, that the other page sent what it
says. Across origins that page may be buggy, out of date, or hostile, so validate
arguments and payloads where it matters. react-iframe-kit/validate wraps a method or a
handler with any Standard Schema library (Zod, Valibot,
ArkType, …) (next release):
import { validateArgs, validatePayload } from 'react-iframe-kit/validate';import { z } from 'zod';
const methods = { // The schema checks the arguments as a tuple; the method gets its output. prefill: validateArgs(z.tuple([z.object({ email: z.email(), promo: z.string().max(32) })]), (data) => applyPrefill(data)),};
widget.on('orderCompleted', validatePayload(Order, (order) => track(order)));- Invalid arguments reject the call with
RIK_VALIDATION. The caller gets aRemoteErrorwhosecause.codeis'RIK_VALIDATION'and whosecause.datalists the issues as{ message, path }. The method isn’t called. - An invalid payload isn’t delivered; the error goes to
reportError, where the console and error trackers see it, like an error thrown by a handler. - The method or handler receives the schema’s output, so transforms and defaults apply.
- The entry is 0.7 kB and imports nothing else from the library; the schema library is yours.
The iframe’s title
Section titled “The iframe’s title”Screen readers announce an iframe by its title. The page inside knows what it shows
best, so it can send its document.title, and the parent passes it on:
// the page inside the iframeconnectToParent({ allowedOrigins: ['https://app.example.com'], syncTitle: true });// the parentconst title = useIframeTitle(iframe);return <iframe ref={setIframe} title={title ?? 'Checkout'} src={url} />;The page opts in because a title can hold private data. useIframeTitle follows every
change, and returns null until the title arrives, when it’s empty, and after the
page unloads: keep a fallback.