loading...
Cover image for Electron Apps Made Easy with Create React App and Electron Forge

Electron Apps Made Easy with Create React App and Electron Forge

mandiwise profile image Mandi Wise ・8 min read

If you ever need to wrap a React app in Electron, you'll surely find there's no shortage of tutorials that explain how to set up your project in your local development environment. But what happens when you're ready to package up that app in distributable formats so users can install it on their systems?

I recently found myself needing to do this on a project and found that I had to do a bit of experimentation before setting on how to best approach generating, developing, and packaging an Electron app with code scaffolded from Create React App under the hood.

In this post, I'll share with you what steps I followed to make this happen and how I reconciled the code generated by the CRA and Electron Forge CLIs to run no-fuss builds with a single command.

TL;DR: You can find the complete code from this demo here.

Set Up the React App with Electron

The approach we'll take to set up our app will involve a few steps:

  1. Scaffold a new React app using the CRA CLI tool
  2. Add a file to start up an Electron app with the React app running inside of it
  3. Import the project into the Electron Forge workflow so that it can easily be packaged up for distribution

We'll take care of the first two steps in this section and then configure our project with Electron Forge in the next section. Let's begin by creating the new React app:

npx create-react-app cra-electron-forge-demo --use-npm

I'll use npm in this tutorial so I've passed the --use-npm flag above, but you can use Yarn if you prefer too. Now we'll change into our new project directory:

cd cra-electron-forge-demo

Our next step will be to install Electron in our project as a development dependency:

npm i -D electron@9.1.1

And we'll also install a package that will make it easy for us to detect if we're running in a development or production environment in our Electron app:

npm i electron-is-dev@1.2.0

Moving onto the second step, we're going to add a file to the public directory called electron.js to contain all of our Electron-related code.

touch public/electron.js

Note that you will often see this file called main.js in tutorials, but I think that calling it electron.js here disambiguates what it's purpose is!

Inside of electron.js, we'll add the following code:

const path = require("path");

const { app, BrowserWindow } = require("electron");
const isDev = require("electron-is-dev");

function createWindow() {
  // Create the browser window.
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true
    }
  });

  // and load the index.html of the app.
  // win.loadFile("index.html");
  win.loadURL(
    isDev
      ? "http://localhost:3000"
      : `file://${path.join(__dirname, "../build/index.html")}`
  );

  // Open the DevTools.
  if (isDev) {
    win.webContents.openDevTools({ mode: "detach" });
  }
}

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(createWindow);

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
  if (process.platform !== "darwin") {
    app.quit();
  }
});

