Building Cross-Platform Desktop Apps with React, Electron, and Electron-Vite

Bantawa Pawan
readytowork, Inc.
Published in
10 min readJun 27, 2023

In modern times, the demand for cross-platform desktop apps has soared. To meet this demand, developers are seeking efficient solutions that cater to diverse operating systems. That’s where the powerful combination of React, Electron, and Electron-Vite comes into play. This blog post will guide you through creating a fundamental to-do list app using these tools highlighting their potential along the way.

In this blog post, we’ll show you how to build a to-do list app using React Electron and Electron-Vite. We’ll cover every step along the way — from setting up your development environment to building the user interface and packaging your app for distribution.

Setting up the environment

  • yarn create electron-vite react-todo : It will create a project folder named react-todo. Select React on the page template with the arrow up/down key and press enter.
  • cd react-todo : Change the directory to the project folder
  • yarn install : Install dependencies
  • yarn dev Run the development server
  • yarn add bulma bulma-toast react-router-dom: Bulma css ,toast notification and react-router for routing
fig: App window

Project folder structure:

fig: project folder structure

React files are placed in /src a folder like in any web app. Leading process and render process files are placed inside the electron folder:

In Electron, the main process and renderer process are two essential components that work together to create and run desktop applications.

Main process: The main process handles the application’s lifecycle, controlling system-level operations, and creating windows and menus. It also communicates with the renderer process when necessary.

Render process: The renderer process runs within an individual Electron window or web view. The renderer process renders and displays the user interface components of the application. Each Electron window or web view has its own renderer process which is isolated from other renderer processes.

In this project, we will create a React Electron app that focuses on building a simple yet functional to-do list application. The app will consist of three main pages: the home page, where users can see the to-do list; the view to-do page, which displays details about a specific to-do item; and the edit to-do page, which allows users to edit individual items’ details.

To store the data for the to-do list app, we will leverage the browser’s built-in local storage functionality. This means that the data is stored directly in your browser and is accessible even after closing and reopening the app.

Layout and functionality:

App.scss

.container {
margin: 1rem
}

.logo-box {
position: relative;
height: 4em;
}

.logo {
position: absolute;
left: calc(50% - 4.5em);
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}

.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}

@keyframes logo-spin {
from {
transform: rotate(0deg);
}

to {
transform: rotate(360deg);
}
}

@media (prefers-reduced-motion: no-preference) {
.logo.electron {
animation: logo-spin infinite 20s linear;
}
}

.todosWrapper {
display: flex;
flex-direction: column;

.todoItem {
display: flex;
align-items: center;
margin-top: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #000;

.changeStatus {
flex: 0 0 auto;
margin-right: 10px;
}

.taskTitle {
flex: 1 1 auto;
}

.completed {
text-decoration: line-through;
}

.actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;

div {
cursor: pointer;
}
}
}
}

main.tsx

import React from "react"
import ReactDOM from "react-dom/client"
import App from "./App"
import "bulma/css/bulma.min.css"
import "./App.scss"
import { RouterProvider, createHashRouter } from "react-router-dom"
import View from "./page/todo/view"
import Hero from "./components/Hero"
import Edit from "./page/todo/edit"

const router = createHashRouter([
{
path: "/",
element: (
<>
<Hero />
<App />
</>
),
},
{
path: "view/:id",
element: (
<>
<Hero />
<View />
</>
),
},
{
path: "edit/:id",
element: (
<>
<Hero />
<Edit />
</>
),
},
])
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
)

postMessage({ payload: "removeLoading" }, "*")

App.tsx

import TodoForm from "@/components/TodoForm"
import { useEffect, useState } from "react"
import TodoItem from "./components/TodoItem"
import Count from "./components/Count"
import { toast } from "bulma-toast"
interface Todo {
id?: number | string
title: string
status?: boolean
}
interface Todos {
todos: Todo[]
count: number
}
function App() {
const [todo, setTodo] = useState<Todo>({
title: "",
status: false,
})
const [inputError, setInputError] = useState<boolean>(false)

const [todos, setTodos] = useState<Todos>(() => {
const savedTodos = localStorage.getItem("todos")
if (savedTodos) {
return JSON.parse(savedTodos)
} else {
return {
todos: [
{
id: 1,
title: "Test 1",
status: false,
},
{
id: 2,
title: "Test 2",
status: false,
},
{
id: 3,
title: "Test 3",
status: true,
},
],
count: 3,
}
}
})

const handleSubmit = (e: any) => {
e.preventDefault()

if (todo.title === "" || todo.title === null) {
setInputError(true)
return
}
const newId = todos.count + 1

const newTodo: Todo = {
id: newId,
title: todo.title,
status: todo.status,
}
toast({
message: "Todo created !",
type: "is-success",
})
setTodos((prevState) => ({
todos: [...prevState.todos, newTodo],
count: prevState.count + 1,
}))

setTodo({
title: "",
status: false,
})
}

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value !== "" || e.target.value !== null) {
setInputError(false)
}
setTodo((prevState) => ({
...prevState,
title: e.target.value,
}))
}

