Preload and IPC

Expose a preload API

Use contextBridge to publish one renderer method for each allowed notes operation.

The preload script translates renderer intentions into fixed IPC channels. Let’s write the whole bridge for Desktop Notes. Replace src/preload.js with this:

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('notesAPI', {
  loadNotes: () => ipcRenderer.invoke('notes:load'),
  saveNotes: notes => ipcRenderer.invoke('notes:save', notes),
  exportNotes: () => ipcRenderer.invoke('notes:export'),
  onNewNote: callback =>
    ipcRenderer.on('notes:new', () => callback())
})

Four methods, four capabilities. Load notes, save notes, export notes, and get told when the user picks “New Note” from the menu.

The page receives window.notesAPI. It does not receive ipcRenderer. The channel names live only in this file.

The event wrapper drops the event object

Look at onNewNote. It does not pass Electron’s listener straight through. It wraps it: () => callback().

ipcRenderer.on calls its listener with an event object as the first argument. That object carries a reference to the sender. We don’t want page code holding privileged Electron objects, so the wrapper calls callback() with no arguments at all.

Don’t expose ipcRenderer

You may be tempted to write exposeInMainWorld('ipc', ipcRenderer) and be done. Two things stop you.

First, current Electron releases don’t let you transfer the complete ipcRenderer object over contextBridge. Second, even a hand-written wrapper that takes a channel name recreates the problem from the previous lesson: the page picks the channel. Stick to one method per intention.

Only plain data crosses

Bridge values are copied or proxied between the two isolated worlds, and IPC values are copied again between processes. Pass plain, serializable data: strings, numbers, booleans, arrays, plain objects.

DOM nodes can’t cross. Electron objects can’t cross. A function nested inside a note object won’t arrive on the other side. For Desktop Notes this is natural: a note is { id, title, body }, three strings.

Names describe capabilities

Each method name says what the interface can do, not how. saveNotes is a capability. invoke('notes:save') is an implementation detail.

This pays off twice. The API is easy to audit: read five lines and you know everything the page can do. And it stays stable: rename the IPC channel tomorrow, and no renderer code changes.

Check the bridge

Run the app and open DevTools. Type window.notesAPI in the console. You should see an object with loadNotes, saveNotes, exportNotes, and onNewNote. Now type require, then window.notesAPI.send. Both undefined. No raw channel selection, no filesystem, no Node.js.

One more thing about onNewNote. Desktop Notes calls it once at startup, so one listener lives for the whole page lifetime and that’s fine. If your page registers listeners repeatedly, say on every route change, add a matching unsubscribe method to the bridge. Otherwise listeners pile up, and one menu click fires your callback ten times.

Lesson completed