DEV Community

Cover image for Building a menu bar application with Electron and React
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Building a menu bar application with Electron and React

Written by Doğacan Bilgili✏️

In this article, I am going to cover how to implement a menu bar application by using Electron.js along with React in a detailed manner.

Although I chose to use React, the client side can of course use any of the other frameworks or just vanilla JavaScript.

Moreover, the target operating system in this tutorial is macOS.

So, if you want to try this tutorial on another operating system, such as Windows or Linux, you might need to do small tweaks in the code due to the fact that the Electron API shows differently on different operating systems.

Setting up the project

To make it fast and easy for those who are not sure how to configure the project, I have prepared a boilerplate for Electron with React.js.

You can clone or download it to start the project, or you can use your own favorite boilerplate instead.

Find mine here:

https://github.com/dbilgili/minimal-electron-react-boilerplate

LogRocket Free Trial Banner

Basics of Electron

Before we start, let’s quickly go over how Electron is structured.

Electron has two processes: renderer and main.

You can think of them as client and server side, respectively.

The renderer process defines what to render inside the windows as the name suggests, and the main process defines what windows to create, as well as the interactions with them and other back-end related things.

These two processes are not aware of each other.

To make them communicate, there are ipcMain and ipcRenderer modules, which are basically EventEmitters.

I am going to touch on these modules in more detail later.

For the time being, let’s start with the basic imports to the src/server/main.js file.

Note that I am referring to the directory structure of the boilerplate I provided at the top of the article, so this might change for your setup.

electron-util is a useful package to conditionally enable things such as devTools when you are in development mode.

Don’t forget to install electron-util before importing:

const { app, BrowserWindow } = require('electron');
const { is } = require('electron-util');
const path = require('path');
Enter fullscreen mode Exit fullscreen mode

Creating a window

In Electron, the BrowserWindow API creates a browser window with the specified options and loads a desired URL to display.

Let’s create a function called createMainWindow and create a BrowserWindow in it.

let mainWindow = null;