const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (e.target?.value === "" || e.target?.value === null) {
setInputError(true)
} else {
setInputError(false)
}
}

const deleteTodo = (id: number) => {
const updatedTodos = todos.todos.filter((todo) => todo.id !== id)
setTodos((_) => ({
count: todos.count - 1,
todos: updatedTodos,
}))
toast({
message: "Todo deleted !",
type: "is-success",
})
}

const handleStatus = (id: number) => {
const updatedTodos = todos.todos.map((todo) => {
if (todo.id === id) {
return {
...todo,
status: !todo.status,
}
}
return todo
})
setTodos((prevState) => ({
...prevState,
todos: updatedTodos,
}))
}

useEffect(() => {
localStorage.setItem("todos", JSON.stringify(todos))
}, [todos])

return (
<div className="container">
<TodoForm
handleChange={handleInputChange}
hasError={inputError}
value={todo.title}
handleBlur={handleBlur}
handleSubmit={handleSubmit}
/>
<Count count={todos.count} />
{todos.todos.map((item) => (
<TodoItem
todo={item}
key={item.id}
handleDelete={deleteTodo}
handleComplete={handleStatus}
/>
))}
</div>
)
}

export default App

count/index.tsx

import React from "react"
interface ICount {
count: number
}
const Count: React.FC<ICount> = ({ count }) => {
return <h5 className="title is-5 mt-3 mb-3">{`Total tasks: ${count}`}</h5>
}

export default Count

Hero/index.tsx

import React from "react"
import logoVite from "@/assets/logo-vite.svg"
import logoElectron from "@/assets/logo-electron.svg"
const Hero: React.FC = () => {
return (
<section className="hero is-small">
<div className="hero-body has-text-centered">
<div className="logo-box">
<img
src={logoVite}
className="logo vite"
alt="Electron + Vite logo"
/>
<img
src={logoElectron}
className="logo electron"
alt="Electron + Vite logo"
/>
</div>
<p className="title mt-5">React+Electron</p>
</div>
</section>
)
}
export default Hero

TodoForm/index.tsx

import React from "react"
interface ITodoForm {
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
handleBlur: (e: React.FocusEvent<HTMLInputElement>) => void
handleSubmit?: any
hasError: boolean
value: string
label?: string
}
const TodoForm: React.FC<ITodoForm> = ({
handleChange,
handleBlur,
hasError = false,
value,
handleSubmit,
label = "Add",
}) => {
return (
<div className="formWrapper">
<div className="columns">
<div className="column is-full">
<div className="field is-grouped">
<p className="control is-expanded">
<input
className={`input ${hasError ? "is-danger" : null}`}
type="text"
value={value}
onChange={(e) => handleChange(e)}
onBlur={handleBlur}
/>
</p>
<p className="control">
<a className="button is-dark" onClick={(e) => handleSubmit(e)}>
{label}
</a>
</p>
</div>
{hasError && (
<p className="help is-danger">Please enter valid todo</p>
)}
</div>
</div>
</div>
)
}

export default TodoForm

TodoItem/index.tsx

import React from "react"
import { IonIcon } from "@ionic/react"
import { closeCircleOutline, createOutline, eyeOutline } from "ionicons/icons"
import { Link } from "react-router-dom"
interface TodoItem {
todo: any
handleDelete?: any
handleComplete?: any
}
const TodoItem: React.FC<TodoItem> = ({
todo,
handleDelete,
handleComplete,
}) => {
return (
<div className="todosWrapper">
<div className="todoItem">
<div className="changeStatus">
<input
type="checkbox"
value={`${todo.status ? "true" : "false"}`}
onChange={() => handleComplete(todo.id)}
/>
</div>
<div className={`taskTitle ${todo?.status ? "completed" : ""}`}>
{todo?.title}
</div>
<div className="actions has-text-black">
<div className="view">
<Link to={`/view/${todo.id}`}>
<IonIcon icon={eyeOutline} />
</Link>
</div>
<div className="edit">
<Link to={`/edit/${todo.id}`}>
<IonIcon icon={createOutline} />
</Link>
</div>
<div className="destroy" onClick={() => handleDelete(todo.id)}>
<IonIcon icon={closeCircleOutline} />
</div>
</div>
</div>
</div>
)
}
export default TodoItem

page/todo/edit.tsx

import TodoForm from "@/components/TodoForm"
import { toast } from "bulma-toast"
import React, { useState, useEffect } from "react"
import { useParams, useNavigate } from "react-router-dom"

