Electron¶
Desktop React Validation
A working Electron desktop app whose renderer is a ReScript + React UI bundled by Vite+. The shipped sample is not a “Hello World” — it shows the full request/response loop between the renderer and the main process, including the safety net you actually need in production: every IPC payload that re-enters the renderer is parsed through Validation.res (zod or sury) so a malformed reply from main.cjs becomes a UI error, not a TypeError deep inside React.
Pick this template if you want to ship a desktop app and start from a stack that already wires contextIsolation: true, nodeIntegration: false, and contextBridge.exposeInMainWorld. The renderer is the same Vite-driven React surface as the Vite + React template, so React knowledge transfers directly; the new ground is Electron’s two-process model and the IPC contract sitting between them.
What You Get¶
my-project/
├── rescript.json # JSX enabled, depends on @rescript/react + validation lib
├── package.json # type: "module", electron + vite-plus dev deps
├── main.cjs # main process — BrowserWindow + ipcMain.handle("app:getInfo")
├── preload.cjs # contextBridge.exposeInMainWorld("electronAPI", { ... })
├── index.html # renderer entry — loads /src/Main.res.mjs as a module
├── vite.config.mjs # vite-plus + @vitejs/plugin-react, base: "./"
├── src/
│ ├── Main.res # ReactDOM.Client.createRoot(rootEl) → <App />
│ ├── App.res # Button → invoke IPC → validate → render result/error
│ ├── Electron.res # @val external electronAPI bindings over window.electronAPI
│ ├── Validation.res # zod or sury — parses the IPC response into typed `info`
│ └── __tests__/App.test.mjs # vitest smoke test that imports App.res.mjs
├── README.md # IPC security model + "About Vite+" section
├── LICENSE # MIT, holder = project name
├── .nvmrc # Node 24
├── .gitignore # node_modules + ReScript output + dist/, out/, .vite/
├── .editorconfig # 2-space indent, LF line endings
└── .github/
├── dependabot.yml # weekly npm updates
└── workflows/ci.yml # install + rescript build + vitest
Wizard Options¶
Option |
Effect |
|---|---|
Project name |
Becomes the npm |
Package manager |
npm / pnpm / yarn / bun. Sets the |
Validation library |
|
Key Dependencies¶
Package |
Purpose |
Version |
|---|---|---|
|
ReScript compiler |
|
|
Standard library |
|
|
Runtime stubs imported by compiled |
|
|
React bindings (JSX-enabled |
|
|
Renderer UI library |
|
|
Validation backend (IPC payload parser) |
|
|
Desktop runtime + bundler-side types |
|
|
Vite-based bundler used to produce |
|
|
Vite+ core runtime (peer of |
|
|
Direct |
|
|
React refresh + JSX transform plugin |
|
|
Smoke test runner |
|
|
Coverage provider for |
|
electron-builder is not included by default — see the Notes section for why and when to add it.
Key Files¶
main.cjs¶
The Electron main process. CommonJS (.cjs) because Electron’s app/BrowserWindow/ipcMain modules are CJS-only at the time the template was authored; using .cjs keeps the file working regardless of the "type": "module" in package.json.
It does three things:
Creates a single
BrowserWindowwith hardened defaults —contextIsolation: true,nodeIntegration: false, and apreload: path.join(__dirname, "preload.cjs").Loads
dist/index.html(the production bundle Vite+ produced).Registers an
ipcMain.handle("app:getInfo", ...)handler that returns app + system info.
The app:getInfo channel demonstrates the recommended domain:action naming convention (see the README’s “Renderer ↔ Main IPC” section). Avoid bare verbs like getInfo — they collide as the surface grows.
preload.cjs¶
The single bridge across the contextIsolation boundary. Uses contextBridge.exposeInMainWorld to publish a small electronAPI object on window:
contextBridge.exposeInMainWorld("electronAPI", {
getInfo: () => ipcRenderer.invoke("app:getInfo"),
});
Anything not in this object is invisible to the renderer. Adding a new channel always means editing this file plus main.cjs plus src/Electron.res — the README ships a step-by-step walkthrough of all three layers.
src/Electron.res¶
Thin ReScript binding over the exposed object. The shipped binding is intentionally typed as JSON.t — not the eventual info record — because validation lives one layer further in:
@val external electronAPI: {"getInfo": unit => promise<JSON.t>} = "electronAPI"
let getInfoRaw = (): promise<JSON.t> => electronAPI["getInfo"]()
The Raw suffix is a deliberate convention: it signals “this value has not been validated yet” so callers know they need to run it through Validation.res before destructuring.
src/Validation.res¶
parseInfo: JSON.t => result<info, string>. The signature is identical between the zod and sury variants, so the renderer’s call site does not branch on which library shipped:
type info = {name: string, electronVersion: string, platform: string, arch: string}
let parseInfo: JSON.t => result<info, string>
Why validate at all? The renderer sits behind contextIsolation, but the main process can still ship malformed payloads during a bad refactor. parseInfo turns those into a human-readable error before the UI assumes the shape.
src/App.res¶
The interactive demo. A button click runs:
let raw = await Electron.getInfoRaw()
switch Validation.parseInfo(raw) {
| Ok(result) => setInfo(_ => Some(result))
| Error(message) => setError(_ => Some(message))
}
The error branch renders a red IPC validation error: ... banner. Mutate main.cjs to return an unexpected payload and you can watch the validator catch it without the React tree blowing up.
src/Main.res¶
The renderer entry the index.html <script type="module"> loads. Mounts <App /> onto #root:
switch ReactDOM.querySelector("#root") {
| Some(rootEl) =>
ReactDOM.Client.Root.render(ReactDOM.Client.createRoot(rootEl), <App />)
| None => Console.error("Could not find root element")
}
vite.config.mjs¶
Minimal Vite+ config — defineConfig from vite-plus (not vite), the React plugin, and base: "./". The relative base matters: Electron loads dist/index.html via file://, so absolute asset URLs would 404.
index.html¶
The renderer entry HTML. Hosts the React root and pulls in /src/Main.res.mjs as an ES module — the file the ReScript compiler emits next to Main.res.
npm Scripts¶
Script |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Adding a New IPC Channel¶
Adding a channel always touches three layers — the README ships a step-by-step walkthrough and the shape is worth memorizing because forgetting any layer is silent (undefined is not a function in the renderer).
Define the handler in
main.cjs:ipcMain.handle("window:setTitle", (_event, title) => { BrowserWindow.getFocusedWindow()?.setTitle(String(title)); });
Expose it from
preload.cjs:contextBridge.exposeInMainWorld("electronAPI", { getInfo: () => ipcRenderer.invoke("app:getInfo"), setWindowTitle: (title) => ipcRenderer.invoke("window:setTitle", title), });
Bind it in
src/Electron.res(extend the externals object so ReScript sees the new method):@val external electronAPI: { "getInfo": unit => promise<JSON.t>, "setWindowTitle": string => promise<unit>, } = "electronAPI" let setWindowTitle = (title: string) => electronAPI["setWindowTitle"](title)
Validate the response in
Validation.resif the handler returns data the UI consumes — a straynullor shape change in main is far easier to debug as a parse error than as aTypeErrordeep inside React. For fire-and-forget channels (setWindowTitlereturnsunit) you can skip this step.
Security Model¶
The bundled BrowserWindow is configured with the modern hardened defaults:
Setting |
Value |
Why |
|---|---|---|
|
|
Keeps preload globals out of the page’s |
|
|
Renderer cannot |
Preload bridge |
|
Only the surface you opt-in to crosses the boundary |
The renderer never imports Electron directly. Every cross-process call goes through window.electronAPI.* → ipcRenderer.invoke(...) → ipcMain.handle(...). Validate every payload that re-enters the renderer with Validation.res so a buggy main-process change cannot crash the UI silently.
Day-Two Recipes¶
Create a React Component — same React conventions apply to the renderer
Optimize Imports — keep
src/Electron.resandsrc/Validation.restidy as the IPC surface growsFind Dead Code — useful for pruning IPC channels that no renderer still calls
For ReScript-side editor workflows once the project is open, see Feature Overview.
Notes¶
Electron is not hot-reloaded.
vp devupdates the renderer in your browser, but the running Electron window does not auto-refresh onmain.cjs/preload.cjschanges. Stop and re-runnpm startafter touching the main-process files.electron-builderis intentionally absent. Packaging is opinionated (signing identities, icons, target platforms, auto-updater) and varies wildly per project, so the template stops at “runs locally”. Addelectron-builder(or Forge / Tauri-style alternatives) when you decide on a release strategy.The renderer never imports Electron directly. Every cross-process call goes through
window.electronAPI.*→ipcRenderer.invoke(...)→ipcMain.handle(...). If you find yourself wantingrequire("electron")in the renderer, you almost certainly want a new IPC channel instead.ipcRenderer.sendis fire-and-forget. The shipped pattern usesinvoke/handleexclusively — request/response with a typed return value the validator can inspect. Stick to it unless you have a streaming use case.Functions, class instances, and DOM nodes cannot cross the bridge. Stringify or convert to plain objects before returning from
ipcMain.handle.Channel names use
domain:action. The shippedapp:getInfois the canonical example; theapp:*prefix is reserved for app-lifecycle channels — pick a different domain (fs:*,db:*,window:*) for feature work.The smoke test is intentionally tiny.
App.test.mjsonly verifies thatimport("../App.res.mjs")resolves; it does not boot Electron or exercise IPC. Add domain tests as you grow the renderer.vite-plusis pre-1.0. The version pin is^0.1.x; expect minor breaking changes between releases until 1.0 lands. Vite+ bundles its ownviteinternally — the directvitedependency is only there so@vitejs/plugin-reactresolves a peer.