Patrick Passarella

Electron and React integration done right

Creating an updated Electron + React app, learning how to communicate between them, and how to build for cross-platform

Patrick Passarella - 13th Dec, 2020

Photo by Raphaël Biscaldi on Unsplash

In this post, I will show you how to create a full React and Electron app, test, and build it.

Summary

Introduction

Electron is a framework to create cross-platform desktop apps. Ages ago it was mostly being used with plain js and html. Now we can integrate it with React. But I found it kinda troublefull, so I wrote this post to help you create a nice integration between these two great technologies.

If you've never heard of Electron, I guess I can make a small tour.

  1. Electron uses Node.js for the back-end development;
  2. Electron uses Chromium for displaying web content;
  3. It also has some custom API's to deal with OS functions.

There are a few reasons to use Electron.

  1. To use desktop features (obviously);
  2. To create cross-platform desktop apps more easily (compared to native technologies);
  3. Depending on the app, you can process stuff on the user-side, instead of using your server resources;
  4. Some apps generally work better as a desktop version, for me at least.

Some popular apps developed with Electron are.

  1. VScode;
  2. Slack;
  3. Whatsapp.

React app setup

This isn't the scope of the post, so we will use the simplest possible CRA project.

You can create the project using $ yarn create react-app my-app or $ npm init react-app my-app. If you have already created a folder, just replace "my-app" with a dot (current folder).

We will also change the folder structure, since it's going to have the electron app in the same project, it would be a mess of files.