app.on("activate", () => {
  // On macOS it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

The code above has been adapted from a starter example in the Electron docs, but with a few notable changes:

  • We use the electron-is-dev package to conditionally point Electron to either our local development server or a bundled production version of our React app (and we use Node's path module to help with this).
  • We conditionally open the Chrome dev tools depending on the environment (because we only want them to open automatically in development).
  • Lastly—and this a matter of personal preference—we detach the dev tools from the app's main window when it launches. If you would prefer to keep the dev tools attached to the main window, then you can instead leave the object argument out when calling the win.webContents.openDevTools method.

With this file in place, we'll need to declare it as the main entry point for the Electron app in our package.json file:

{
  "name": "cra-electron-forge-demo",
  "version": "0.1.0",
  "main": "public/electron.js", // NEW!
  // ...
}

Now we need to think about how we're going to start our app up. In the development environment, we'll want to start the React app up first and only launch Electron once http://localhost:3000 is available. Ideally, we'd also prevent the default CRA behavior that opens a browser tab with our app running in it. (We don't have to worry about any this in production because Electron will just load the static index.html and bundled JS files.)

To launch our development server and Electron together, we'll need to install two more packages:

npm i -D concurrently@5.2.0 wait-on@5.1.0

Concurrently will allow us to run multiple commands in one npm script and wait-on will require Electron to wait for port 3000 to be available before the app launches. We'll use these packages to add dev and electron scripts to our package.json file:

{
  // ...
  "scripts": {
    "dev": "concurrently -k \"BROWSER=none npm start\" \"npm:electron\"",
    "electron": "wait-on tcp:3000 && electron .",
    // ...
  }
  // ...
}

Passing the BROWSER=none option before npm start will prevent a regular browser tab from launching once our React app starts up. If we run npm run dev now, then we'll be able to see our React app running with Electron instead of in a browser window:

Create React App running inside of an Electron window

Configuring Electron Forge

If all we wanted to do was wrap a React app in Electron and experiment with it in our development environment, then we could call it a day here. But odds are that you'll likely want other people be to able to use your app like any other desktop app on their computers too!

There are different options available for packaging up Electron apps for different platforms, but the best all-in-one solution I've tried is Electron Forge. We can import our existing Electron app into the Electron Forge workflow by running this command in our project now:

npx @electron-forge/cli import

When the importer script runs (and it may take a couple of minutes...), Electron Forge will install some additional dependencies and make some changes to our package.json file. If we take a look at the scripts in the package.json file, we'll see that it has modified our existing start script to this:

{
  // ...
  "scripts": {
    // ...
    "start": "electron-forge start",
    // ...
  }
  // ...
}

We'll need to change this script back to the way it was before:

{
  // ...
  "scripts": {
    // ...
    "start": "react-scripts start",
    // ...
  }
  // ...
}

We'll also make a small adjustment to the new package and make scripts to also build our React app beforehand:

{
  // ...
  "scripts": {
    // ...
    "package": "react-scripts build && electron-forge package",
    "make": "react-scripts build && electron-forge make"
  }
  // ...
}

Lastly, we'll modify our own electron script to use electron-forge to start up the Electron app instead:

{
  // ...
  "scripts": {
    // ...
    "electron": "wait-on tcp:3000 && electron-forge start",
    // ...
  }
  // ...
}

Over in public/electron.js now, we can use the electron-squirrel-startup package installed Electron Forge to handle creating and removing shortcuts on Windows (should we wish to package our app for that platform too):

const path = require("path");

const { app, BrowserWindow } = require("electron");
const isDev = require("electron-is-dev");

// Handle creating/removing shortcuts on Windows when installing/uninstalling
if (require("electron-squirrel-startup")) {
  app.quit();
} // NEW!

// ...

With our new code in place, we should be able to run npm run dev again and see the Electron app start just as it did before.

Add Support for React Developer Tools

If we're building a React app, there's a good chance that we're going to want to have access to the React Developer Tools extension at some point. We'll need to add support the React Developer Tools manually to our app by installing this package:

npm i -D electron-devtools-installer

Next, we'll add some new code at the top of public/electron.js to conditionally require this package when our development environment is detected:

const path = require("path");

const { app, BrowserWindow } = require("electron");
const isDev = require("electron-is-dev");

// Conditionally include the dev tools installer to load React Dev Tools
let installExtension, REACT_DEVELOPER_TOOLS; // NEW!

if (isDev) {
  const devTools = require("electron-devtools-installer");
  installExtension = devTools.default;
  REACT_DEVELOPER_TOOLS = devTools.REACT_DEVELOPER_TOOLS;
} // NEW!

// Handle creating/removing shortcuts on Windows when installing/uninstalling
if (require("electron-squirrel-startup")) {
  app.quit();
} // NEW!

// ...

Lastly, we'll need to call the installExtension function when the app is ready, so we must update the existing app.whenReady().then(createWindow); lines as follows:

// ...

app.whenReady().then(() => {
  createWindow();

  if (isDev) {
    installExtension(REACT_DEVELOPER_TOOLS)
      .then(name => console.log(`Added Extension:  ${name}`))
      .catch(error => console.log(`An error occurred: , ${error}`));
  }
}); // UPDATED!

// ...

If we relaunch the app now, we should be able to see that the Components and Profiler tabs are available in the dev tools window:

React Developer Tools running in an Electron app

Building for Distribution

We're finally ready to generate a platform-specific distributable so other people can install our app. In the following example, we'll build the Mac version of the app.

First, we'll need to set a homepage property in the package.json file to help CRA correctly infer the root path to use in the generated HTML file:

{
  "name": "cra-electron-forge-demo",
  "version": "0.1.0",
  "main": "public/electron.js",
  "homepage": "./",
  // ...
}

As a nice touch, we can also create a custom app icon to appear in the user's dock using the electron-icon-maker package. To do this, we'll need to supply it an absolute path to a PNG file that's at least 1024px by 1024px. We'll run this script from the root directory of our project to generate the icon files:

npx electron-icon-maker --input=/absolute/path/to/cra-electron-forge-demo/src/app-icon.png --output=src

Next, we can add the correct icon file and customize the name our app (as it will appear in the top menu or when hovering over the dock icon) under the config key in our package.json file:

{
  // ...
  "config": {
    "forge": {
      "packagerConfig": {
        "icon": "src/icons/mac/icon.icns",
        "name": "React + Electron App"
      },
      // ...
    }
  }
}

Note that if you wish to change the name that appears at the top of the window, you'll need to update that in the title element in the public/index.html file before building the app:

Diagram of app menu and window names

Now we can run a single command to package up our app for distribution:

npm run make

The packaged app can now be found in a new out directory in the root of our project. You can now drag this app in your Applications folder and run it like any other app on your system.

Finally, if you plan on version controlling this project with Git, then be sure to add the out directory to the .gitignore file before making your next commit:

# ...

# production
/build
/out # NEW!

# ...

Summary

In this tutorial, we used Create React App to scaffold a React app, which we then wrapped in Electron. We imported the app into an Electron Forge workflow using the provided CLI tool, made some adjustments for it to play nice with a CRA app, and then generated a distribution of the app for Mac.

You can find the complete code for this tutorial here.

I hope you've found the code in this tutorial helpful! Thanks for coding along and please leave any questions or comments below.

Posted on by:

mandiwise profile

Mandi Wise

@mandiwise

JavaScript Developer 👩‍💻 Machine Learning Enthusiast 🤖 Author of Advanced GraphQL with Apollo & React 🚀

Discussion

markdown guide
 

Hey Mandi,

I just happened to be working on moving our existing React dashboard from electron-builder to electron-forge, when I stumbled upon your perfectly timed article. I'm running into some issues with setting up the electron forge building process.

We've used create-react-app to set up the application initially, and are now using a folder structure where the src folder contains all our sources. There's also a public folder (from CRA), and we have some extra folders for unit tests and a bunch of files to configure things like GitLab CI, Babel, GraphQL, etc. CRA builds the application for us to the build folder, but when using electron forge to package / make the app, it will copy literally everything in the root folder (including tests, node modules, and our project config files) into the packaged app.

I've been looking for hours on Google to find out how I could specify a folder (e.g. the build folder) for Forge to use, but it seems like that's not possible at all. For the time being, I've just put every non-important folder to the ignore key of the packager config, but that's not a very solid solution.

Hoping you know a bit more about this than I do, and that you might be able to help me out!

Edit: I also just found out that CRA copies the public files into the build folder, but Electron Forge will still use the original files it copied over in the public folder, so you essentially have those files packaged twice in the final executable.

 

Hi Ririshi! This is a bit outside the scope of what I've done with Electron Forge, but if I discover a solution as I continue to experiment with it, then I'll post it here.

 

Thank you! Excellent guide!
A note for those using react-router 6, you'll need to replace BrowserRouter with HashRouter or you'll get a white screen when building for production.