Converting a React web application into a desktop application using Electron allows you to leverage your existing codebase while accessing native desktop features. This guide will walk you through the process with practical examples.
What is Electron?
Electron is a framework that allows you to build cross-platform desktop applications using web technologies like JavaScript, HTML, and CSS. It combines Chromium (for rendering) and Node.js (for backend operations) into a single runtime.
Prerequisites
Before we begin, ensure you have:
- A functioning React application
- Node.js and npm installed
- Basic familiarity with JavaScript and React
Step 1: Set Up Your Project
Let's start by installing Electron in your existing React application:
# Navigate to your React project
cd my-react-app
# Install Electron and necessary dependencies
npm install --save-dev electron electron-builder concurrently wait-on cross-env
Step 2: Create Electron Main Process File
Create a new file called electron.js in your project's public directory:
const { app, BrowserWindow } = require('electron');
const path = require('path');
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
enableRemoteModule: true
}
});
mainWindow.loadURL(
!app.isPackaged
? "http://localhost:3000/"
: `file://${path.join(__dirname, "../build/index.html")}`
);
if (!app.isPackaged) {
mainWindow.webContents.openDevTools();
}
mainWindow.on('closed', () => mainWindow = null);
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});
Step 3: Update package.json
Modify your package.json file to include Electron-specific configurations:
{
"name": "my-electron-react-app",
"version": "0.1.0",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1"
},
"devDependencies": {
"electron": "^30.0.0",
"electron-builder": "^24.9.0",
"concurrently": "^8.2.2",
"wait-on": "^7.2.0",
"cross-env": "^7.0.3"
},
"main": "public/electron.js",
"homepage": "./",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"electron:dev": "concurrently \"cross-env BROWSER=none npm start\" \"wait-on http://localhost:3000 && electron .\"",
"electron:build": "npm run build && electron-builder -c.extraMetadata.main=build/electron.js"
}
}
Step 4: Accessing Native Features
One of the main advantages of Electron is the ability to access desktop features. Let's create a simple example that interacts with the file system:
import React, { useState } from 'react';
const { dialog } = window.require('electron').remote;
const fs = window.require('fs');
function FileSystemComponent() {
const [fileContent, setFileContent] = useState('');
const openFile = async () => {
const { filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text Files', extensions: ['txt'] }]
});
if (filePaths && filePaths.length > 0) {
const content = fs.readFileSync(filePaths[0], 'utf8');
setFileContent(content);
}
};
return (
<div>
<button onClick={openFile}>Open File</button>
{fileContent && (
<div>
<h3>File Content:</h3>
<pre>{fileContent}</pre>
</div>
)}
</div>
);
}
export default FileSystemComponent;
However, for newer Electron versions, you'll need to use IPC (Inter-Process Communication) instead of directly accessing Electron APIs from the renderer process:
Step 5: Setting Up IPC Communication
First, update your electron.js file:
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const fs = require('fs');
// ... existing code ...
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
// ... existing code ...
}
// Handle file open request
ipcMain.handle('file:open', async () => {
const { filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text Files', extensions: ['txt'] }]
});
if (filePaths && filePaths.length > 0) {
const content = fs.readFileSync(filePaths[0], 'utf8');
return content;
}
return null;
});
Then create a preload.js file in your public directory:
const { contextBridge, ipcRenderer } = require('electron');
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld(
'electron', {
openFile: () => ipcRenderer.invoke('file:open')
}
);
Finally, update your React component:
import React, { useState } from 'react';
function FileSystemComponent() {
const [fileContent, setFileContent] = useState('');
const openFile = async () => {
const content = await window.electron.openFile();
if (content) {
setFileContent(content);
}
};
return (
<div>
<button onClick={openFile}>Open File</button>
{fileContent && (
<div>
<h3>File Content:</h3>
<pre>{fileContent}</pre>
</div>
)}
</div>
);
}
export default FileSystemComponent;
Step 6: Building Your Application
Now that your application is set up, you can build it for distribution:
# Run in development mode
npm run electron:dev
# Build for production
npm run electron:build
The production build will be created in the dist directory, typically containing installers for your chosen platforms.
Real-World Example: Creating a Markdown Editor
Let's create a more practical example - a simple markdown editor that saves files to the desktop:
// Main Electron process (electron.js)
const { app, BrowserWindow, ipcMain, dialog, Menu } = require('electron');
const path = require('path');
const fs = require('fs');
let mainWindow;
let currentFilePath = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
});
mainWindow.loadURL(
!app.isPackaged
? 'http://localhost:3000'
: `file://${path.join(__dirname, '../build/index.html')}`
);
if (!app.isPackaged) {
mainWindow.webContents.openDevTools();
}
mainWindow.on('closed', () => mainWindow = null);
const template = [
{
label: 'File',
submenu: [
{
label: 'New',
accelerator: 'CmdOrCtrl+N',
click: () => {
mainWindow.webContents.send('file:new');
currentFilePath = null;
}
},
{
label: 'Open',
accelerator: 'CmdOrCtrl+O',
click: async () => {
await openFile();
}
},
{
label: 'Save',
accelerator: 'CmdOrCtrl+S',
click: async () => {
mainWindow.webContents.send('file:save-request');
}
},
{
label: 'Save As',
accelerator: 'CmdOrCtrl+Shift+S',
click: async () => {
mainWindow.webContents.send('file:save-as-request');
}
},
{ type: 'separator' },
{
label: 'Exit',
accelerator: 'CmdOrCtrl+Q',
click: () => {
app.quit();
}
}
]
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'delete' },
{ role: 'selectAll' }
]
}
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
async function openFile() {
const { filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'Markdown', extensions: ['md', 'markdown'] },
{ name: 'All Files', extensions: ['*'] }
]
});
if (filePaths && filePaths.length > 0) {
const content = fs.readFileSync(filePaths[0], 'utf8');
currentFilePath = filePaths[0];
mainWindow.webContents.send('file:opened', { filePath: currentFilePath, content });
return content;
}
return null;
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});
// IPC handlers
ipcMain.handle('file:open', openFile);
ipcMain.handle('file:save', async (event, { content, saveAs = false }) => {
let filePath = currentFilePath;
if (!filePath || saveAs) {
const { filePath: savePath, canceled } = await dialog.showSaveDialog({
title: 'Save Markdown File',
defaultPath: filePath || 'untitled.md',
filters: [
{ name: 'Markdown', extensions: ['md'] },
{ name: 'All Files', extensions: ['*'] }
]
});
if (canceled || !savePath) {
return { success: false };
}
filePath = savePath;
}
try {
fs.writeFileSync(filePath, content, 'utf8');
currentFilePath = filePath;
return { success: true, filePath };
} catch (error) {
return { success: false, error: error.message };
}
});
// Preload script (preload.js)
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld(
'electron', {
openFile: () => ipcRenderer.invoke('file:open'),
saveFile: (content, saveAs) => ipcRenderer.invoke('file:save', { content, saveAs }),
onFileOpened: (callback) => ipcRenderer.on('file:opened', (event, data) => callback(data)),
onNewFile: (callback) => ipcRenderer.on('file:new', () => callback()),
onSaveRequest: (callback) => ipcRenderer.on('file:save-request', () => callback()),
onSaveAsRequest: (callback) => ipcRenderer.on('file:save-as-request', () => callback())
}
);
// React Component for the markdown editor (App.js)
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import './App.css';
function App() {
const [markdown, setMarkdown] = useState('# Hello Electron Markdown\n\nStart typing...');
const [currentFile, setCurrentFile] = useState(null);
useEffect(() => {
// Listen for file open events
window.electron.onFileOpened((data) => {
setMarkdown(data.content);
setCurrentFile(data.filePath);
});
// Listen for new file events
window.electron.onNewFile(() => {
setMarkdown('# New Document\n\nStart typing...');
setCurrentFile(null);
});
// Listen for save requests
window.electron.onSaveRequest(() => {
saveFile(false);
});
// Listen for save-as requests
window.electron.onSaveAsRequest(() => {
saveFile(true);
});
}, []);
const saveFile = async (saveAs) => {
const result = await window.electron.saveFile(markdown, saveAs);
if (result.success) {
setCurrentFile(result.filePath);
}
};
return (
<div className="app">
<div className="file-info">
{currentFile ? `Editing: ${currentFile}` : 'New Document'}
</div>
<div className="container">
<div className="editor-pane">
<textarea
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
className="markdown-input"
/>
</div>
<div className="preview-pane">
<ReactMarkdown>{markdown}</ReactMarkdown>
</div>
</div>
</div>
); }
export default App;
// CSS Styling (App.css)
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.file-info {
background-color: #f0f0f0;
padding: 8px 16px;
font-size: 12px;
border-bottom: 1px solid #ddd;
}
.container {
display: flex;
flex: 1;
}
.editor-pane, .preview-pane {
flex: 1;
overflow: auto;
padding: 20px;
} .editor-pane {
border-right: 1px solid #ccc;
}
.markdown-input {
width: 100%;
height: 100%;
border: none;
outline: none;
resize: none;
font-family: monospace;
font-size: 14px;
}
.preview-pane {
background-color: #fafafa;
}
Step 7: Packaging for Distribution
To distribute your application to users, you'll need to package it appropriately for each platform:
# Add platform-specific build configurations to package.json
"build": {
"appId": "com.yourcompany.markdowneditor",
"productName": "Markdown Editor",
"mac": {
"category": "public.app-category.productivity"
},
"win": {
"target": [
"nsis"
]
},
"linux": {
"target": [
"deb",
"AppImage"
],
"category": "Office"
}
}
Common Challenges and Solutions
Challenge 1: Working with Native Modules
Native Node.js modules often need to be rebuilt for Electron:
# Install electron-rebuild
npm install --save-dev electron-rebuild
# Add to package.json scripts
"postinstall": "electron-rebuild"
Challenge 2: Optimizing Application Size
Electron apps can be large. Consider these optimizations in your package.json:
"build": {
"files": [
"build/**/*",
"node_modules/**/*"
],
"extraResources": [
"./assets/**"
],
"asar": true
}
Challenge 3: Handling Updates
For automatic updates, consider adding Electron Updater:
npm install electron-updater
Then implement it in your main process:
const { autoUpdater } = require('electron-updater');
// Check for updates
autoUpdater.checkForUpdatesAndNotify();
// Listen for update events
autoUpdater.on('update-available', () => {
mainWindow.webContents.send('update-available');
});
autoUpdater.on('update-downloaded', () => {
mainWindow.webContents.send('update-downloaded');
});
Conclusion
Converting a React application to a desktop app with Electron combines the best of both worlds: the rich ecosystem and development experience of React with the desktop capabilities of Electron. By following the steps in this guide, you can transform your web application into a fully featured desktop experience.
Remember that Electron applications give you access to file systems, notifications, system tray integration, and more - opening possibilities that simply aren't available in traditional web applications. Use these capabilities wisely to enhance your application's user experience.