Creating multi-window Electron apps using React portals

Don’t increase your Electron app code complexity as you add more windows to it
profile photo
Adam Pietrasiak
Screen recorder app - Screen Studio I created is a desktop app that is heavily based on multi-windows architecture:
Image without caption
Technically it would be possible to handle it all in one window somehow, but that would make it feel way more like a web app, not a native desktop app.
Those windows have multiple purposes; some are rendered always on top of the other apps, some use macOS background effects, etc.
The app is created with Electron, so I needed an effective way to manage that.

The default way of doing it

The common way of dealing with multi-window Electron apps is simply creating multiple windows by calling a new BrowserWindow and then loading proper parts of the app there and managing those windows on the Electron side.
While I believe this is by far the most popular way, I also think it is extremely hard to scale the codebase without exploding the complexity this way.
There are many issues with this approach:
  • There is no easy way to communicate between the windows. You likely will need the IPC channels you use to send data and messages between windows.
    • you’ll not be able to send things like callback functions or custom class instances easily
    • you’ll need boilerplate code for every single kind of message you want to send
  • You’ll need to bundle multiple javascript entry points and complicate your build pipeline
    • You can bypass it by creating only one entry point, which will boot up different things depending on some condition sent to the window. It will work but will make opening each window slower as a lot of JavaScript will have to be loaded
  • The code will be imperative as you write imperative handlers that open and manage several windows. I create the apps using React, which is declarative, and it is way more natural to write the app code this way. I want to express I need a new window and some content inside of it, the same way I create Modal or Popover, except the content of it is displayed in another window.

Crazy idea, React portals

When working at around.co some years ago, some of my co-workers had this idea, and I fell in love with it since then.
The idea is simple:
  • In React, you can use Portal to render a given React node into an arbitral DOM node
  • I’m pretty sure React authors did mean it mostly to allow devs to render things like modals into the root of the DOM structure.
  • But what if you could get a reference to a new window, using window.open(), and then render stuff into newWindow.document.body?

The architecture

To implement this concept in Electron, there are several pieces needed

React side

On the React side, we need ChildWindow component, which will:
  • actually, open a new window with all the options Electron will understand
  • if options are changed, it will send a proper message to Electron to update the window settings
  • if the window is closed “not by react” (by user closing it using native close button), we need to call onClosed prop so React can stop rendering the window component.
    • There is a convention here - “after onClosed is called, you must stop rendering the component. Otherwise, you’ll be rendering into non-existing window”
Image without caption
This is the pseudo-code of it.
For the sake of this article, it is greatly simplified only to show the main idea.
There are some aspects you might notice:
  • I used React context using WindowContext, which will allow me to get the reference to the parent window out of React hierarchy using useWindow corresponding hook.
  • I created StyleSheetManager. In Screen Studio, I use styled-components which is a CSS-in-JS library. By default, it adds proper CSS rules to the <head> element of the window running the code. It would not work in this case, as we render into another window, and CSS is not shared between parent and child windows.

Electron side

On the Electron side, we need to do 3 things:
  • Handle window.open requests and parse the options and pass them to the BrowserWindows options object so it has the correct settings instantly. We do that using setWindowOpenHandler
  • Then we need to start listening for updateWindowOptions messages and apply new settings to the window.
  • Then we need to listen to window close requests and send them to React, so it calls onClosed prop.
Image without caption
This code is also greatly simplified, as it skips many aspects such as cleaning up, setting up IPC channels, etc which is not the subject of this article.

Example usage

Here is an example of the screen.studio recording picker, which allows user to pick window, desktop, microphone and camera. For optimal experience - it is always on top and has default macOS background panel effect.
Image without caption
It uses macOS background effect, is always on top, has no frame, has a shadow, etc:
Image without caption

Advantages

There are a lot of advantages to using this architecture:

Simplicity

You can simply copy-paste one of <ChildWindow /> components, and boom, you have 2 fully independent windows. You can change the props of one of them, and they now look totally different. If done correctly, it will work with hot-reloading, so you instantly see changes without having to restart the app (note: some BrowserWindow options are not changeable after the window was created)

Communication

You can pass any prop, callback function, custom object, etc., into the window content react components. Under the hood, it works exactly like any other React code; it’s just you happen to render some of your React trees into another window

Declarative

You can escape the imperative nature of creating windows on the Electron side and enjoy the declarative nature of working with React. It means you express what you want, aka. “I now need a window with those props,” and you don’t worry about “what has to happen for this window with those props to be there”

Caveats and quirks

There are MANY caveats and quirks related to this architecture. Those are sometimes very annoying, but I believe they are totally worth it.

