How I actually implemented postMessage communication between React and an iframe

How I actually implemented postMessage communication between React and an iframe image 1

When you run a React app, you end up needing an iframe more often than you'd expect. Especially when you have to embed a page built separately in plain JS a while ago, game content, or an external widget, an iframe is close to the only option.

The project I was working on also included game content, and the game was a standalone build made with only HTML, CSS and JS. It wasn't something I could attach directly inside React, so I ended up wrapping it in an iframe and showing it that way.

But the problem didn't end there. If all I had to do was display it, a single iframe tag would have been enough, but in practice I kept running into cases where the game had to send a completion signal to its parent, React, after it finished, or where React had to pass data into the game.

In this post I'll write down how I set up message passing between the iframe and React, and why I think this approach is fine as a stopgap but not a structure to keep for long.

When do you use this?

The browser blocks different documents from touching each other's variables directly. So for the inside and outside of an iframe to talk, the only way is to send a message with window.postMessage and have the receiving side listen for the message event. The four snippets below are all there is to it.

Sending a message from React to the iframe

On the React side, I put a ref on the iframe and call postMessage on its contentWindow. It has to be sent after the game has fully loaded, so I hooked it to onLoad.

const AlphabetGame = () => {
  const sendMessage = (message: string) => {
    const win = iframeRef.current?.contentWindow;
    if (!win) return;
    win.postMessage({ type: message }, 'http://your-domain');
    console.log('Sent to iframe:', message);
  };

  // send it after the iframe has fully loaded, or the inside can't receive it
  const handleIframeLoad = () => {
    sendMessage('react');
  };

  return (
    <div className='flex w-[100%] h-[100vh]'>
      <iframe
        ref={iframeRef}
        onLoad={handleIframeLoad}
        className='w-[100%] h-[100%]'
        draggable="false"
        src={'http://your-domain/' + resrcFile.fpath + '/' + resrcFile.fname + '/index.html?id=' + id + '&lang=' + lang}
      ></iframe>
    </div>
  );
}

The reason I send a type: "react" message first comes up again below.

Receiving a message from the iframe in React

The opposite direction is handled by listening for the message event on window. I split the work by the type the game sends. If the mission is over it goes back to the previous screen, and if the game asks for the next activity it calls the API, and so on.

useEffect(() => {
  const handleMessage = (event: MessageEvent) => {
    console.log(event.data);
    if (event.data?.type === "closeMission") {
      console.log("closeMission received");
      navigate(-1);
    } else if (event.data?.type === "nextactivity") {
      getNextActivity().then((res) => {
        console.log(res);
      });
    } else if (event.data?.type === "getMyInfo") {
      getMyInfo(user.id || '-1').then((res) => {
        console.log(res);
      });
    } else if (event.data?.type === "addActivityLog") {
      addActivityLog(event.data?.id, event.data?.step).then((res) => {
        console.log(res);
      });
    }
  };

  window.addEventListener("message", handleMessage);
  return () => window.removeEventListener("message", handleMessage);
}, []);

The listener has to be removed when the component goes away. If it isn't, one more listener piles up every time you move between screens, and the same handling runs several times for a single message.

Receiving a message from React inside the iframe

The game side isn't React, it's just plain JS, so it attaches a listener straight to window. Here it only checks whether the type React sent is "react" and stores it in a variable.

window.addEventListener('message', function (event) {
  console.log('postMessage received:', event.data.type);

  // event.data can be null or undefined, so check it first
  if (event.data && event.data.type === 'react') {
    _dataType = event.data.type;
  }

  console.log('postMessage receive done');
});

Sending a message from inside the iframe to React

When the game ends, it sends a postMessage to the parent window, that is, window.parent. I made it send only when the _dataType stored above is "react".

if (_dataType === 'react') {
  console.log("window.parent", window.parent);
  console.log("location.origin", location.origin);

  window.parent.postMessage(
    { type: "closeMission" },
    "http://your-domain" // write the parent origin exactly
  );

  console.log('postMessage sent:', {
    type: "closeMission",
    target: "http://your-domain"
  });
}

Things to watch out for

Why isn't this a structure to keep for long?

The iframe and postMessage combination is a pretty usable tool, but its limits became clear as I used it.

1. Security issues

Since this is communication between different domains, if targetOrigin isn't set exactly, there's room for another site to cut in. You have to write the exact origin no matter what, and the receiving side has to check it too.

2. Syncing state is hard

The parent and the iframe each hold their own state separately, so keeping them in line means sending and receiving messages constantly. The bigger the structure gets, the harder it gets to tell which side is up to date.

3. Debugging is a pain

You have to follow the flow with console.log alone, and since the message shape has no types, a typo just quietly makes nothing happen. For a React developer used to passing data with props or hooks, it's a frustrating way to work.

4. In the end it's a temporary connection

A structure that communicates through an iframe is only a stopgap, and in the end it's more natural to move everything to React or to pull the external content in as a library.

So why did I build it this way anyway?

I know this approach isn't the best, but given the structure of the project I was in charge of, the iframe was the realistic option.

So I went with showing the game in an iframe from React and only exchanging messages through postMessage.

I also added a restriction so that React sends a type: "react" message first, and the iframe sends messages to the parent only after it has received that. The game file can also be opened on its own, and I wanted to keep it from sending messages to a window with no parent when it's opened that way. That's what _dataType does in the code above.

This is the game CMS screen I built later. Pick a game from the list and it runs right there inside an iframe.

How I actually implemented postMessage communication between React and an iframe image 2

I used the same structure again

This approach was convenient, so when I built the game CMS later I attached the games with an iframe in exactly the same way. And in doing that, I learned for the second time why iframes are a pain.

First, to restart a game I set src to the same value again, and nothing happened. From the browser's point of view nothing had changed. In the end I solved it by changing the React key so the whole thing gets created again.

<iframe
  key={reloadKey}        // this value has to change for it to reload
  ref={iframeRef}
  src={gameSrc}
  allow="fullscreen; autoplay; gamepad; accelerometer; gyroscope"
  allowFullScreen
/>

Second, every permission has to be written out in allow one by one. Fullscreen, audio autoplay, gamepad, if it isn't listed it just doesn't work inside the iframe. I spent a long time looking for why a game had no sound, and this was it.

The third one I still haven't fixed. I set ESC to exit the enlarged view, but the key is listened for on document, so it doesn't register when focus is inside the iframe. If you press ESC while playing a game there's no response, and it only works after you click outside once.

document.addEventListener('keydown', onKey)   // keys pressed inside the iframe don't arrive here

This could also be solved by having the inside notify the outside with postMessage, but that would mean rebuilding all 100 games. For now I've put up a hint saying you can click outside the screen once, and I plan to add it when I next have a reason to touch the game template.