Create a client (or any name you want) folder in the src folder, move everything except the index.js and setupTests.js (if you're using CRA) to it. Remember to also correct the imports.

That's it for now for the React structure, we will be back to it shortly.


Electron app setup

Now we need to create the Electron part of the app.

First, create a folder, outside of the src folder, named electron, and a main.js file inside it. Change the main property in the package.json to electron/main.js. This will define the main entry point for the app.

After that, add the Electron package to the project, running $ yarn add electron --dev and a start script to your package.json. In the start script, we also add the environment variable containing the React app url, to access later.

package.json

1...
2"main": "electron/main.js",
3"scripts": {
4 "start:electron": "ELECTRON_START_URL=http://localhost:3000 electron ."
5 }
6...

Lastly, the main.js content. I will be using the Electron default code, found on their documentation. With just some small tweaks.

electron/main.js

1const { app, BrowserWindow } = require('electron');
2const path = require('path');
3
4// Keep a global reference of the window object, if you don't, the window will
5// be closed automatically when the JavaScript object is garbage collected.
6let window = null;
7
8function createWindow() {
9 // Create the browser window.
10 window = new BrowserWindow({
11 width: 800,
12 height: 600,
13 webPreferences: {
14 nodeIntegration: true,
15 },
16 });
17
18 // Load the index.html of the app.
19 // This will give an error for now, since we will be using a React app instead of a file.
20 window.loadFile('index.html');
21
22 // Open the DevTools.
23 window.webContents.openDevTools({ mode: 'detach' });
24}
25
26app.on('ready', () => {
27 createWindow();
28});
29
30app.on('window-all-closed', () => {
31 if (process.platform !== 'darwin') {
32 app.quit();
33 }
34});
35
36app.on('activate', () => {
37 // On macOS it's common to re-create a window in the app when the
38 // dock icon is clicked and there are no other windows open.
39 if (BrowserWindow.getAllWindows().length === 0) {
40 createWindow();
41 }
42});

This file is where all the electron code will be. Here, we are creating a window after the app is ready, and adding some functionality, like being able to close the window.

Running $ yarn start:electron should open up a blank window, and the devTools.


Integrating both

It's time to show the React app inside the Electron desktop window. For that, it's quite simple, we just render the url instead of the index.html file directly.

So, we need to change the createWindow function to load the url.

electron/main.js

1const createWindow = () => {
2 // Here, we are grabbing the React url from the env (which is on the start script)
3 const startUrl = process.env.ELECTRON_START_URL;
4
5 window = new BrowserWindow({
6 width: 800,
7 height: 600,
8 webPreferences: {
9 nodeIntegration: true,
10 },
11 });
12
13 // And loading it in the window
14 window.loadURL(startUrl);
15 window.show();
16 window.webContents.openDevTools({ mode: 'detach' });
17};

Now your Electron app should load the React app in the window, try it by running $ yarn start (I renamed it to start:client) to start the React app, and $ yarn start:electron again to show the window.


Communication between React and Electron

Now, another thing left to do, is to learn how to communicate between them. But you may ask, "what do you mean by communication"?, what can we do between Electron and React? The main.js file (the Electron part), is a node application, so you could do anything "back-end" related. You could fetch a database, add a notification system, use the local file-system, and so on. Technically you can use Node APIs inside the Renderer process also, but I don't think that's a good practice.

Electron provides a way to communicate between the main and the renderer (React) process using modules called ipcMain and ipcRenderer.

Both have a few functions, like send and on, who are used for asynchronous communication. And handle (ipcMain) and invoke (ipcRenderer) for synchronous communication.

For the sake of simplicity, we will just send a simple object from one to another, but you could do anything.

Let's look at it step by step.

1. Defining the channel names

Create a shared folder, inside the src folder. In there, is where it's gonna have the code that will be used inside the main and renderer process. So, let's also create a constants.js file inside it, where we define our message names.

src/shared/constants.js

1module.exports = {
2 channels: {
3 GET_DATA: 'get_data',
4 },
5};

As you can notice, we are using module.exports in there, since we are going to use it in electron also.

2. Creating the action that will send the message to Electron main process

In the React app, we will change some css, just to open up space. (you can skip this part if you are using it in your own app, obviously). I just deleted some css code, added a button and h3 style, and decentralized the logo vertically.

App.css

1@media (prefers-reduced-motion: no-preference) {
2 .App-logo {
3 animation: App-logo-spin infinite 20s linear;
4 }
5}
6
7.App {
8 background-color: #282c34;
9 min-height: 100vh;
10 color: #fff;
11}
12
13input {
14 margin-top: 16px;
15 padding: 8px;
16}
17
18h3 {
19 margin-left: 16px;
20}
21
22.App-header {
23 display: flex;
24 flex-direction: column;
25 align-items: center;
26}
27
28.App-header button {
29 border: none;
30 padding: 10px 20px;
31 background: #777;
32 color: #fff;
33 z-index: 1;
34 margin-top: 16px;
35 cursor: pointer;
36 outline: none;
37}
38
39@keyframes App-logo-spin {
40 from {
41 transform: rotate(0deg);
42 }
43 to {
44 transform: rotate(360deg);
45 }
46}

In the App.js file, we will add the button, and the function to send the message.

App.js

1import logo from './logo.svg';
2import './App.css';
3import { channels } from '../shared/constants';
4
5const { ipcRenderer } = window.require('electron');
6
7function App() {
8 const getData = () => {
9 ipcRenderer.send(channels.GET_DATA, { product: 'notebook' });
10 };
11
12 return (
13 <div className="App">
14 <header className="App-header">
15 <img src={logo} width={200} className="App-logo" alt="logo" />
16 <button onClick={getData}>Get data</button>
17 </header>
18 </div>
19 );
20}
21
22export default App;

We need to import the ipcRenderer module using window.require, because we want to require the electron during runtime from the node environment, rather than the one used during compilation by webpack. link to the github issue here.

In this file, we import the ipcRenderer, and the channels we've created in the constants file. And create a getData function, that will be triggered by clicking on the button.

The send method from the ipcRenderer, receives the channel name as the first parameter, and a optional data that will be sent to the main process.

3. Receiving the data in the main process

At the end of the main.js file, we need to listen to the get_data event, using the ipcMain module.

electron/main.js

1const { app, BrowserWindow, ipcMain } = require('electron');
2const { channels } = require('../src/shared/constants');
3
4...
5
6// End of the file
7ipcMain.on(channels.GET_DATA, (event, arg) => {
8 const { product } = arg;
9 console.log(product);
10});

The ipcMain.on method, also receives the channel as the first parameter, and a function as the second, that function has an event, and an arg arguments. The arg is the data we've sent from the renderer process.

Restart the Electron app, and click the Get data button, you should see the data print in the server console.

4. Sending a response back to the renderer process

Now we will mock a database call (or a file system), by just returning an object to the renderer process.

1const products = {
2 notebook: {
3 name: 'notebook',
4 price: '2500',
5 color: 'gray',
6 },
7 headphone: {
8 name: 'headphone',
9 price: '700',
10 color: 'black',
11 },
12};
13
14// End of the file
15ipcMain.on(channels.GET_DATA, (event, arg) => {
16 const { product } = arg;
17 event.sender.send(channels.GET_DATA, products[product]);
18});

This will send an event back to the renderer process, for the get_data channel.

We also need to change our React app to listen for the event, and show it on screen. We use the useEffect hook, to listen to the get_data channel. After the response is sent from the main process, it will get that data, and set the state, which will fill the information about the product.

Here is the full file.

App.js

1import { useState, useEffect } from 'react';
2import logo from './logo.svg';
3import './App.css';
4import { channels } from '../shared/constants';
5
6const { ipcRenderer } = window.require('electron');
7
8function App() {
9 const [product, setProduct] = useState('');
10 const [data, setData] = useState(null);
11
12 const getData = () => {
13 // Send the event to get the data
14 ipcRenderer.send(channels.GET_DATA, { product });
15 };
16
17 useEffect(() => {
18 // Listen for the event
19 ipcRenderer.on(channels.GET_DATA, (event, arg) => {
20 setData(arg);
21 });
22
23 // Clean the listener after the component is dismounted
24 return () => {
25 ipcRenderer.removeAllListeners();
26 };
27 }, []);
28
29 return (
30 <div className="App">
31 <header className="App-header">
32 <img src={logo} width={200} className="App-logo" alt="logo" />
33 <input
34 onChange={(e) => setProduct(e.target.value)}
35 placeholder="Product name"
36 />
37 <button onClick={getData}>Search</button>
38 </header>
39
40 {data && (
41 <>
42 <h3>Product info</h3>
43 <ul>
44 <li>Name: {data.name}</li>
45 <li>Price: {data.price}</li>
46 <li>Color: {data.color}</li>
47 </ul>
48 </>
49 )}
50 </div>
51 );
52}
53
54export default App;

Electron App

It seems complicated, but it's quite simple, I'm gonna summarize it for you below.

  1. In the React app, we add the ipcRenderer.send on an action (or in another way), to notify Electron about something we need, and also add a listener to listen for the respective response using ipcRenderer.on.
  2. In the main process, we add the listener to listen for the renderer process (React app) events, using ipcMain.on, and we can send back the response it is waiting for using event.sender.send.

Isn't it better to just use an api?

Depends, I've used this example here because it's simple, but the main focus is the communication, you can do anything with it. One thing for example that we could do, that is more useful, is an option to quit the app.

App.js

1...
2
3const handleQuit = () => {
4 ipcRenderer.invoke(channels.QUIT);
5};
6
7return (
8 <nav>
9 <button>Config</button>
10 <button onClick={handleQuit}>Quit app</button>
11 </nav>
12);

electron/main.js

1...
2
3ipcMain.handle(channels.QUIT, () => {
4 app.quit();
5});

Testing the ipcRenderer with Jest

It took me a few hours to find a way to test it properly, and I don't think I found the perfect way for it, there are some questions on Stackoverflow about it, but it was complicated for me to understand and implement, so I'm gonna just comment on how I tested my Electron apps, even if it isn't the best way to do it.

I'm gonna use the app I created in this post as an example.

First, we need to setup our tests initialization config. Because we use window.require to require electron, the tests will scream about it. To fix that, I'm gonna add this single-line config in the setupTests.js file that create-react-app makes available.

src/setupTests.js

1window.require = require;

If you're using just Jest, you can add the same config in the jest.setup.js, and add the setupFilesAfterEnv in the package.json or jest.config.js, like that.

Now, we need to mock the ipcRenderer used in the React app. In the same file (setupTests or jest.setup.js), we can use the jest.mock to mock the electron package. This function takes the module name, and a factory function (a function that returns an object) containing the module properties we want to mock.

src/setupTests.js

1window.require = require;
2
3jest.mock('electron', () => {
4 return {
5 ipcRenderer: {
6 on: jest.fn(),
7 send: jest.fn(),
8 removeAllListeners: jest.fn(),
9 },
10 };
11});

We can now start testing without initial errors. I'm gonna show you the full test file, and then explain some sections later.

index.test.js

1import { render, screen } from '@testing-library/react';
2import userEvent from '@testing-library/user-event';
3import { act } from 'react-dom/test-utils';
4import { channels } from '../../shared/constants';
5import App from '../App';
6
7const { ipcRenderer } = require('electron');
8
9describe('App component', () => {
10 it('Should search for a product after clicking search', () => {
11 render(<App />);
12 const input = screen.getByRole('textbox');
13 const searchButton = screen.getByRole('button');
14 const product = 'notebook';
15
16 userEvent.type(input, product);
17 userEvent.click(searchButton);
18
19 expect(ipcRenderer.send).toBeCalledWith(channels.GET_DATA, {
20 product,
21 });
22 });
23
24 it('Should render the search result on the page', () => {
25 render(<App />);
26 const mData = {
27 name: 'notebook',
28 price: '2500',
29 color: 'gray',
30 };
31
32 act(() => {
33 ipcRenderer.on.mock.calls[0][1](null, mData);
34 });
35
36 expect(ipcRenderer.on).toBeCalledWith(
37 channels.GET_DATA,
38 expect.any(Function)
39 );
40
41 expect(screen.getByText(/Name/).textContent).toEqual(`Name: ${mData.name}`);
42 expect(screen.getByText(/Price/).textContent).toEqual(
43 `Price: ${mData.price}`
44 );
45 expect(screen.getByText(/Color/).textContent).toEqual(
46 `Color: ${mData.color}`
47 );
48 });
49});

Let's start with the first test

1it('Should search for a product after clicking search', () => {
2 // 1
3 render(<App />);
4 const input = screen.getByRole('textbox');
5 const searchButton = screen.getByRole('button');
6 const product = 'notebook';
7
8 // 2
9 userEvent.type(input, product);
10 userEvent.click(searchButton);
11
12 // 3
13 expect(ipcRenderer.send).toBeCalledWith(channels.GET_DATA, {
14 product,
15 });
16});
  1. Rendering the component that is being tested, getting the input and search button.
  2. Typing the product name on the input, and hitting search.
  3. Testing if the ipcRenderer.send is being called with the right channel and product name typed in the input, after hitting search.

Now, the second test. This one is more tricky, since we need to also mock the on method call, and execute it's callback.

1it('Should render the search result on the page', () => {
2 // 1
3 render(<App />);
4 const mData = {
5 name: 'notebook',
6 price: '2500',
7 color: 'gray',
8 };
9
10 // 2
11 act(() => {
12 ipcRenderer.on.mock.calls[0][1](null, mData);
13 });
14
15 // 3
16 expect(ipcRenderer.on).toBeCalledWith(
17 channels.GET_DATA,
18 expect.any(Function)
19 );
20
21 // 4
22 expect(screen.getByText(/Name/).textContent).toEqual(`Name: ${mData.name}`);
23 expect(screen.getByText(/Price/).textContent).toEqual(
24 `Price: ${mData.price}`
25 );
26 expect(screen.getByText(/Color/).textContent).toEqual(
27 `Color: ${mData.color}`
28 );
29});
  1. Rendering the component that is being tested, and creating an object to mock the returned data.
  2. In the App component, the ipcRenderer.on is called on the useEffect hook, so we can already access its properties, ipcRenderer.on.mock.calls is accessing all the calls made, it returns something like this [ [ 'get_data', [Function] ] ], which is the arguments passed to it, including the callback, in this case, I'm getting that callback and firing it, that way, it will also call the setData.
  3. Testing if the ipcRenderer.on is being called with the right channel and a callback function.
  4. Testing if the product information is rendered correctly on the page, and with the right data.

I hope it's all clear. Another thing it's important to say, is that, technically, we shouldn't test implementations, only the UI like it's the user who is using the app. But with Electron, it's hard to test like that, since we have that communication between them.


Adding a script to start both Electron and React simultaneously

As you noticed, we need to start both React (with yarn start:client) and Electron (with yarn start:electron). And, we need to always start the React app first. We can do something to solve that, and make it easier for someone to start the app.

Run $ yarn add --dev concurrently wait-on cross-env, to install those required dependencies. Then, change your start script in the package.json file.

package.json

1"scripts": {
2 "start": "concurrently \"cross-env BROWSER=none PORT=3000 react-scripts start\" \"wait-on http://localhost:3000 && ELECTRON_START_URL=http://localhost:3000 electron .\""
3 ...
4}

We use the concurrently to run two scripts at once, the wait-on to wait for the React app to load, and the cross-env is used to being able to use the same env for any platform, in one command.

That's it, now you only need to run one command to start your app.

Building for cross-platform

Electron most powerful feature is being able to build for cross-platform easily. But, not so much with React, it's more verbose and needs some weird configuration.

I'll start by installing the library electron-builder. And back again with the step-by-step process.

1. Configuring the package.json

We need to add two new properties to the package.json file, build and homepage. build have some general configuration, and homepage is the path for the built app to be served from a subdirectory, React sets the root path based on this setting.

package.json

1...
2"homepage": "./",
3"build": {
4 "productName": "Sample App",
5 "appId": "com.company.sample-app",
6 "files": [
7 "build/**/*",
8 "node_modules/**/*",
9 "dist/",
10 "package.json"
11 ],
12 "directories": {
13 "output": "release"
14 }
15 }

productName: The name that will be shown on the executable. appId: Necessary if building for mac. files: Specifies which resource files to include when creating the package. directories:The platform executable output folder.

We also need to add the packing scripts, to build React, Electron, and use electron-builder to build it.

package.json

1"scripts": {
2 "build:client": "react-scripts build",
3 "build:electron": "rm -rf build/src build/shared && mkdir build/src && cp -r electron/. build/electron && cp -r src/shared/. build/src/shared",
4 "build:electron-win": "rm -rf build/src build/shared && mkdir build/src && robocopy electron build/electron /S & robocopy src/shared build/src/shared /S",
5 "pack:linux": "electron-builder -c.extraMetadata.main=build/electron/main.js --publish never",
6 "pack:windows": "electron-builder --win -c.extraMetadata.main=build/electron/main.js --publish never",
7 },

build:client: Build the React app normally.
build:electron: Here, I'm copying the electron and the src/shared folder to the build folder. We do that because we need to access this from the react-builder later'. build:electron-win: The same as the build:electron, except that it's a script for windows.

pack:linux: Executing electron-builder to build electron. Here I needed to use the -c.extraMetadata.main property, to change the package.json main property to build/electron/main.js, to point to the correct build files which we moved in the script before. pack:windows: The same as the pack:linux, except that it's to build for windows. Notice the --win flag.

2. Rendering the compiled react code for production

For running the project locally, we render the React app url into Electron, but for production, we will use the compiled react app, which is the index.html file located in the build folder.

It's just some adjustments in the main.js file.

electron/main.js

1const url = require('url');
2const createWindow = () => {
3 const startUrl =
4 process.env.ELECTRON_START_URL ||
5 url.format({
6 pathname: path.join(__dirname, '../index.html'),
7 protocol: 'file:',
8 slashes: true,
9 });
10
11 window = new BrowserWindow({
12 width: 800,
13 height: 600,
14 webPreferences: {
15 nodeIntegration: true,
16 },
17 });
18
19 window.loadURL(startUrl);
20 window.show();
21 window.webContents.openDevTools({ mode: 'detach' });
22};

As you can see, I just added the index.html path when there is no ELECTRON_START_URL env. The path.join(__dirname, '../index.html') grabs the index.html path, because the main entry point (the main property from package.json) is build/electron/main.js file, doing a ../index.html will get it from the build folder.

3. Creating the executable To create the executable, I choose to use Docker. The reason, is that we need different setups for each operating system without it. For example, Linux building for windows would require you to install Wine.

Building for Linux

Just run this (very big) docker script, and it will run this docker image in your local machine.

1docker run --rm -ti \
2 --env-file <(env | grep -iE 'DEBUG|NODE_|ELECTRON_') \
3 --env ELECTRON_CACHE="/root/.cache/electron" \
4 --env ELECTRON_BUILDER_CACHE="/root/.cache/electron-builder" \
5 -v ${PWD}:/project \
6 -v ${PWD##*/}-node-modules:/project/node_modules \
7 -v ~/.cache/electron:/root/.cache/electron \
8 -v ~/.cache/electron-builder:/root/.cache/electron-builder \
9 electronuserland/builder

Inside Docker, run $ yarn && yarn pack:linux.
After that, a release folder (which is the folder we defined in the build property inside package.json) will be created in the project root, which will have the app in AppImage or .deb format for Linux.

Building for Windows

It's almost the same thing, except that we need another image, for Linux we are using electronuserland/builder, and for Windows we need the electronuserland/builder:wine, which has Wine installed within it.

1docker run --rm -ti \
2 --env-file <(env | grep -iE 'DEBUG|NODE_|ELECTRON_') \
3 --env ELECTRON_CACHE="/root/.cache/electron" \
4 --env ELECTRON_BUILDER_CACHE="/root/.cache/electron-builder" \
5 -v ${PWD}:/project \
6 -v ${PWD##*/}-node-modules:/project/node_modules \
7 -v ~/.cache/electron:/root/.cache/electron \
8 -v ~/.cache/electron-builder:/root/.cache/electron-builder \
9 electronuserland/builder:wine

Inside Docker, run $ yarn && yarn pack:windows.
The same release folder will be created, but with Windows .exe executable.

You can check out more about building with Docker for other platforms in the electron-builder documentation.


Conclusion

If everything occurred right, you should have a working Electron + React app to share!

As you can see, in the end, it's not that complicated, but also there is a lot to learn and implement to not have a headache working with Electron and React. I hope it was useful for you. I will update this post if some change that could break the app happens.

Thanks for reading!Enjoyed the content or just want to send me a message? Follow me on Twitter!
Patrick Passarella (@P_Passarella) | Twitter
Twitter user