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.
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.
There are a few reasons to use Electron.
Some popular apps developed with Electron are.
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.
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.
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.
1const { app, BrowserWindow } = require('electron');2const path = require('path');34// Keep a global reference of the window object, if you don't, the window will5// be closed automatically when the JavaScript object is garbage collected.6let window = null;78function createWindow() {9 // Create the browser window.10 window = new BrowserWindow({11 width: 800,12 height: 600,13 webPreferences: {14 nodeIntegration: true,15 },16 });1718 // 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');2122 // Open the DevTools.23 window.webContents.openDevTools({ mode: 'detach' });24}2526app.on('ready', () => {27 createWindow();28});2930app.on('window-all-closed', () => {31 if (process.platform !== 'darwin') {32 app.quit();33 }34});3536app.on('activate', () => {37 // On macOS it's common to re-create a window in the app when the38 // 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.
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.
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;45 window = new BrowserWindow({6 width: 800,7 height: 600,8 webPreferences: {9 nodeIntegration: true,10 },11 });1213 // And loading it in the window14 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.
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.
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.
1@media (prefers-reduced-motion: no-preference) {2 .App-logo {3 animation: App-logo-spin infinite 20s linear;4 }5}67.App {8 background-color: #282c34;9 min-height: 100vh;10 color: #fff;11}1213input {14 margin-top: 16px;15 padding: 8px;16}1718h3 {19 margin-left: 16px;20}2122.App-header {23 display: flex;24 flex-direction: column;25 align-items: center;26}2728.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}3839@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.
1import logo from './logo.svg';2import './App.css';3import { channels } from '../shared/constants';45const { ipcRenderer } = window.require('electron');67function App() {8 const getData = () => {9 ipcRenderer.send(channels.GET_DATA, { product: 'notebook' });10 };1112 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}2122export 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.
1const { app, BrowserWindow, ipcMain } = require('electron');2const { channels } = require('../src/shared/constants');34...56// End of the file7ipcMain.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};1314// End of the file15ipcMain.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.
1import { useState, useEffect } from 'react';2import logo from './logo.svg';3import './App.css';4import { channels } from '../shared/constants';56const { ipcRenderer } = window.require('electron');78function App() {9 const [product, setProduct] = useState('');10 const [data, setData] = useState(null);1112 const getData = () => {13 // Send the event to get the data14 ipcRenderer.send(channels.GET_DATA, { product });15 };1617 useEffect(() => {18 // Listen for the event19 ipcRenderer.on(channels.GET_DATA, (event, arg) => {20 setData(arg);21 });2223 // Clean the listener after the component is dismounted24 return () => {25 ipcRenderer.removeAllListeners();26 };27 }, []);2829 return (30 <div className="App">31 <header className="App-header">32 <img src={logo} width={200} className="App-logo" alt="logo" />33 <input34 onChange={(e) => setProduct(e.target.value)}35 placeholder="Product name"36 />37 <button onClick={getData}>Search</button>38 </header>3940 {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}5354export default App;
It seems complicated, but it's quite simple, I'm gonna summarize it for you below.
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
.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
.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.
1...23const handleQuit = () => {4 ipcRenderer.invoke(channels.QUIT);5};67return (8 <nav>9 <button>Config</button>10 <button onClick={handleQuit}>Quit app</button>11 </nav>12);
1...23ipcMain.handle(channels.QUIT, () => {4 app.quit();5});
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.
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.
1window.require = require;23jest.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.
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';67const { ipcRenderer } = require('electron');89describe('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';1516 userEvent.type(input, product);17 userEvent.click(searchButton);1819 expect(ipcRenderer.send).toBeCalledWith(channels.GET_DATA, {20 product,21 });22 });2324 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 };3132 act(() => {33 ipcRenderer.on.mock.calls[0][1](null, mData);34 });3536 expect(ipcRenderer.on).toBeCalledWith(37 channels.GET_DATA,38 expect.any(Function)39 );4041 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 // 13 render(<App />);4 const input = screen.getByRole('textbox');5 const searchButton = screen.getByRole('button');6 const product = 'notebook';78 // 29 userEvent.type(input, product);10 userEvent.click(searchButton);1112 // 313 expect(ipcRenderer.send).toBeCalledWith(channels.GET_DATA, {14 product,15 });16});
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 // 13 render(<App />);4 const mData = {5 name: 'notebook',6 price: '2500',7 color: 'gray',8 };910 // 211 act(() => {12 ipcRenderer.on.mock.calls[0][1](null, mData);13 });1415 // 316 expect(ipcRenderer.on).toBeCalledWith(17 channels.GET_DATA,18 expect.any(Function)19 );2021 // 422 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});
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
.ipcRenderer.on
is being called with the right channel and a callback function.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.
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.
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.
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.
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.
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.
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 });1011 window = new BrowserWindow({12 width: 800,13 height: 600,14 webPreferences: {15 nodeIntegration: true,16 },17 });1819 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.
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.
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.
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.