Dev inspector

As we have multiple windows, we also have multiple Chromium dev inspectors, which we can open. All of those are relevant, but the contents of each are quite unintuitive for a day-to-day web developer:
The parent window inspector shows all the console logs as this is where JavaScript actually runs, but shows no HTML elements in DOM inspector:
Image without caption
This is the DOM inspector of the main window. In our case, the main window is actually an invisible window and everything that is visible, is rendered into child windows. As a result, the DOM inspector of the main window is quite empty.
Image without caption
But the console tab of the main window is full of logs related to those child windows.
It is a bit tricky if I render the actual DOM node into the console, eg console.log(elementRef.current). You will see its preview, but you’ll not be able to click it to inspect it in the DOM inspector, as the inspector will look for this node in the window you’re inspecting, and it is not there.
On the other hand, the inspector of the child window will show all the DOM nodes but no console logs.

Managing events

Another very unintuitive thing. We, web devs are very used to doing things like window.addEventListener, which should just work. Except, it will not be in the case of multi-window apps. Some examples:
  • You might have global keyboard shortcuts handler attached to keyboard events. Now you need to make sure every window you have is listening to them, and the best would be to have only currently focused window to listen to them
  • You might be using external libraries that are “naively” attaching events to windows such as window.addEventListener("resize"). It is, of course, understandable for library devs to do something like this, but it would simply not work in our case. The code above would be listening for resizing events of the wrong window, as it is not the one given thing rendered into. This is especially painful with DOM-heavy libraries such as rich text editors (I was not able to make most of them work at all)
    • Tip: a good practice is to use domNode.ownerDocument.defaultView to get the window to which a given DOM node belongs.

Managing CSS

CSS is not shared between Windows. It is also quite unintuitive for web devs to work in this architecture. We’re used to CSS being ‘singleton’ - you just add it, and it’s there. It’s not the case here. You need to make sure all relevant CSS is included in every window.
If you use regular CSS files, you’ll probably just need to add those to the head of every window. (I’m not sure if hot reloading will work just fine with it)
If you use CSS-in-JS libraries, you depend on whether or not the authors of the library allow you to set the scope of collecting the styles and setting the target where <style> rules will be added.
In screen.studio, I use amazing styled-components, which have StyleSheetManager allowing me to do exactly that.

Animations

It is very common to use requestAnimationFrame (or, I should say window.requestAnimationFrame) to animate in an effective way. The callback passed to RAFs (request animation frame) is precisely timed in a moment which is perfect for a given window to do the painting and similar things.
Except this moment is different for every window, and 99% of libraries obviously use window.requestAnimationFrame, not animatedNode.ownerDocument.defaultView.requestAnimationFrame, which totally ruins all its benefits.
It had a terrible impact on the performance of our WebGL rendering engine, which is using ‘ticker’, which ticks every frame and is the callback where you actually render things.
After many attempts, I ended up monkey-patching this function so it is using requestAnimationFrame of the currently focused window:
Image without caption
We’re monkey-patching the original RAF, trying to get the currently active window and using RAF from it instead.

Other things

There are many other things, and I think I don’t even remember all of those now:
  • Preventing the window from being closed (e.g. “You have unsaved changes”) and keeping this in sync with React tree
  • Avoiding empty window flicker on both opening it and closing it, e.g. React elements (and DOM nodes) are unmounted, but the window is not closed yet.

Conclusion

When creating larger apps, I believe you should be very careful about keeping the code complexity horizontally scalable.
If you don’t do that, you might semi-unconsciously avoid given patterns, knowing how complex it will be to implement them. E.g., you’ll create a “Settings view” in the same window as the app main window, even though it is not the default UX pattern e.g., on macOS where Settings are almost always in the new window.
Creating this, ChildWindow architecture was a complex task in itself. Implementing it was quite painful. There are also many day-to-day quirks involved, and I’m not able to use some 3rd party libraries.
Even with all of that, I believe it unlocked many possibilities and is keeping developing screen.studio as simple as it was when it had 3 times fewer windows to manage.
I was thinking several times about open-sourcing the architecture I’m using, but it is currently tightly coupled with the app and as a solo dev of screen.studio, I constantly keep prioritizing other things.
I hope you enjoyed. If you want to follow my #buildinpublic journey, follow me on Twitter.
Related posts
post image
When the same bug is caused by multiple issues and only fixing all of them at once will make it go away
post image
Due to a simple bug, Screen Studio app did generate over 2 petabytes of network traffic
post image
Never read pixels into JavaScript memory.
Powered by Notaku