interface Todo {
id: number | string
title: string
status: boolean
}

interface Todos {
todos: Todo[]
count: number
}

const Edit: React.FC = () => {
const navigate = useNavigate()
const { id } = useParams<{ id: string }>()
const [inputError, setInputError] = useState<boolean>(false)
const [todo, setTodo] = useState<Todo>({
id: "",
title: "",
status: false,
})
const [todos, setTodos] = useState<Todos>({
todos: [],
count: 0,
})
useEffect(() => {
const storedTodos = JSON.parse(localStorage.getItem("todos") || "{}")
if (storedTodos) {
console.log(storedTodos)
setTodos(storedTodos)
} else {
toast({
message: "Todo not found",
type: "is-danger",
})
}
}, [])

useEffect(() => {
const todoItem = todos.todos.find(
(item) => item.id === parseInt(id ?? "-1")
)
if (todoItem) {
setTodo(todoItem)
} else {
toast({
message: "Todo not found",
type: "is-danger",
})
navigate("/", { replace: true })
}
return () => {
setTodo({
id: "",
title: "",
status: false,
})
}
}, [todos])

const handleChange = (e: any) => {
setTodo({ ...todo, title: e.target.value })
}

const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (e.target?.value === "" || e.target?.value === null) {
setInputError(true)
} else {
setInputError(false)
}
}

const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()

if (todo.title === "" || todo.title === null) {
setInputError(true)
return
}

const updatedTodos = {
...todos,
todos: todos.todos.map((item) =>
item.id === parseInt(id ?? "-1") ? { ...item, title: todo.title } : item
),
}

setTodos(updatedTodos)
localStorage.setItem("todos", JSON.stringify(updatedTodos))
toast({
message: "Todo updated !",
type: "is-success",
})
navigate("/", { replace: true })
}

return (
<div className="container">
<TodoForm
hasError={inputError}
value={todo?.title}
handleChange={handleChange}
handleBlur={handleBlur}
handleSubmit={handleSubmit}
label={"Update"}
/>
</div>
)
}

export default Edit

page/view.tsx

import React, { useEffect, useState } from "react"
import { Link, useParams } from "react-router-dom"

const View: React.FC = () => {
const [todo, setTodo] = useState<{
id: number
title: string
status: boolean
}>()

const { id } = useParams<{ id: string }>()

useEffect(() => {
const todos = JSON.parse(localStorage.getItem("todos") || "[]")
const todoItem = todos.todos.find(
(t: { id: number; title: string; status: boolean }) =>
t.id === parseInt(id ?? "-1")
)

setTodo(todoItem)
}, [id])
return (
<div className="container">
<div className="card">
<div className="card-content">
<p className="subtitle">{todo?.title}</p>
</div>
<footer className="card-footer">
<Link to={"/"} className="card-footer-item">
Back to list
</Link>
</footer>
</div>
</div>
)
}

export default View

Note: In an Electron Vite app with React, we can’t use createBrowserRouter it for routing because Electron typically serves the application as a local file system, using the file:// protocol and createBrowserRouter does not support serving files via file://. Instead, we use createHashRouter from react-router-dom. As shown in the example above, HashRouter uses URL fragments (hash) to manage routing, appending a hash fragment like #/path to represent different routes.

Todo app window with the above code in development mode

Packaging and Distributing the App

By default electron-vite is configured to bundle/build the app for Windows OS and Mac OS (see electron-builder.json5). If you want to develop app for Linux paste the following code snippet in electron-builder.json5 below win key.

  linux: {
target: ["AppImage", "snap", "deb"],
artifactName: "${productName}_${version}.${ext}",
},

And place the following script in the package.json scripts section:

"build:win": "npm run build && electron-builder --win --config",
"build:mac": "npm run build && electron-builder --mac --config",
"build:linux": "npm run build && electron-builder --linux --config"

run yarn rub build:* (* denotes os distribution of your choice)

After running the build command the installation files will be generated in release/release_version a folder. It gets release_version from the version defined in package.json.

fig: Installing app in macOS
fig: Search installed app
fig: Running installed app

Conclusion

In this blog post, we explored the process of creating a React Electron app using Electron-Vite. We learned about the powerful combination of React and Electron and how they work together seamlessly to enable the development of cross-platform desktop applications.

While we focused on front-end development in this project, we acknowledged the potential of Electron’s main process and renderer process for further enhancing our application. We look forward to exploring these processes in a future article and harnessing their capabilities to incorporate additional functionalities and system interactions.

To learn more about React Electron and its capabilities, we recommend checking out the official documentation for React, Electron, and Electron-Vite. These resources provide comprehensive guides, examples, and reference materials to help you explore the intricacies of building cross-platform desktop apps.

Github Repo:
https://github.com/bantawa04/fantastic-spoon/

Reference:

--

--