Two Approaches for Opening Multiple Electron Windows in a React/Electron Desktop Application

Recent­ly I’ve had to fig­ure out how to open mul­ti­ple Elec­tron win­dows for use in a React/Electron appli­ca­tion. This turned out to be sim­pler and eas­i­er than I’d orig­i­nal­ly thought but find­ing the sim­ple solu­tion did take some search­ing around, since I did­n’t see any good exam­ples. In fact, many of the exam­ples I came across took me in a direc­tion that ulti­mate­ly was more com­pli­cat­ed and frus­trat­ing than necessary.

There are two ways that I know of to open a new brows­er win­dow if you’re using React/Electron:

  1. Enabling nativeWin­dowOpen in your main Browser­Win­dow, using JS window.open() to open a win­dow and tying the win­dow body to your app using cre­atePor­tal from the ‘react-dom’ package.
  2. Using an Elec­tron Inter­process Com­mu­ni­ca­tion (IPC) to send a mes­sage to your elec­tron event han­dler and spawn­ing a new BrowserWindow.

My hope here is to pro­vide the exam­ple for oth­ers to use, so they don’t have to fig­ure it out from scratch as I did. Either way may work for your sit­u­a­tion but there are some things you should know about each.

Method 1: Native Browser Window

What most of the Exam­ples and Mes­sage Board Posts Say to Do

The exam­ples I saw sug­gest­ed updat­ing the main.js where your Elec­tron appli­ca­tion is ini­tial­ized, to allow native brows­er win­dows to open, using window.open() to open a stan­dard JavaScript win­dow, then using React cre­atePor­tal to link some react component(s) to the body of the window.

BrowserWindow Settings

let window = new BrowserWindow({ 
                width: 700, height: 500, 
                show: false, 
                title: 'my title', 
                webPreferences: { 
                  nodeIntegration: true, 
                  webSecurity: false, 
                  nativeWindowOpen: true 
                }
              });

Standalone Window Component

Ide­al­ly, to do the native win­dow method, you’ll want to cre­ate a com­po­nent to encap­su­late the win­dow han­dling. Per­haps some­thing like this would get you started.

import React, { useState, useEffect} from 'react';
import {createPortal } from 'react-router';
/**
 * COMPONENT:  STANDALONE WINDOW
 * PURPOSE:   DISPLAY THE CHILD CONTENT IN A SEPARATE BROWSER WINDOW
 */
function StandaloneWindow({ children }) {
  /**@type {[Window, React.Dispatch<Window>]} */
  const [windowHandle, setWindowHandle] = useState(null);
  
  // SIDE EFFECT: WHEN COMPONENT MOUNTS OPEN A WINDOW
  //              WHEN THE COMPONENT UNMOUNTS, CLOSE THE WINDOW
  useEffect(() => {
    let w = window.open('');
    copyStyles(window.document, w.document);
    setWindowHandle(w);
    return () => {
      if(windowHandle) {
        windowHandle.close();
      }
    }
  }, []);
  /**
   * FUNCTION:  COPY STYLES
   * PURPOSE:   COPY THE STYLE CONFIGURATION FROM ONE DOCUMENT TO 
   *            ANOTHER SO THE TARGET DOCUMENT WILL MATCH 
   *            THE SOURCE DOCUMENT
   * @param {Document} source
   * @param {Document} target
   */
  function copyStyles(source, target) {
    Array.from(source.querySelectorAll('link[rel="stylesheet"], style'))
    .forEach(link => {
      target.head.appendChild(link.cloneNode(true));
    });
  }
  if(windowHandle) {
    return createPortal(children, windowHandle.document.body);
  }
}

export default StandaloneWindow;

Rendering Your Component Using the StandaloneWindow Component and React Context

It is advan­ta­geous to cre­ate a React con­text and con­text provider, to hold ref­er­ences to any win­dows you cre­ate. This way one could sim­ply add the win­dows to a col­lec­tion in a high lev­el con­text and have them ren­der. It is pos­si­ble to add to that col­lec­tion from any­where beneath the con­text provider. 

If routes are used in the appli­ca­tion then the routes can be placed inside of the con­text provider you cre­ate so the win­dows can per­sist even if the user changes routes; this way the win­dows won’t go out of scope and there­fore go blank. 

A bare­bones exam­ple of a con­text and con­text provider would be some­thing like the following.

import React, { useState, useEffect } from 'react';

import StandaloneWindow from './StandaloneWindow';

// BASIC CONTEXT TO STORE THE POPUP WINDOWS AND PROVIDER A
// FUNCTION TO CREATE A NEW POPUP WINDOW AND HANDLE THE 
// BUSINESS LOGIC OF SAVING IT IN THE COLLECTION
export const AppContext = React.createContext({
  popupWindows: [],
  createPopupWindow: () => {}
});

