How I actually implemented postMessage communication between React and an iframe
How I showed game content in an iframe and exchanged postMessage messages with React: the code on both sides, origin checks, and why it's a stopgap.
#React #Iframe
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? When you've put external HTML (a game, external content and so on) inside a React app with an iframe When a user action inside the iframe (mission complete, a button click) has to be reported to React When React has to send a state change or a command (start, stop and so on) down into the iframe 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 The second argument of postMessage (targetOrigin) has to be the exact address of the receiving side. If you leave it as '*', any site can receive the message, which is a security risk The receiving side also has to check event.origin to filter out messages that came from somewhere unexpected It's better to fix the shape of the data you send as an object, like { type: '...', payload: ... }. If you send strings, it gets out of hand later when the number of message kinds grows Once there are many message types, it's better to organize them with a switch or a handler object instead of lining up ifs like above 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 . The existing game was a standalone HTML/JS build bundled with Webpack It was a structure that couldn't be integrated into React And with the project schedule, tearing up the whole structure was impossible 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. 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.