Getting started
electron-use-ipc replaces string channel names and hand-written IPC types with a single contract that all three processes infer from.
pnpm add @cc-heart/electron-use-ipc
The four files
Every integration has the same shape. A shared contract, then one file per process.
1. The contract — shared
This is the only place your IPC surface is described. Note that shared/api.ts imports nothing from Electron, so it is safe to use from the renderer.
import { defineApi, defineEvent, defineInvoke } from '@cc-heart/electron-use-ipc'
export interface User {
id: string
name: string
}
/**
* Single source of truth: define the contract once.
* Every arg / return / payload type flows from here to main, preload, renderer.
*/
export const api = defineApi({
invoke: {
getUser: defineInvoke<[string], User>(),
listUsers: defineInvoke<[], User[]>(),
createUser: defineInvoke<[string], User>(),
},
events: {
'user:updated': defineEvent<User>(),
'server:shutdown': defineEvent<void>(),
},
})
2. The main process
Handlers are typed by the token they are registered against — arguments and return values are not annotated here.
import { registerMain } from '@cc-heart/electron-use-ipc/main'
import { api, type User } from './shared/api'
const users = new Map<string, User>()
// `main` is captured by closures below; declare first so handlers can emit.
const main = registerMain(api, {
invoke: {
// (id: string) => Promise<User> — inferred from the token
getUser: async (id) => {
const u = users.get(id)
if (!u) throw new Error(`no user ${id}`)
return u
},
// () => Promise<User[]>
listUsers: async () => [...users.values()],
// (name: string) => Promise<User>
createUser: async (name) => {
const u: User = { id: crypto.randomUUID(), name }
users.set(u.id, u)
// typed event broadcast: payload must be User
main.emit(api.events['user:updated'], u)
return u
},
},
})
// void event: no payload allowed
main.emit(api.events['server:shutdown'])
main.dispose()
3. Preload
exposeBridge publishes the bridge on window.api through contextBridge. It forwards tokens without needing to know what they mean.
import { exposeBridge } from '@cc-heart/electron-use-ipc/preload'
import { api } from './shared/api'
// exposes window.api with the typed bridge
exposeBridge(api)
4. Renderer
useInvoke and useEvent return signals: data, error and loading for a call, and the latest payload for an event.
import { useEvent, useInvoke } from '@cc-heart/electron-use-ipc/renderer'
import { api } from './shared/api'
/* ---- request / response hook ---- */
const { data, loading, call } = useInvoke(api.invoke.getUser)
// call: (id: string) => Promise<User> ✅ inferred
// data: Signal<User | undefined> ✅ inferred
call('1').then(() => {
const u = data.get() // User | undefined
console.log(u?.name)
})
data.subscribe((u) => {
// u: User | undefined
})
/* ---- immediate call (args still typed) ---- */
const list = useInvoke(api.invoke.listUsers, { immediate: [] })
// list.data: Signal<User[] | undefined>
/* ---- event hook ---- */
const updated = useEvent(api.events['user:updated'])
// updated: Signal<User | undefined> ✅ inferred
updated.subscribe((u) => {
if (u) console.log('updated:', u.name)
})
/* ---- compile-time type checks (uncomment to see errors) ---- */
// call(123) // ❌ Argument of type 'number'...
// main.emit(api.events['user:updated']) // ❌ void-typed event still works, but
// // a non-void event without payload errors. (void events allow no-arg emit)
// updated.subscribe((u: number) => {}) // ❌ u is User | undefined
Requirements
| Electron | >= 20 |
| TypeScript | >= 5.0 |
| Module format | ESM only |
The package ships ESM with no require condition, so it must be consumed from an ESM context. In an Electron app that means "type": "module" in your package.json, or a bundler that emits ESM for the main process.
contextIsolation must be on (the default). The bridge is published with contextBridge.exposeInMainWorld, so nodeIntegration is never required.
Next
Work through the architecture in order — the contract, then each process that infers from it: