Electron and ReactJS application

published: ⏰ 10

Previously, I created examples using Spring Boot with ReactJS and Go with HTML, but those were client-server applications. Sometimes you need to create a simple desktop application that needs to be used locally. One option is to use Electron.

Introduction

There are many options for creating desktop applications: Qt/wxWidgets/GTK/MFC (C++), Swing/JavaFX (Java), WPF/WinForms (C#), Electron (JS/TS), Flutter (Dart), and others. But if you have experience with JavaScript/TypeScript and ReactJS, Electron might be a good choice.

Electron is a framework that allows you to use web technologies (HTML, CSS, JavaScript) to create cross-platform desktop applications. Under the hood, it combines the Chromium engine (for displaying the interface) and Node.js (for interacting with the operating system). This means that any web application can be made desktop-ready with virtually no changes to the interface code. You can use any library for the user interface; in my case, ReactJS, since I currently use it everywhere.

One of the key advantages is a unified codebase with web applications. For example, React might have a pre-existing component library that can be used in the desktop app. You can also use the same styles as the web app. And all of this can be compiled for different platforms and looks consistent across them. React (Vue, Angular) itself is pointless; they’re vast ecosystems with numerous ready-made libraries and tools.

Naturally, there’s also access to native APIs: the file system, menu, tray, notifications, and other system functions.

Of course, there are some downsides:

  • Since the entire Chromium engine is distributed with the app, the minimum installer size is typically around 100-150 MB.
  • Probably the biggest issue is memory consumption: Each Electron window is essentially a separate browser process, which can consume more RAM than native apps.
  • You’ll need to additionally describe the bridges (IPC) between the UI and NodeJS.

Electron Architecture

To access business logic in a typical web app, we simply call a server-side API; in a native app, everything is located in a single process. In Electron, things are more complex: the rendering process (frontend) shouldn’t have direct access to the OS. Therefore, all interaction with the OS must occur through a special interprocess bridge (IPC).

arch.svg

For example:

  • A UI component wants to save a file.
  • It sends a message via ipcRenderer to the preload script.
  • The preload script checks the request and forwards it to the main process via ipcRenderer.invoke.
  • The main process performs a file system operation via the fs module.
  • The result is returned back up the chain to the UI.

Creating an Application

This example will be based on React + TypeScript + Vite, in the form of a simple application for managing website content on Hugo.

Setting Up the Environment

The easiest way to get started with Electron is to use templates. There are official ones: minimal-repro and Electron Forge. However, in my projects, I use Vite as my primary build system. For integrating Vite with Electron, there’s a good template, electron-vite. It’s preconfigured to separate processes (main, preload, renderer). This significantly saves time on creating the project structure and manually setting up configuration files.

To create a new project, use the following command:

Shell
# Create a new project
npm create @quick-start/electron@latest sample-hugo-content-manager -- --template react-ts

# Change to the project directory
cd sample-hugo-content-manager

# Install dependencies
npm install

# Run the application
npm run dev

As a result, we will get a ready-made project structure, which can be run with the command npm run dev.

Всё, дальше можно добавлять функционал. Полный код приложения приводить не стану, так как сильно увеличивает объем статьи, поэтому будут ссылки на репозитории и только важные моменты.

IPC (API)

All interactions with the main process in Electron occur via IPC (Inter-Process Communication). Electron has two main processes: the main process and the renderer process. ipcMain and ipcRenderer are used to interact with the main process and display data in the UI.

In short:

  • ipcMain is an API for handling events from the renderer process (backend).
  • ipcRenderer is an API for sending events to the main process (frontend -> backend).

The initial template will have one simple function, ping, which will display the message pong in the console. Since we need to create a site content editor, we will need the following functions:

  • Getting a list of pages.
  • Getting the contents of a single page.
  • Saving page contents.
  • Getting page statistics (languages)
  • Selecting a directory with the site content.

Below is an example of an interaction description for retrieving a list of articles (listPages).

Since I’m using TypeScript, I need to inform the compiler that a new api property will be added to the window object, through which I’ll access Electron functions. The browser doesn’t have this property, so I need to create a declaration file index.d.ts.

index.d.ts

TypeScript
declare global {
    interface Window {
        electron: ElectronAPI
        api: {
            listPages: () => Promise<ListPagesResponse>
            // ... other methods
        }
    }
}

TypeScript will now understand calls to window.api.listPages and provide argument hints.

We also need to add our API to preload (index.ts). This is necessary so preload can access the API and pass it to the main process.

TypeScript
const api = {
    listPages: async (): Promise<ListPagesResponse> => {
        return ipcRenderer.invoke('content:list-pages')
    }
    // ... other methods
}

if (process.contextIsolated) {
    try {
        contextBridge.exposeInMainWorld('electron', electronAPI)
        contextBridge.exposeInMainWorld('api', api)
    } catch (error) {
        console.error(error)
    }
} else {
    window.electron = electronAPI
    window.api = api
}

contextBridge.exposeInMainWorld is a method that allows you to pass data from preload to the main process. It creates a global window.api object, which will be available in the renderer process with the methods we’ve enabled. We don’t directly pass any OS interaction logic here. All file handling logic will remain in contentManager.ts.

In my case, the business logic mapping is located in ipcs.ts to avoid overloading index.ts.

ipcs.ts

TypeScript
  ipcMain.handle('content:list-pages', async (): Promise<ListPagesResponse> => {
    try {
        const pages = await scanContentDir()
        return {pages}
    } catch (error) {
        console.error('Error listing pages:', error)
        return {pages: [], error: String(error)}
    }
})

In ipcMain, we register the content:list-pages handler, which will be called from the renderer. Inside, we call scanContentDir(), which returns a list of pages.

The logic itself is implemented in contentManager.ts.

contentManager.ts

TypeScript
export async function scanContentDir(): Promise<PageContent[]> {
    try {
        const files = await recursivelyListFiles(getContentPath())
        const markdownFiles = files.filter((f) => f.relativePath.endsWith('.md'))

        const pages: PageContent[] = []
        for (const file of markdownFiles) {
            try {
                const content = await fs.readFile(file.path, 'utf-8')
                const page = await parsePageContent(content, file, true)
                if (page) {
                    pages.push(page)
                }
            } catch (error) {
                console.error(`Error reading file ${file.path}:`, error)
            }
        }

        return pages
    } catch (error) {
        console.error('Error scanning content directory:', error)
        throw error
    }
}

In scanContentDir, we get a list of all files in the content directory, filter only .md files, and parse their contents.

Using the API in the renderer

To interact with the main process from the renderer process, I’ll use a global React context, which will encapsulate all the API logic. It’s probably better to use alternative state managers (Mobx, Redux, Zustand, etc.), but for the sake of simplicity, I’ll implement it using React.Context and the useGlobalState hook.

TypeScript

export interface ApplicationState {
    pages: PageContent[] // ... other properties    
}

export interface ApplicationContextProviderProps {
    children: React.ReactNode
}

const ApplicationContext: React.Context<ApplicationState | undefined> = createContext<
    ApplicationState | undefined
>(undefined)

export const ApplicationContextProvider: React.FC<ApplicationContextProviderProps> = (
    props: ApplicationContextProviderProps
) => {
    const [pages, setPages] = useState<PageContent[]>([])

    // ... other state variables

    const loadPages = useCallback(async () => {
        const result = await window.api.listPages()
        // ... other logic
        setPages(result.pages)
    }, [])

    return (
        <ApplicationContext.Provider value={{
            pages, // ... other properties 
        }}>{props.children}</ApplicationContext.Provider>
    )
}

export function useGlobalState(): ApplicationState {
    const ctx = useContext(ApplicationContext)
    if (!ctx) {
        throw new Error('useGlobalState must be used within a ApplicationContextProvider')
    }
    return ctx as ApplicationState
}

Accordingly, ApplicationContextProvider must be wrapped around App:

TypeScript
createRoot(document.getElementById('root')!).render(
    <ApplicationContextProvider>
        <StrictMode>
            <App/>
        </StrictMode>
    </ApplicationContextProvider>
)

User Interface

Once we’ve implemented the data retrieval logic, we need to display it in the UI. There’s no point in describing how to build React apps; it’s not that important. To call the API in components, you need to use the previously created useGlobalState hook.

TypeScript
function App(): React.JSX.Element {
    const {
        pages, loadPages, // ... other state variables
    } = useGlobalState()

    // ... other state variables

    useEffect(() => {
        loadPages()
    }, [loadPages])

    // ... other logic

    return (
        <ContentPagesTreeWidget
            pages={pages}
            // ... other props
        />
    );
}

When the app opens, we call loadPages() to retrieve data from the API. All data will be automatically saved in global state and passed to the ContentPagesTreeWidget component (the left panel for navigating through the site’s pages) for display.

ℹ️ Note

Sample site content in the samples project directory.

Next, we work for a while, or not so long, and then we see the result.

Database

Using a database to store application state isn’t mandatory; you can use Electron Store or localStorage. However, in my case, I decided to use SQLite as an example of how to implement a database for storing the path to the site’s content.

The main database initialization logic is located in the database/index.ts file in the main process. In this file, we create a database connection, initialize tables, and run migrations. The schema itself for primary initialization is located in the file resources/database/schema.sql.

database/index.ts
TypeScript
let db: Database.Database | null = null

function getSchemaPath(): string {
    const outPath = join(__dirname, '../../resources/database/schema.sql')
    if (!fs.existsSync(outPath)) {
        throw Error('Schema file not found: ' + outPath)
    }
    return outPath
}

export function getDB(): Database.Database {
    if (db) {
        return db
    }

    const userDataPath = app.getPath('userData')
    const dbPath = join(userDataPath, 'hcm.db')

    const isNewDb = !fs.existsSync(dbPath)
    const schema = isNewDb ? fs.readFileSync(getSchemaPath(), 'utf-8') : null

    db = new Database(dbPath)
    db.pragma('journal_mode = WAL')

    if (isNewDb) {
        db.exec(schema)
    }

    return db
}

export function closeDB(): void {
    if (db) {
        db.close()
        db = null
    }
}

export default getDB

schema.sql

SQL
CREATE TABLE IF NOT EXISTS settings (
    id INTEGER PRIMARY KEY,
    key TEXT UNIQUE NOT NULL,
    value TEXT NOT NULL
);

Security

This section is a separate topic beyond the scope of this article; I don’t want to overcomplicate things. However, it’s worth mentioning that when developing Electron, it’s important to understand that your application is essentially a browser with elevated privileges and OS access. Things to consider:

  1. Context Isolation and Node Integration:
  • nodeIntegration(false) prevents the use of require('electron') or require('fs') directly in React code. This prevents an attacker from gaining access to the file system through XSS (script injection).
  • contextIsolation(true) ensures that preload scripts run in a separate context. This means that variables defined in preload cannot be overwritten by code from the page (for example, via window.api = maliciousObject).
  1. Validate IPC data. In the ipcMain.handle handlers, we receive data from the renderer, and this data must be validated. Everything is done the same way as in regular web applications; we don’t trust the client.
  2. Content Security Policy (CSP) is a security mechanism that allows you to control the resources that can be loaded in an application. It can be set via meta tags or HTTP headers. Example:
HTML
<meta
        http-equiv="Content-Security-Policy"
        content="default-src 'self'; 
            script-src 'self'; 
            style-src 'self' 'unsafe-inline'; 
            img-src 'self' data:"/>
  1. Disable external link navigation. By default, if a user clicks a link like <a href="http://scammer.com">, Electron will open that website within the app window. We need to intercept the navigation and open external links in the system browser.
TypeScript
 mainWindow.webContents.setWindowOpenHandler((details) => {
    shell.openExternal(details.url)
    return {action: 'deny'}
})
  1. Updating dependencies (npm audit).

Building the Application

Once the application is ready, it needs to be packaged into an archive that’s easy to distribute and install. Electron-Builder is used for this. It supports building for all platforms, creating installers (NSIS for Windows, DMG for macOS, Deb/AppImage for Linux), and setting up automatic updates.

The electron-builder configuration is specified in the electron-builder.yml file . See the example below:

electron-builder.yml
TypeScript
appId: com.gihub.devmix.hugocontentmanager
productName: hugo - content - manager
directories:
    buildResources: build
files:
    -'!**/.vscode/*'
    - '!src/*'
    - '!electron.vite.config.{js,ts,mjs,cjs}'
    - '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
    - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
    - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
    -resources/**
 // ... packages
 npmRebuild: false
 publish:
 provider: generic
 url: https://example.com/auto-updates
 electronDownload:
 mirror: https://npmmirror.com/mirrors/electron/

In files, we specify which files to include in the build. Typically, these are compiled files from the dist folder (the output of Vite) and the source files of the main/preload processes, as they run in a Node environment.

If the application was created using electron-vite, you can build it using one of the following commands:

Shell
npm run build:linux
npm run build:win
npm run build:mac

These commands first compile the code using Vite and then run electron-builder. Once complete, the following installation files will appear in the dist folder:

  • .exe for Windows.
  • .dmg for macOS.
  • .AppImage, .deb, and snap for Linux.

Conclusion

Overall, Electron + ReactJS is well suited for creating full-fledged desktop applications, especially if you have experience with React/Node.js and established templates. Additionally, the UI can be subsequently migrated to a web application by rewriting the logic from the main process, if necessary.

However, there are some downsides:

  • You have to write IPC code to describe the interaction between the main and rendering processes. This is not required in native applications.
  • There are noticeable lags when working with the UI; after all, this is a browser application, not a native one. And JavaScript itself can be slow, especially when performing intensive calculations.
  • This might not be a downside, but the appearance differs from the OS shell interface.

ℹ️ Note

The sample project is available in the open source.

Electron Reactjs Desktop Nodejs Devmix