In this article, we’ll create a simple desktop application using Electron and React. It will be a small text editor called “scratchpad” that automatically saves changes as you type, similar to FromScratch. We’ll pay attention to making the application secure by using Electron Forge, the up-to-date build tool provided by the Electron team.
Electron Forge is “a complete tool for creating, publishing, and installing modern Electron applications”. It provides a convenient development environment, as well as configuring everything needed for building the application for multiple platforms (though we won’t touch on that in this article).
We’ll assume you know what Electron and React are, though you don’t need to know these to follow along with the article.
You can find the code for the finished application on GitHub.
Key Takeaways
- Utilize Electron Forge for streamlined development and security features in your desktop app, ensuring a modern, efficient setup for Electron applications.
- Integrate React with Electron by setting up Electron Forge with a webpack template and configuring Babel to support JSX for a seamless development experience.
- Enhance text editing functionality by incorporating CodeMirror into your React components, allowing for real-time updates and a customized, user-friendly interface.
- Implement a secure method to save and load content from the disk using Electron’s main and renderer processes, without compromising the security of the application.
- Optimize user experience by adjusting window settings to prevent the initial white flash on load, ensuring content appears immediately when the app is opened.
- Package and distribute your Electron application effectively using Electron Forge’s built-in commands, simplifying the process for various operating systems.
Setup
This tutorial assumes that you have Node installed on your machine. If that’s not the case, please head over to the official download page and grab the correct binaries for your system, or use a version manager such as nvm. We’ll also assume a working installation of Git.
Two important terms I’ll use below are “main” and “renderer”. Electron applications are “managed” by a Node.js JavaScript file. This file is called the “main” process, and it’s responsible for anything operating-system related, and for creating browser windows. These browser windows run Chromium, and are referred to as the “renderer” part of Electron, because it’s the part that actually renders something to the screen.
Now let’s begin by setting up a new project. Since we want to use Electron Forge and React, we’ll head over to the Forge website and look at the guide for integrating React.
First off, we need to set up Electron Forge with the webpack template. Here’s how we can do that in one terminal command:
$ npx create-electron-app scratchpad --template=webpack
Running that command will take a little while as it sets up and configures everything from Git to webpack to a package.json
file. When that’s done and we cd
into that directory, this is what we see:
➜ scratchpad git:(master) ls
node_modules
package.json
src
webpack.main.config.js
webpack.renderer.config.js
webpack.rules.js
We’ll skip over the node_modules
and package.json
, and before we peek into the src
folder, let’s go over the webpack files, since there are three. That’s because Electron actually runs two JavaScript files: one for the Node.js part, called “main”, which is where it creates browser windows and communicates with the rest of the operating system, and the Chromium part called “renderer”, which is the part that actually shows up on your screen.
The third webpack file — webpack.rules.js
— is where any shared configuration between Node.js and Chromium is set to avoid duplication.
Okay, now it’s time to look into the src
folder:
➜ src git:(master) ls
index.css
index.html
main.js
renderer.js
Not too overwhelming: an HTML and CSS file, and a JavaScript file for both the main, and the renderer. That’s looking good. We’ll open these up later on in the article.
Adding React
Configuring webpack can be pretty daunting, so luckily we can largely follow the guide to integrating React into Electron. We’ll begin by installing all the dependencies we need.
First, the devDependencies
:
npm install --save-dev @babel/core @babel/preset-react babel-loader
Followed by React and React-dom as regular dependencies:
npm install --save react react-dom
With all the dependencies installed, we need to teach webpack to support JSX. We can do that in either webpack.renderer.js
or webpack.rules.js
, but we’ll follow the guide and add the following loader into webpack.rules.js
:
module.exports = [
...
{
test: /\.jsx?$/,
use: {
loader: 'babel-loader',
options: {
exclude: /node_modules/,
presets: ['@babel/preset-react']
}
}
},
];
Okay, that should work. Let’s quickly test it by opening up src/renderer.js
and replacing its contents with the following:
import './app.jsx';
import './index.css';
Then create a new file src/app.jsx
and add in the following:
import React from 'react';
import ReactDOM from 'react-dom';
ReactDOM.render(<h2>Hello from React in Electron!</h2>, document.body);
We can test if that works by running npm start
in the console. If it opens a window that says “Hello from React in Electron!”, everything is good to go.
You might have noticed that the devtools are open when the window shows. That’s because of this line in the main.js
file:
mainWindow.webContents.openDevTools();
It’s fine to leave this for now, as it will come in handy while we work. We’ll get to main.js
later on in the article as we configure its security and other settings.
As for the error and the warnings in the console, we can safely ignore them. Mounting a React component on document.body
can indeed be problematic with third-party code interfering with it, but we’re not a website and don’t run any code that’s not ours. Electron gives us a warning as well, but we’ll deal with that later.
Building Our Functionality
As a reminder, we’re going to build a small scratchpad: a little application that saves anything we type as we type it.
To start, we’ll add CodeMirror and react-codemirror so we get an easy-to-use editor:
npm install --save react-codemirror codemirror
Let’s set up CodeMirror. First, we need to open up src/renderer.js
and import and require some CSS. CodeMirror ships with a couple of different themes, so pick one you like, but for this article we’ll use the Material theme. Your renderer.js should now look like this:
import 'codemirror/lib/codemirror.css';
import 'codemirror/theme/material.css';
import './app.jsx';
import './index.css';
Note how we import our own files after the CodeMirror CSS. We do this so we can more easily override the default styling later.
Then in our app.jsx
file we’re going to import our CodeMirror
component as follows:
import CodeMirror from 'react-codemirror';
Create a new React component in app.jsx
that adds CodeMirror:
const ScratchPad = () => {
const options = {
theme: "material"
};
const updateScratchpad = newValue => {
console.log(newValue)
}
return <CodeMirror
value="Hello from CodeMirror in React in Electron"
onChange={updateScratchpad}
options={options} />;
}
Also replace the render function to load our ScratchPad component:
ReactDOM.render(<ScratchPad />, document.body);
When we start the app now, we should see a text editor with the text “Hello from CodeMirror in React in Electron”. As we type into it, the updates will show in our console.
What we also see is that there’s a white border, and that our editor doesn’t actually fill the whole window, so let’s do something about that. While we’re doing that, we’ll do some housekeeping in our index.html
and index.css
files.
First, in index.html
, let’s remove everything inside the body element, since we don’t need it anyway. Then we’ll change the title to “Scratchpad”, so that the title bar won’t say “Hello World!” as the app loads.
We’ll also add a Content-Security-Policy
. What that means is too much to deal with in this article (MDN has a good introduction), but it’s essentially a way to prevent third-party code from doing things we don’t want to happen. Here, we tell it to only allow scripts from our origin (file) and nothing else.
All in all, our index.html
will be very empty and will look like this:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Scratchpad</title>
<meta http-equiv="Content-Security-Policy" content="script-src 'self';">
</head>
<body></body>
</html>
Now let’s move to index.css
. We can remove everything that’s in there now, and replace it with this:
html, body {
position: relative;
width:100vw;
height:100vh;
margin:0;
background: #263238;
}
.ReactCodeMirror,
.CodeMirror {
position: absolute;
height: 100vh;
inset: 0;
}
This does a couple of things:
- It removes the margin that the body element has by default.
- It makes the CodeMirror element the same height and width as the window itself.
- It adds the same background color to the body element so it blends nicely.
Notice how we use inset, which is a shorthand CSS property for the top, right, bottom and left values. Since we know that our app is always going to run in Chromium version 89, we can use modern CSS without worrying about support!
So this is pretty good: we have an application that we can start up and that lets us type into it. Sweet!
Except, when we close the application and restart it again, everything’s gone again. We want to write to the file system so that our text is saved, and we want to do that as safely as possible. For that, we’ll now shift our focus to the main.js
file.
Now, you might have also noticed that even though we added a background color to the html
and body
elements, the window is still white while we load the application. That’s because it takes a few milliseconds to load in our index.css
file. To improve how this looks, we can configure the browser window to have a specific background color when we create it. So let’s go to our main.js
file and add a background color. Change your mainWindow
so it looks like this:
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
backgroundColor: "#263238",
});
And now when you start, the flash of white should be gone!
Saving our scratchpad on disk
When I explained Electron earlier in this article, I made it a little simpler than it is. While Electron has a main and a renderer process, in recent years there’s actually been a third context, which is the preload script.
The idea behind the preload script is that it acts as a bridge between the main (which can access all the Node.js APIs) and the renderer (which should definitely not!). In our preload script we can add functions that can talk to the main process, and then expose them to the renderer process in such a way that it doesn’t impact the security of our application.
So let’s get an overview of what we want to do:
- When the user makes a change, we want to save it to the disk.
- When the application launches, we want to load back in that stored content from disk, and make sure it shows in our CodeMirror editor.
First, we’ll write code that lets us load and store content to disk in our main.js
file. That file already imports Node’s path
module, but we also need to import fs
to do things with the file system. Add this to the top of the file:
const fs = require('fs');
Then, we’ll need to choose a location for our stored text file. Here, we’re going to use the appData
folder, which is an automatically created place for your app to store information. You can get it with the app.getPath
feature, so let’s add a filename
variable to the main.js
file right before the createWindow
function:
const filename = `${app.getPath('userData')}/content.txt`;
After that, we’re going to need two functions: one to read the file and one to store the file. We’ll call them loadContent
and saveContent
, and here’s what they look like:
const loadContent = async () => {
return fs.existsSync(filename) ? fs.readFileSync(filename, 'utf8') : '';
}
const saveContent = async (content) => {
fs.writeFileSync(filename, content, 'utf8');
}
They’re both one-liners using the built-in fs
methods. For loadContent
, we first need to check if the file already exists (since it won’t be there the first time we launch it!) and if it doesn’t, we can return an empty string.
saveContent
is even simpler: when it’s called, we call writeFile
with the filename, the content, and we make sure it’s stored as UTF8.
Now that we have these functions, we need to hook them up. And the way to communicate these is through IPC, Inter Process Communication. Let’s set that up next.
Setting up IPC
First, we need to import ipcMain
from Electron, so make sure your require('Electron')
line in main.js
looks like this:
const { app, BrowserWindow, ipcMain } = require('electron');
IPC lets you send messages from the renderer to main (and the other way around). Right below the saveContent
function, add the following:
ipcMain.on("saveContent", (e, content) =>{
saveContent(content);
});
When we receive a saveContent
message from the renderer, we call the saveContent
function with the content we got. Pretty straightforward. But how do we call that function? That’s where things get a little complicated.
We don’t want the renderer file to have access to all of this, because that would be very unsafe. We need to add in an intermediary that can talk with the main.js
file and the renderer file. That’s what a preload script can do.
Let’s create that preload.js
file in the src
directory, and link it in our mainWindow
like so:
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
backgroundColor: "#263238",
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
}
});
Then in our preload script we’ll add the following code:
const { ipcRenderer, contextBridge } = require("electron");
contextBridge.exposeInMainWorld(
'scratchpad',
{
saveContent: (content) => ipcRenderer.send('saveContent', content)
}
)
contextBridge.exposeInMainWorld
lets us add a function saveContent
in our renderer.js
file without making the whole of Electron and Node available. That way, the renderer only knows about saveContent
without knowing how, or where, the content is saved. The first argument, “scratchpad”, is the global variable that saveContent
will be available in. To call it in our React app, we do window.scratchpad.saveContent(content);
.
Let’s do that now. We open our app.jsx
file and update the updateScratchpad
function like this:
const updateScratchpad = newValue => {
window.scratchpad.saveContent(newValue);
};
That’s it. Now every change we make is written to disk. But when we close and reopen the application, it’s empty again. We need to load in the content when we first start as well.
Load the content when we open the app
We’ve already written the loadContent
function in main.js
, so let’s hook that up to our UI. We used IPC send
and on
for saving the content, since we didn’t need to get a response, but now we need to get the file from disk and send it to the renderer. For that, we’ll use the IPC invoke
and handle
functions. invoke
returns a promise that gets resolved with whatever the handle
function returns.
We’ll begin with writing the handler in our main.js
file, right below the saveContent
handler:
ipcMain.handle("loadContent", (e) => {
return loadContent();
});
In our preload.js
file, we’ll invoke this function and expose it to our React code. To our exporeInMainWorld
list of properties we add a second one called content
:
contextBridge.exposeInMainWorld(
'scratchpad',
{
saveContent: (content) => ipcRenderer.send('saveContent', content),
content: ipcRenderer.invoke("loadContent"),
}
);
In our app.jsx
we can get that with window.scratchpad.content
, but that’s a promise, so we need to await
it before loading. To do that, we wrap the ReactDOM renderer in an async IFFE like so:
(async () => {
const content = await window.scratchpad.content;
ReactDOM.render(<ScratchPad text={content} />, document.body);
})();
We also update our ScratchPad
component to use the text prop as our starting value:
const ScratchPad = ({text}) => {
const options = {
theme: "material"
};
const updateScratchpad = newValue => {
window.scratchpad.saveContent(newValue);
};
return (
<CodeMirror
value={text}
onChange={updateScratchpad}
options={options}
/>
);
};
There you have it: we’ve successfully integrated Electron and React and created a small application that users can type in, and that’s automatically saved, without giving our scratchpad any access to the file system that we don’t want to give it.
We’re done, right? Well, there’s a few things we can do to make it look a little bit more “app” like.
“Faster” Loading
You might have noticed that, when you open the app, it takes a few moments before the text is visible. That doesn’t look great, so it would be better to wait for the app to have loaded, and only then show it. This will make the whole app feel faster, since you won’t be looking at an inactive window.
First, we add show: false
to our new BrowserWindow
invocation, and add a listener to the ready-to-show
event. There we show and focus our created window:
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
backgroundColor: "#263238",
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
}
});
mainWindow.once('ready-to-show', () => {
mainWindow.show();
mainWindow.focus();
});
While we’re in the main.js
file, we’ll also remove the openDevTools
call, since we don’t want to show that to users:
mainWindow.webContents.openDevTools();
When we now start the application, the app window shows with the content already there. Much better!
Building and Installing the Application
Now that the application is done, we can build it. Electron Forge already has created a command for this. Run npm run make
and Forge will build an app and installer for your current operating system and place it in the “out” folder, all ready for you to install whether its an .exe
, .dmg
or .deb
.
If you’re on Linux and get an error about rpmbuild
, install the “rpm” package, for example with sudo apt install rpm
on Ubuntu. If you don’t want to make an rpm installer, you can also remove the “@electron-forge/maker-rpm” block from the makers in your package.json
.
This will miss some essential things like code signing, notarization and auto updates, but we’ll leave those for a later article.
This is a really minimal example of integrating Electron and React. There’s much more we can do with the application itself. Here are some ideas for you to explore:
- Add a cool desktop icon.
- Create dark and light mode support based on the operating system settings, either with media queries or by using the nativeTheme api provided by Electron.
- Add shortcuts with something like mousetrap.js or with Electron’s menu accelerators and globalShortcuts.
- Store and restore the size and position of the window.
- Sync with a server instead of a file on disk.
And don’t forget, you can find the finished application on GitHub.
Frequently Asked Questions (FAQs) on Electron Forge and React
What are the key differences between Electron Forge and Electron JS?
Electron Forge and Electron JS are both powerful tools for creating desktop applications with web technologies. However, they differ in several ways. Electron JS is a framework that allows you to build cross-platform desktop apps with JavaScript, HTML, and CSS. It provides the core functionality needed to create a desktop application, but it doesn’t include any tools for packaging or distributing your application.
On the other hand, Electron Forge is a complete toolchain for developing, packaging, and publishing Electron applications. It includes a development server, a build system, and a package manager. It also integrates with popular JavaScript frameworks like React and Angular, making it easier to build complex applications.
How can I integrate React with Electron Forge?
Integrating React with Electron Forge is straightforward. First, you need to initialize a new Electron Forge project. Then, you can install React and ReactDOM using npm or yarn. Once React is installed, you can start building your React components and import them into your Electron application.
To render your React components in Electron, you need to modify the main.js file in your Electron Forge project. Instead of loading an HTML file, you should load a JavaScript file that renders your React components. You can use the ReactDOM.render() function to render your components into a specific element in your HTML file.
Can I use TypeScript with Electron Forge and React?
Yes, you can use TypeScript with Electron Forge and React. TypeScript is a statically typed superset of JavaScript that adds optional types to the language. It can help you catch errors early and write more robust code.
To use TypeScript with Electron Forge and React, you need to install the TypeScript compiler and configure it to work with Electron Forge. You also need to install the @types/react and @types/react-dom packages to get type definitions for React.
Once TypeScript is set up, you can start writing your React components in TypeScript. You can use the .tsx file extension for your components and use TypeScript’s type annotations to ensure that your components are used correctly.
How can I package and distribute my Electron Forge application?
Electron Forge includes a powerful packaging and distribution system. To package your application, you can use the “electron-forge package” command. This will create a distributable package of your application that can be run on any platform.
To distribute your application, you can use the “electron-forge publish” command. This will publish your application to a distribution channel like GitHub or the Mac App Store. You can also configure Electron Forge to automatically update your application when a new version is released.
How can I secure my Electron Forge application?
Securing your Electron Forge application is crucial to protect your users’ data and prevent malicious attacks. There are several best practices you can follow to secure your application.
First, you should always validate and sanitize any user input to prevent injection attacks. You should also use the “contextIsolation” and “sandbox” options in Electron to isolate your application’s processes and prevent cross-site scripting attacks.
Second, you should use HTTPS for all network communications to prevent man-in-the-middle attacks. You can use the “webSecurity” option in Electron to enforce this.
Finally, you should regularly update Electron and all your dependencies to get the latest security patches. You can use npm or yarn to manage your dependencies and keep them up to date.
Kilian is a web developer from The Netherlands that builds software that helps designers and developers be better at what they do. He is interested in the modern web, app development and new tech, and regularly writes about topics like responsive websites, design systems and Electron on his site.