// APP CONTEXT PROVIDER WHICH CAN BE USED TO ACCESS THE CONTEXT
export const AppContextProvider = ({ children }) => {

  function createPopupWindow(contents) {
    let newWindow = <StandaloneWindow>{contents}</StandaloneWindow>;
  setState((oldState) => (...oldState, pupupWindows: [...oldState.popupWindow, newWindow])
  }
  return (
    <AppContext.Provider value={state}>
      {children}
    </AppContext.Provider>
  ); 
}
export { AppContext, AppContextProvider };

Rendering The Popup Windows Stored in Your Context Provider

Once the infra­struc­ture to store the win­dows is in place you need to have the win­dows inside of a ren­der­ing func­tion, so the app will be able to ren­der and update the win­dows as needed.

Here’s an exam­ple of how this could be done in our app’s index.js, where all of our routes are defined.

import React, { useContext } from 'react';
import { render } from 'react-dom';
import { HashRouter as Router, Route Switch } from 'react-router-dom';
import { AppContextProvider, AppContext } from './AppContext';


function App() {
  return(
    <AppContextProvider>
      <Router>
        <Switch>
          <Route path="/LandingPage" component={LandingPage} />
        </Switch>
      </Router>
      <PopupWindows />  
    </AppContextProvider>  
  );
}
/**
 * COMPONENT: POPUP WINDOWS
 * PURPOSE:   DISPLAY ANY POPUP WINDOWS IN THE APP CONTEXT
 */
function PopupWindows() {
  const { popupWindows } = useContext(AppContext);
  return (<>{popupWindows}</>); 
}

render(<App />, document.getElementById('root'));

Generating a Popup From a Within a Component

The fol­low­ing bare­bones exam­ple will gen­er­ate a pop­up by call­ing the cre­atePop­up­Win­dow() func­tion from the App­Con­text. This will then pop­up a new win­dow and dis­play “Hel­lo World”.

import React, { useContext } from 'react';
import { Button } from 'react-bootstrap';

function MyTestComponent() {
  let { createPopupWindow } = useContext(AppContext); 
  function getPopup() {
    createPopupWindow(<h1>Hello World</h1>);
  }
  return (<Button onClick={() => getPopup()}>Click Me</Button>);
}
export default MyTestComponent;

Conclusions About the Native Browser Window / createPortal Method

The native brows­er win­dow method is com­pli­cat­ed and may cause unex­pect­ed errors. In my own work, I was attempt­ing to show C3 charts inside of one of these win­dows and I could not get the C3 charts to dis­play. I ulti­mate­ly had to aban­don this method because it was­n’t worth try­ing to fig­ure out what was going on with it.

Method 2: Using Electron's Inter-Process Communications (IPC) To Create a New Electron BrowserWindow

After aban­don­ing method 1, I found the IPC method of spawn­ing a win­dow and dis­play­ing com­po­nents to be far supe­ri­or both in terms of sim­plic­i­ty as well as reli­a­bil­i­ty. Any­thing that works in your main react/electron app should work in one of these win­dows, pro­vid­ed you ini­tial­ize every­thing as needed.

Updates to the Electron Main.js to Facilitate Multiple Windows

Your main.js may be quite dif­fer­ent from this but this exam­ple below should give an idea of how you could use the IPC func­tion­al­i­ty of Elec­tron to cre­ate new win­dows when­ev­er it receives a new mes­sage from your application.

const { app, BrowserWindow, ipcMain, Menu } = require('electron'); 
const uuid = require('uuid/v4');

/**@type {{id: String, window: BrowserWindow}[]}
let windows = [];
let dev = false;
/**
 * FUNCTION: CREATE REACT APP WINDOW
 * PURPOSE:  REUSABLE FUNCTION FOR CREATING NEW ELECTRON BrowserWindow INSTANCES
 */
function createReactAppWindow(windows, { dev, baseDir, reactRouterPath, reactRouteParams, windowTitle, windowWidth, windowHeight }) {
  let id = uuid();
  let window = new BrowserWindow({
    width: windowWidth,
    height: windowHeight,
    show: false,
    title: windowTitle,
    webPreferences: {
      nodeIntegration: true,
      webSecurity: false,
      nativeWindowOpen: false,
    }
  });
  browserWindowCollection.push({id, window});

  let indexPath;
  // DEV MODE, IF WE ARE RUNNING FROM SOURCE
  if(dev && process.argv.indexOf('--noDevServer') === -1) {
    indexPath = url.format({
      protocol: 'http:',
      host: 'localhost:8080',
      pathname: 'index.html',
      slashes: 'true'
    });
  // IF WE ARE IN PRODUCTION MODE, RUNNING A BUILT VERSION OF THE APP
  } else {
    indexPath = url.format({
      protocol: 'file:',
      pathname: path.join(baseDir, 'dist', 'index.html'),
      slashes: true
    });
  }

  // THIS IS WHERE WE CUSTOMIZE WHICH WINDOW WILL BE DISPLAYED BY 
  // USING REACT HASH ROUTER PATH / PARAMETERS
  // THIS WILL BE LOADED ON THE REACT SIDE TO RENDER THE RIGHT
  // COMPONENTS FOR THE USER
  if( reactRouterPath) {
    indexPath = indexPath.concat(`#${reactRouterPath}${reactRouteParams ? `/${reactRouteParams}` : ''}` );
  }
  window.loadURL(indexPath);
}

// A BASIC FUNCTION TO CREATE THE MAIN WINDOW FOR OUR APP WHICH
// THE USER SEES WHEN THEY OPEN THE APPLICATION
function createMainWindow() {
  createReactAppWindow(windows, { dev, baseDir: __dirname, reactRouterPath: '/SplashPage', reactRouteParams: null, windowTitle: 'my main window' })
}

// WHEN THE APP FIRST ACTIVATES OPEN THE WINDOW
app.on('activate', () => {
  if(windows.length === 0) {
    createMainWindow();
  }
});

// HERE WE HAVE AN IPC CHANNEL CALLED 'NEW_WINDOW' WHICH WE WILL 
// SEND MESSAGES TO FROM OUR APP, IN ORDER TO CREATE THE WINDOW
// FOR EACH KIND OF WINDOW WE WANT, WE NEED TO HAVE ANOTHER case HERE
// THIS WILL CALL OUR FUNCTION TO CREATE THE WINDOW WHENEVER IT 
// RECEIVES A MESSAGE
ipcMain.on('NEW_WINDOW', (event, arg) => {
  var argObject = arg[0];
  if(argObject && argObject.type) {
    switch(argObject.type) {
      case 'MY_NEW_WINDOW_1_TYPE':
        createReactAppWindow(windows, { dev, baseDir: __dirname, reactRouterPath: '/MyPath1', reactRouteParams: argObject.param, windowTitle: 'my test window' });
        break;
    }
  }
});

Updates to Your App to Trigger the New Window Functionality

Now that we have elec­tron ready to make new win­dows, we need to update our appli­ca­tion to be able to trig­ger new win­dows to be cre­at­ed. It’s prob­a­bly best to encap­su­late this func­tion­al­i­ty in a cus­tom react hook, to sep­a­rate con­cerns with­in your application.

Custom React Hook to Trigger Window Creation

Here’s an exam­ple of the cus­tom hook you could cre­ate to trig­ger a window.

import { ipcRenderer } from 'electron';

function useWindowManagement() {
  
  function OpenMyTestWindow(myID) {
    let paramObj = { type: 'MY_NEW_WINDOW_1_TYPE', param: myID };
    ipcRenderer.send('NEW_WINDOW', [paramObj]); 
  }

  return { OpenMyTestWindow };
}
Using The Custom Window Creation Hook in a Component
import React from 'react';
 import { Button } from 'react-bootstrap';
 // THE CUSTOM HOOKS FROM THE EXAMPLE ABOVE
 import {useWindowManagement } from './WindowManagement.hooks';
 // A BAREBONES TEST COMPONENT TO CALL THE CUSTOM HOOK
 function MyComponent() {
   return(<Button onClick={() => OpenMyTestWindow('1')} />);
 }
Creating React Routes to Display Our Components In the Windows

Now that the ground­work has been laid to dis­play the win­dow, we need to have some paths for react to uti­lize for choos­ing which com­po­nents to dis­play when the win­dow opens.

In this exam­ple, the default path elec­tron will send the user to will be Splash­Page while the pop­up will send the user to MyCompo­nent­ToDis­play.

import React from 'react';
 import { render } from 'react-dom';
 import { HashRouter as Router, Route Switch } from 'react-router-dom';
 import {MyComponentToDisplay} from './MyComponentToDisplay';
 import {SplashPage} from './SplashPage';

function App() {
  return(
  <Router>
    <Switch>
      <Route path="/SplashPage" component={SplashPage} />
      <Route path="/MyPath1" component={MyComponentToDisplay} />
    </Switch>
  </Router>
  );
}

Conclusions about the IPC Method of Creating New Windows in React/Electron

This method appears to have the great­est sim­plic­i­ty, though if you have com­pli­cat­ed lists of para­me­ters there could be some chal­lenges in this ver­sion of the solution.

It seems as though, in most sit­u­a­tions, one would want to cre­ate a new win­dow using an ID as a para­me­ter and this would work quite well.

Leave a Reply

Your email address will not be published. Required fields are marked *