const createMainWindow = () => {
  mainWindow = new BrowserWindow({
    backgroundColor: '#FFF',
    width: 250,
    height: 150,
    webPreferences: {
      devTools: is.development,
      nodeIntegration: true,
    }
  });
  if (is.development) {
    mainWindow.webContents.openDevTools({ mode: 'detach' });
    mainWindow.loadURL('http://localhost:3000');
  } else {
    mainWindow.loadURL(`file://${path.join(__dirname, '../../build/index.html')}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

The reason I declared the mainWindow variable out of the createMainWindow function is because we will create a class to create a Tray object out of this BrowserWindow later, so we need to access the variable globally.

The API documentation helps you figure out all the available settings, so I won’t go into much detail about that.

However, you’ll see we use is.development to open devTools and loadURL from local the development server. You can also use the build folder if it is built for production.

To create the BrowserWindow instance, we need to wait until the app is ready.

Therefore, we need to use ready event, which is emitted when Electron has finished initializing:

app.on('ready', () => {
  createMainWindow();
});
Enter fullscreen mode Exit fullscreen mode

So far, we have only created a BrowserWindow and specified what to display in it.

However, we don’t have any renderer process running.

If you run npm run client, this will start a server on localhost port 3000, which is the specific port loaded by the loadURL method in development mode.

Since there is already a component rendering a text, you can go ahead and run npm run server in another terminal instance.

This will run the Electron app. You should see the following application window:

An image of the Electron Minimal Boilerplate product.

Editing src/client/components/App/index.js will re-render the component and update the contents of the mainWindow automatically.

Creating a tray object

The mainWindow we created appears in the middle of the screen, but we want to create an icon in the menu bar and toggle this window when it’s clicked.

To do that, we first need to create a Tray object, which displays the specified icon in the menu bar.

Since we need to position and toggle our mainWindow object and also preferably be able to show a system menu when right clicked to Tray icon, it is more convenient to create a class which bundles all the functionalities inside.

So, let’s create a file named TrayGenerator.js under src/server.

In the TrayGenerator, class we need the following functions along with a constructor accepting a BrowserWindow object, which is the mainWindow variable in our case:

  • getWindowPosition
  • showWindow
  • toggleWindow
  • rightClickMenu
  • createTray
const { Tray, Menu } = require('electron');
const path = require('path');

class TrayGenerator {
  constructor(mainWindow) {
    this.tray = null;
    this.mainWindow = mainWindow;
  }
  getWindowPosition = () => {
  };

  showWindow = () => {
  };

  toggleWindow = () => {
  };

  rightClickMenu = () => {
  }

  createTray = () => {
  };
}

module.exports = TrayGenerator;
Enter fullscreen mode Exit fullscreen mode

getWindowPosition()

This function needs to figure out the position and dimensions of the Tray icon and BrowserWindow in order to find the x and y coordinates that place BrowserWindow centered and right relative to the Tray icon.

The getBounds() function called on BrowserWindow and Tray objects returns x and y positions, along with width and height information.

Although a Tray object instance is not assigned to this.tray yet, it will be assigned at the time getWindowPosition() is called, so assume that it has the appropriate object assigned.

getWindowPosition = () => {
  const windowBounds = this.mainWindow.getBounds();
  const trayBounds = this.tray.getBounds();
  const x = Math.round(trayBounds.x + (trayBounds.width / 2) - (windowBounds.width / 2));
  const y = Math.round(trayBounds.y + trayBounds.height);
  return { x, y };
};
Enter fullscreen mode Exit fullscreen mode

Returned x and y values guarantee that the mainWindow will be positioned centered and below the Tray icon.

showWindow()

This function is responsible for setting the position of mainWindow by using the previous function we created, getWindowPosition().

First, we set the position of mainWindow by using the setPosition method.

Then we make it visible by using the show method.

Note that although mainWindow is visible by default, we will set it to be invisible later since we don’t want it to appear when the menu bar application runs.

In macOS, there are multiple desktops (workspaces), so if you click the Tray icon and show the window, then switch to another desktop and click the icon there again, you should make sure that it does not focus the previously opened window.

In order to handle this, we make it visible on all desktops by using the setVisibleOnAllWorkspaces method, then focus the one visible on the active desktop.

We hide the rest by passing false to same method:

showWindow = () => {
  const position = this.getWindowPosition();
  this.mainWindow.setPosition(position.x, position.y, false);
  this.mainWindow.show();
  this.mainWindow.setVisibleOnAllWorkspaces(true);
  this.mainWindow.focus();
  this.mainWindow.setVisibleOnAllWorkspaces(false);
};
Enter fullscreen mode Exit fullscreen mode

toggleWindow()

This is a very straightforward function.

We use the isVisible method to figure out whether the window is visible. If it is, we hide it.

If it’s not, we show it.

This function will later be attached to the click event listener of the Tray object.

toggleWindow = () => {
  if (this.mainWindow.isVisible()) {
    this.mainWindow.hide();
  } else {
    this.showWindow();
  }
};
Enter fullscreen mode Exit fullscreen mode

rightClickMenu()

This function creates an array for the options of a contextMenu, which will be shown when right clicked on the Tray icon.

You can find the details on the popUpContextMenu and buildFromTemplate methods used on Tray and Menu along with other available options used to create a menu in the following API documentations:

Tray | Electron

Add icons and context menus to the system’s notification area.

Menu | Electron

Create native application menus and context menus.

rightClickMenu = () => {
  const menu = [
    {
      role: 'quit',
      accelerator: 'Command+Q'
    }
  ];
  this.tray.popUpContextMenu(Menu.buildFromTemplate(menu));
}
Enter fullscreen mode Exit fullscreen mode

createTray()

This final method eventually creates a Tray instance by passing the path to the Tray icon.

Create a folder named assets under /server and put your icon file in PNG format there.

It’s a rule for macOS that the icon file name should end with Template, hence the name selection of IconTemplate.

You need two versions with the @1x and @2x suffixes, which corresponds to 16×16 and 32×32 pixels in size.

It is required to enable the setIgnoreDoubleClickEvents method on macOS so that double click events are ignored and every individual click is registered as a single click.

Now we need to use the toggleWindow() function to toggle mainWindow whenever the Tray icon is clicked.

Likewise, we need to call rightClickMenu when the Tray icon is right clicked in order to display the contextMenu.

So we use click and right-click event listeners on our tray object:

createTray = () => {
  this.tray = new Tray(path.join(__dirname, './assets/IconTemplate.png'));
  this.tray.setIgnoreDoubleClickEvents(true);

  this.tray.on('click', this.toggleWindow);
  this.tray.on('right-click', this.rightClickMenu);
};
Enter fullscreen mode Exit fullscreen mode

Now we are done with the TrayGenerator class.

It’s time to instantiate it and then call the createTray method on it.

But first, import the TrayGenerator class at the top of the main.js file:

const TrayGenerator = require('./TrayGenerator');
Enter fullscreen mode Exit fullscreen mode

Then initialize TrayGenerator by passing mainWindow and calling the createTray() method on its instance to generate a Tray object.

We do this right after calling createMainWindow(), which creates and assigns an instance of BrowserWindow to the mainWindow variable.

app.on('ready', () => {
  createMainWindow();
  const Tray = new TrayGenerator(mainWindow);
  Tray.createTray();
});
Enter fullscreen mode Exit fullscreen mode

Further tweaks

If you run npm run client and then npm run server , you will notice that the mainWindow still appears in the middle of the screen and then disappears when you click the Tray icon.

And if you click the Tray icon once again, it repositions itself below the icon as we desired.

This is because we didn’t hide the mainWindow initially.

So, the following options passed to BrowserWindow ensure that:

– The window won’t be visible initially

– The window won’t have a frame

– It won’t be possible to put the window in fullscreen mode

– The user won’t be able to resize the window

show: false,
frame: false,
fullscreenable: false,
resizable: false,
Enter fullscreen mode Exit fullscreen mode

You may have noticed that although we have a Tray icon in the menu bar, the application icon is still visible in the dock.

If you don’t want this, you can simply call the following line to hide it:

app.dock.hide();
Enter fullscreen mode Exit fullscreen mode

So, after all the adjustments, the final code in main.js looks as follows:

// eslint-disable-next-line import/no-extraneous-dependencies
const { app, BrowserWindow } = require('electron');
const { is } = require('electron-util');
const path = require('path');
const TrayGenerator = require('./TrayGenerator');

let mainWindow = null;

const createMainWindow = () => {
  mainWindow = new BrowserWindow({
    backgroundColor: '#FFF',
    width: 300,
    height: 150,
    show: false,
    frame: false,
    fullscreenable: false,
    resizable: false,
    webPreferences: {
      devTools: is.development,
      nodeIntegration: true,
    }
  });
  if (is.development) {
    mainWindow.webContents.openDevTools({ mode: 'detach' });
    mainWindow.loadURL('http://localhost:3000');
  } else {
    mainWindow.loadURL(`file://${path.join(__dirname, '../../build/index.html')}`);
  }
};

app.on('ready', () => {
  createMainWindow();
  const Tray = new TrayGenerator(mainWindow);
  Tray.createTray();
});

app.dock.hide();
Enter fullscreen mode Exit fullscreen mode

Persisting data on main process

Whether you want to store some user preferences or application state, there is a very handy npm package called electron-store to persist data on main process.

Let’s use this package to store a user preference, which is important for menu bar applications: “Launch at startup”.

Automatically launch at startup

Install and import the package and then create a store with a schema in main.js:

const Store = require('electron-store');
const schema = {
  launchAtStart: true
}
const store = new Store(schema);
Enter fullscreen mode Exit fullscreen mode

The next thing we want to do is to be able to toggle this value.

This can be done in the renderer process, or we can add this functionality to contextMenu we created earlier, which is triggered by right clicking on the Tray icon.

Let’s change the TrayGenerator class slightly so that it also accepts a store object and shows a toggle option for “Launch at startup”.

We add a new menu item, which has this type of checkbox.

Its state should depend on the launchAtStart key, which we defined in the schema that we used to initialize the store.

So, to fetch this value, we use the get method on the store object.

And whenever we click this menu item, we get the value of the checkbox and store it as the value of the launchAtStart key by using the set method.

So, final version of the TrayGenerator.js file looks like this:

constructor(mainWindow, store) {
  this.tray = null;
  this.store = store;
  this.mainWindow = mainWindow;
}

rightClickMenu = () => {
  const menu = [
    {
      label: 'Launch at startup',
      type: 'checkbox',
      checked: this.store.get('launchAtStart'),
      click: event => this.store.set('launchAtStart', event.checked),
    },
    {
      role: 'quit',
      accelerator: 'Command+Q'
    },
  ];
  this.tray.popUpContextMenu(Menu.buildFromTemplate(menu));
}
Enter fullscreen mode Exit fullscreen mode

And don’t forget to pass the store object as the second argument when creating the TrayGenerator instance.

const Tray = new TrayGenerator(mainWindow, store);
Enter fullscreen mode Exit fullscreen mode

Now you should be able to see the “Launch at startup” option in the contextMenu opened through right-click.

Although we store the value of the checkbox in the store object under the name launchAtStart key, we didn’t make use of it to add our application to the system’s login items list.

This is done by calling the setLoginItemSettings method on app with an object, which has the key of openAtLogin with the value of launchAtStart store item.

app.setLoginItemSettings({
  openAtLogin: store.get('launchAtStart'),
});
Enter fullscreen mode Exit fullscreen mode

Communication between renderer and main processes

So far we have only created a window and triggered it with a Tray icon click.

However, the actual application lives on the client side, which is the renderer process.

Building and styling the application on renderer process with React is no different than building a regular React application.

But, when it comes to communication between renderer and main processes, this is something handled through Electron API.

ipcMain and ipcRenderer

The communication between these two processes happens through ipcMan and ipcRenderer modules.

ipcRenderer can both listen and send messages, whereas ipcMain can only listen for messages coming from the Renderer process.

In order to send a message to a specific window, webContents.send() should be called on the specific BrowserWindow object.

Below you can see the example demonstration of the communication.

  • Main process => Renderer process:
// In main process
const { ipcMain } = require('electron');
mainWindow.webContents.send('MSG_FROM_MAIN', 'hello renderer');

// In renderer process
const { ipcRenderer } = window.require('electron');
ipcRenderer.on('MSG_FROM_MAIN', (event, data) => console.log(data));
Enter fullscreen mode Exit fullscreen mode
  • Renderer process => Main process
// In renderer process
const { ipcRenderer } = window.require('electron');
ipcRenderer.send('MSG_FROM_RENDERER', 'hello main');

// In main process
const { ipcMain } = require('electron');
ipcMain.on('MSG_FROM_RENDERER', (event, data) => console.log(data));
Enter fullscreen mode Exit fullscreen mode

IPC in action

Let’s build a very basic React app and store its state data on the electron-store we have created in the previous section.

The React app is a basic counter where you can increase or decrease a number by clicking the buttons.

The basic React counter app.

I am just sharing the component code along with the styling without getting into any details since it is very basic. I will go into the details of the IPC connection.

import React from 'react';
import styles from './styles.sass';
class App extends React.Component {
  constructor() {
    super();
    this.state = {
      counter: 0
    };
  }

  increase = () => {
    this.setState(prevState => ({ counter: prevState.counter + 1 }));
  }

  decrease = () => {
    const { counter } = this.state;
    if (counter) {
      this.setState(prevState => ({ counter: prevState.counter - 1 }));
    }
  }

  render() {
    const { counter } = this.state;
    return (
      <div className={styles.app}>
        <button
          type="button"
          className={styles.button}
          onClick={this.decrease}
        >
          -
        </button>
        <div className={styles.counter}>{counter}</div>
        <button
          type="button"
          className={styles.button}
          onClick={this.increase}
        >
          +
        </button>
      </div>
    );
  }
}
export default App;
Enter fullscreen mode Exit fullscreen mode
body
  margin: 0
.app
  align-items: center
  display: flex
  font-family: monospace
  font-size: 16px
  height: 100vh
  justify-content: space-around
  padding: 0 40px

  .counter
    font-size: 20px

  .button
    align-items: center
    border: 1px solid black
    border-radius: 50%
    cursor: pointer
    display: flex
    font-size: 20px
    height: 50px
    justify-content: center
    outline: none
    width: 50px
Enter fullscreen mode Exit fullscreen mode

When the user interacts with the app and changes the state, we need to send this information to main process to be stored.

As we discussed earlier, sending data from renderer to main process happens through ipcRenderer.send();.

Sending from renderer process

Let’s include the ipcRenderer from Electron.

const { ipcRenderer } = window.require('electron');
Enter fullscreen mode Exit fullscreen mode

Now, every time we update the state, we need to send a message.

In increase and decrease functions, we call setState.

Its callback function is where we need to dispatch our message.

Since we are going to send the same message along with the data, I’ve created a function named sendCounterUpdate and called this function in the callback of setState by passing the state variable counter as the argument.

Note that if you use object destructuring as const {counter} = this.state to pass the counter state, this would be referring to the old state, not the recently updated state, hence the use of this.state.counter as the parameter of this.sendCounterUpdate.

sendCounterUpdate = (data) => {
      ipcRenderer.send('COUNTER_UPDATED', data);
    }

    increase = () => {
  this.setState(prevState => (
    { counter: prevState.counter + 1 }
  ), () => {
    this.sendCounterUpdate(this.state.counter);
  });
}

decrease = () => {
  const { counter } = this.state;
  if (counter) {
    this.setState(prevState => (
      { counter: prevState.counter - 1 }
    ), () => {
      this.sendCounterUpdate(this.state.counter);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the message is sent to main process, but we don’t have any mechanism listening for this message.

Receiving on renderer process

Include ipcMain from Electron in your main.js file.

const { app, BrowserWindow, ipcMain } = require('electron');
Enter fullscreen mode Exit fullscreen mode

Inside the callback function of the ready event of app, we listen for the specific message we sent from renderer process and store the received data in counterValue key of the store.

We also have to add this key to schema of the store.

So, update the schema variable as follows with an initial value of 0:

const schema = {
  launchAtStart: true,
  counterValue: 0
}
app.on('ready', () => {
  createMainWindow();
  const Tray = new TrayGenerator(mainWindow, store);
  Tray.createTray();

  ipcMain.on('COUNTER_UPDATED', (event, data) => {
    store.set('counterValue', data);
  });
});
Enter fullscreen mode Exit fullscreen mode

Now the message is sent to main process, but we don’t have any mechanism listening for this message.

Storing this value alone doesn’t have any use. Therefore, we need to send this value back to the renderer process each time the app is initialized.

We also need to listen for this message on renderer process.

Sending from main process

As we discussed earlier, this happens by calling the send method on mainWindow.webContent, where mainWindow is the window that we want to send the message to.

Before sending a message, we need to make sure that the browserWindow instance we created (mainWindow) is finished loading.

If we don’t do that check, chances are our message would be sent before the renderer process starts listening for it and we could miss it as a result.

did-finish-load event is emitted on mainWindow.webContents and guarantees that the renderer process is loaded and ready.

Inside the callback function of mainWindow.webContents.on('did-finish-load'), we send an INITIALIZE_COUNTER message along with counterValue data fetched from the store.

app.on('ready', () => {
  createMainWindow();
  const Tray = new TrayGenerator(mainWindow, store);
  Tray.createTray();

  ipcMain.on('COUNTER_UPDATED', (event, data) => {
    store.set('counterValue', data);
  });

  mainWindow.webContents.on('did-finish-load', () => {
    mainWindow.webContents.send('INITIALIZE_COUNTER', store.get('counterValue'));
  });
});
Enter fullscreen mode Exit fullscreen mode

Receiving on main process

In the React app, we use componentDidMount lifecycle hook to start listening for the INITIALIZE_COUNTER message and set the counter state with the received data whenever this message is sent from the main process, which happens only one time right after the renderer process is finished loading.

componentDidMount() {
  ipcRenderer.on('INITIALIZE_COUNTER', (event, counter) => {
    this.setState({ counter });
  });
}
Enter fullscreen mode Exit fullscreen mode

Final versions of both main.js and the React component are below

Main.js:

const { app, BrowserWindow, ipcMain } = require('electron');
const Store = require('electron-store');
const { is } = require('electron-util');
const path = require('path');
const TrayGenerator = require('./TrayGenerator');

const schema = {
  launchAtStart: true,
  counterValue: 0
};

const store = new Store(schema);
let mainWindow = null;

const createMainWindow = () => {
  mainWindow = new BrowserWindow({
    backgroundColor: '#FFF',
    width: 300,
    height: 150,
    show: false,
    frame: false,
    fullscreenable: false,
    resizable: false,
    webPreferences: {
      devTools: is.development,
      nodeIntegration: true,
    }
  });
  if (is.development) {
    mainWindow.webContents.openDevTools({ mode: 'detach' });
    mainWindow.loadURL('http://localhost:3000');
  } else {
    mainWindow.loadURL(`file://${path.join(__dirname, '../../build/index.html')}`);
  }
};

app.on('ready', () => {
  createMainWindow();
  const Tray = new TrayGenerator(mainWindow, store);
  Tray.createTray();
  ipcMain.on('COUNTER_UPDATED', (event, data) => {
    store.set('counterValue', data);
  });
  mainWindow.webContents.on('did-finish-load', () => {
    mainWindow.webContents.send('INITIALIZE_COUNTER', store.get('counterValue'));
  });
});

app.dock.hide();
Enter fullscreen mode Exit fullscreen mode

React component:

import React from 'react';
import styles from './styles.sass';
const { ipcRenderer } = window.require('electron');

class App extends React.Component {
  constructor() {
    super();
    this.state = {
      counter: 0
    };
  }

  componentDidMount() {
    ipcRenderer.on('INITIALIZE_COUNTER', (event, counter) => {
      this.setState({ counter });
    });
  }

  sendCounterUpdate = (data) => {
    ipcRenderer.send('COUNTER_UPDATED', data);
  }

  increase = () => {
    this.setState(prevState => (
      { counter: prevState.counter + 1 }
    ), () => {
      this.sendCounterUpdate(this.state.counter);
    });
  }

  decrease = () => {
    const { counter } = this.state;

    if (counter) {
      this.setState(prevState => (
        { counter: prevState.counter - 1 }
      ), () => {
        this.sendCounterUpdate(this.state.counter);
      });
    }
  }

  render() {
    const { counter } = this.state;

    return (
      <div className={styles.app}>
        <button
          type="button"
          className={styles.button}
          onClick={this.decrease}
        >
          -
        </button>
        <div className={styles.counter}>{counter}</div>
        <button
          type="button"
          className={styles.button}
          onClick={this.increase}
        >
          +
        </button>
      </div>
    );
  }
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Distributing the application

After you are done with implementation, the final step is to distribute your app.

There are several different packages for distributing an electron app, but I personally use electron-builder and this is already included in the boilerplate, which I have shared at the beginning of this article.

If you are using the boilerplate, all you have to do is run npm run build to create a production build of the React app, and then run npm run dist to distribute your Electron app.

Conclusion

Those who use their own setup can get more information regarding electron-builder here.

Happy coding!


Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

Alt Text

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — start monitoring for free.


The post Building a menu bar application with Electron and React appeared first on LogRocket Blog.

Top comments (0)