Skip to content

Electron - File Handling

Interacting with the file system is a common requirement for desktop applications. Electron allows this using Node.js’s built-in fs module. In modern Electron apps, for security reasons (contextIsolation: true, nodeIntegration: false), direct file system access from the renderer process is discouraged. Instead, file operations should be handled in the main process, with the renderer process requesting these operations via Inter-Process Communication (IPC) facilitated by a preload script.

We’ll create an app to manage a list of contacts (name and email), saving them to a JSON file and displaying them. This example will use Bootstrap for styling (install via npm install bootstrap).

First, set up main.js. This will handle IPC calls for file operations:

const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs').promises; // Using promise-based fs for async operations
const contactsFilePath = path.join(app.getPath('userData'), 'contacts.json');
let mainWindow;
async function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 700,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
mainWindow.loadFile('index.html');
}
// IPC Handlers for file operations
ipcFutures = {
async getContacts() {
try {
await fs.access(contactsFilePath); // Check if file exists
const data = await fs.readFile(contactsFilePath, 'utf8');
return JSON.parse(data);
} catch (error) {
// If file doesn't exist or other error, return empty array
if (error.code === 'ENOENT') return [];
console.error('Failed to read contacts:', error);
throw error; // Re-throw other errors
}
},
async saveContacts(contacts) {
try {
await fs.writeFile(contactsFilePath, JSON.stringify(contacts, null, 2), 'utf8');
return { success: true };
} catch (error) {
console.error('Failed to save contacts:', error);
return { success: false, error: error.message };
}
},
async showSaveDialog(defaultPath, content) {
const { filePath, canceled } = await dialog.showSaveDialog(mainWindow, {
defaultPath: defaultPath,
filters: [{ name: 'Text Files', extensions: ['txt'] }, { name: 'All Files', extensions: ['*'] }]
});
if (canceled || !filePath) return { canceled: true };
try {
await fs.writeFile(filePath, content, 'utf8');
return { success: true, filePath: filePath };
} catch (error) {
console.error('Failed to save file via dialog:', error);
return { success: false, error: error.message };
}
}
};
app.whenReady().then(() => {
ipcMain.handle('get-contacts', ipcFutures.getContacts);
ipcMain.handle('save-contacts', (event, contacts) => ipcFutures.saveContacts(contacts));
ipcMain.handle('show-save-dialog', (event, defaultPath, content) => ipcFutures.showSaveDialog(defaultPath, content));
createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

Next, create preload.js to expose these IPC channels securely:

const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronFileAPI', {
getContacts: () => ipcRenderer.invoke('get-contacts'),
saveContacts: (contacts) => ipcRenderer.invoke('save-contacts', contacts),
showSaveDialog: (defaultPath, content) => ipcRenderer.invoke('show-save-dialog', defaultPath, content)
});

Now, index.html for the UI:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Contact Manager</title>
<link rel="stylesheet" href="../node_modules/bootstrap/dist/css/bootstrap.min.css">
<style>
body { padding: 20px; }
#contact-list-container { max-height: 300px; overflow-y: auto; margin-top: 15px; border: 1px solid #ccc; }
</style>
</head>
<body>
<div class="container">
<h2>Contact List</h2>
<form id="contact-form">
<div class="mb-3">
<label for="Name" class="form-label">Name</label>
<input type="text" class="form-control" id="Name" required>
</div>
<div class="mb-3">
<label for="Email" class="form-label">Email</label>
<input type="email" class="form-control" id="Email" required>
</div>
<button type="submit" class="btn btn-primary">Add Contact</button>
<button type="button" class="btn btn-info" id="export-contacts">Export Contacts (TXT)</button>
</form>
<div id="contact-list-container" class="mt-4">
<table class="table table-striped table-hover">
<thead>
<tr><th>#</th><th>Name</th><th>Email</th><th>Actions</th></tr>
</thead>
<tbody id="contact-table-body"></tbody>
</table>
</div>
</div>
<script src="./renderer.js"></script>
</body>
</html>

Finally, renderer.js to handle UI logic and interact with the preload API:

document.addEventListener('DOMContentLoaded', () => {
const contactForm = document.getElementById('contact-form');
const nameInput = document.getElementById('Name');
const emailInput = document.getElementById('Email');
const contactTableBody = document.getElementById('contact-table-body');
const exportButton = document.getElementById('export-contacts');
let contacts = [];
async function loadAndDisplayContacts() {
try {
contacts = await window.electronFileAPI.getContacts();
renderContacts();
} catch (error) {
console.error('Error loading contacts:', error);
alert('Could not load contacts. Check console.');
}
}
function renderContacts() {
contactTableBody.innerHTML = ''; // Clear existing rows
contacts.forEach((contact, index) => {
const row = contactTableBody.insertRow();
row.insertCell().textContent = index + 1;
row.insertCell().textContent = contact.name;
row.insertCell().textContent = contact.email;
const actionsCell = row.insertCell();
const deleteButton = document.createElement('button');
deleteButton.textContent = 'Delete';
deleteButton.className = 'btn btn-danger btn-sm';
deleteButton.onclick = () => deleteContact(index);
actionsCell.appendChild(deleteButton);
});
}
async function addContact(name, email) {
contacts.push({ name, email });
await saveContactsToFile();
renderContacts();
}
async function deleteContact(index) {
contacts.splice(index, 1);
await saveContactsToFile();
renderContacts();
}
async function saveContactsToFile() {
try {
const result = await window.electronFileAPI.saveContacts(contacts);
if (!result.success) {
console.error('Error saving contacts:', result.error);
alert('Failed to save contacts: ' + result.error);
}
} catch (error) {
console.error('IPC error saving contacts:', error);
alert('IPC error saving contacts. Check console.');
}
}
contactForm.addEventListener('submit', async (event) => {
event.preventDefault();
const name = nameInput.value.trim();
const email = emailInput.value.trim();
if (name && email) {
await addContact(name, email);
contactForm.reset();
nameInput.focus();
} else {
alert('Name and Email cannot be empty.');
}
});
exportButton.addEventListener('click', async () => {
if (contacts.length === 0) {
alert('No contacts to export.');
return;
}
let content = 'Contacts:\n\n';
contacts.forEach(c => {
content += `Name: ${c.name}, Email: ${c.email}\n`;
});
try {
const result = await window.electronFileAPI.showSaveDialog('contacts-export.txt', content);
if (result.success) {
alert('Contacts exported successfully to: ' + result.filePath);
} else if (!result.canceled) {
alert('Failed to export contacts: ' + (result.error || 'Unknown error'));
}
} catch (error) {
console.error('Error exporting contacts:', error);
alert('Error exporting contacts. See console for details.');
}
});
loadAndDisplayContacts(); // Initial load
});

Run the app with npx electron . (or npm start if configured in package.json).

The application will show a form to enter names and email addresses. Added contacts appear in a table below. These contacts are saved to contacts.json in your application’s user data directory (e.g., on Windows: C:\Users\[YourUser]\AppData\Roaming\[YourAppName]\contacts.json). The data persists across app restarts. There’s also an ‘Export Contacts (TXT)’ button that uses a native save dialog.

This example demonstrates a secure and modern approach to file handling in Electron, using asynchronous operations, IPC, and native dialogs. For more complex scenarios, consider using a lightweight database like SQLite (via sqlite3 npm package) for more robust data storage, still managed through IPC calls to the main process.

Refer to the official Electron documentation for more on ipcMain, ipcRenderer, and the dialog module.