Introduction:
In the realm of application development, there often arises a need for solutions that can operate independently of online services while efficiently managing and storing data. This necessity becomes particularly pronounced in scenarios where vast amounts of data are collected in remote locations devoid of internet connectivity.
Recently, a requirement emerged while working on a project where extensive field research data needed to be stored without reliance on online resources. To address this challenge, a Windows application was developed using the C# Windows Forms framework and SQLite. This combination facilitated seamless data storage and retrieval, even in offline environments.
As the project progressed, the focus shifted towards refining skills and exploring new technologies. Consequently, there was an initiative to reimagine the application using a modern tech stack, which will be discussed further in the following sections.
Let’s get to what lights up our eyes
First of all, we must use create-react-app to bootstrap our application and then install some dependencies related to the Electron.
create-react-app app
yarn add electron electron-builder wait-on concurrently --dev
yarn add electron-is-dev
Add this electron.js file to the “public” folder.
const electron = require('electron');
const { app } = electron;
const { BrowserWindow } = electron;
const path = require('path');
const isDev = require('electron-is-dev');
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({ width: 900, height: 680 });
mainWindow.loadURL(
isDev
? 'http://localhost:3000'
: `file://${path.join(__dirname, '../build/index.html')}`
);
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.on('ready', createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (mainWindow === null) {
createWindow();
}
});
Add the “main” property to package.json and point to our electron file:
“main”: “public/electron.js”
Add this script to run the dev version:
"electron-dev": "concurrently \"BROWSER=none yarn start\" \"wait-on http://localhost:3000 && electron .\""
Run the script:
yarn electron-dev
Now you have a sample electron app running. It’s basically what a guy named Kitze wrote here.
What now? How to connect to an SQLite data source?
At this point, We’re about to use something named Electron ipcMain and ipcRenderer. They are both a Node.js Event Emitter. Essentially we use them to communicate between the main process to the render process and vice-versa.
Having these tools on our hands, let’s “ping pong” it.
Write a function to allow the renderer to say ping to the main process. It will send a message named asynchronous-message to the main process and will be waiting once for a response named asynchronous-reply.
const electron = window.require('electron');
const { ipcRenderer } = electron;
export default function send(message) {
return new Promise((resolve) => {
ipcRenderer.once('asynchronous-reply', (_, arg) => {
resolve(arg);
});
ipcRenderer.send('asynchronous-message', message);
});
}
As our renderer.js will be imported by the Electron renderer process, we must explicit we want Node integration at our created BrowserWindow. So, get back to electron.js and put this there.
new BrowserWindow({
...
webPreferences: {
nodeIntegration: true,
}
});
That’s how the electron dependencies are injected into the browser window. Afterward, we import the dependency from the window variable as you could see on the renderer.js file.
const electron = window.require('electron');
Now, write a function to allow the main process to receive the renderer message (asynchronous-message) and respond it back (asynchronous-reply).
const { ipcMain } = require('electron');
ipcMain.on('asynchronous-message', (event, arg) => {
console.log(arg); // prints "ping"
if (arg === 'ping') event.reply('asynchronous-reply', 'pong!');
else event.reply('asynchronous-reply', 'please, send me ping.');
});
Import the main.js on our electron.js file. Remember that file, right?
// preferable require it right after the main imports.
require('../src/message-control/main');
On our web application (the renderer), create basic logic just to see that behavior happening.
import React, { useState } from 'react';
import sendAsync from './message-control/renderer';
import './App.css';
function App() {
const [message, setMessage] = useState('');
const [responses, setResponses] = useState([]);
function send(data) {
sendAsync(data).then((result) => setResponses([...responses, result]));
}
return (
<div className="App">
<header className="App-header">
<h1>
Standalone application with Electron, React, and
SQLite stack.
</h1>
</header>
<article>
<p>
Say <i>ping</i> to the main process.
</p>
<input
type="text"
value={message}
onChange={({ target: { value } }) => setMessage(value)}
/>
<button type="button" onClick={() => send(message)}>
Send
</button>
<br />
<p>Main process responses:</p>
<br />
<pre>
{(responses && responses.join('\n')) ||
'the main process seems quiet!'}
</pre>
</article>
</div>
);
}
export default App;
Awesome, we already have our web application communicating to the main process, which is so powerful in terms of access.
You may say: “Ok, we’ve already played a lot with it but you didn’t show me anything about SQLite yet.”
Yeah, I did this on purpose to make sure you understood the concept of the communication between both processes (main and renderer).
So, talking about SQLite… add it.
$ yarn add nedb-promises
Tip: Add an SQLite database file to the public folder. I suggest you use the DB Browser for SQLite tool.
Change the main.js to receive a SQL statement, run it, and send the result back to the renderer via event.
const { ipcMain } = require('electron');
const sqlite3 = require('sqlite3');
const database = new sqlite3.Database('./public/db.sqlite3', (err) => {
if (err) console.error('Database opening error: ', err);
});
ipcMain.on('asynchronous-message', (event, arg) => {
const sql = arg;
database.all(sql, (err, rows) => {
event.reply('asynchronous-reply', (err && err.message) || rows);
});
});
Change the App.js to treat the result as an array, as below:
import React, { useState } from 'react';
import sendAsync from './message-control/renderer';
import './App.css';
function App() {
const [message, setMessage] = useState('SELECT * FROM repositories');
const [response, setResponse] = useState();
function send(sql) {
sendAsync(sql).then((result) => setResponse(result));
}
return (
<div className="App">
<header className="App-header">
<h1>
Standalone application with Electron, React, and
SQLite stack.
</h1>
</header>
<article>
<p>
Say <i>ping</i> to the main process.
</p>
<input
type="text"
value={message}
onChange={({ target: { value } }) => setMessage(value)}
/>
<button type="button" onClick={() => send(message)}>
Send
</button>
<br />
<p>Main process responses:</p>
<br />
<pre>
{(response && JSON.stringify(response, null, 2)) ||
'No query results yet!'}
</pre>
</article>
</div>
);
}
export default App;
Now, you must see the query results on the app screen. Simple as it is.
Did you get a kind of ‘module not found’ error about SQLite?
That’s because sqlite3 isn’t a native module, as in Node.js only 400 modules are native, against the 650,000 modules available.
https://www.electronjs.org/docs/tutorial/using-native-node-modules
But, if you wanna solve that, repeat the following steps:
yarn add --dev electron-rebuild
Add to your package.json:
“rebuild-sqlite3”: “electron-rebuild -f -w sqlite3”
Run:
yarn rebuild-sqlite3
It could be a bit tricky. If you are on a MacOSX, you might need to have XCode and its tools installed. Also, on Windows, you may need to have some of the .NET Framework properly installed to build that native module. I don’t want to talk about that part of the story to avoid distraction from the main story’s idea.
Conclusion
With that stack, every React web developer can build desktop solutions collecting and storing data into an improved data source once compared to the simple and limited local storage.