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.

examples/shared/api.ts
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.

examples/main.ts
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.

examples/preload.ts
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.

examples/renderer.ts
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 formatESM 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:

  1. The shared contract
  2. Main process
  3. Preload bridge
  4. Renderer hooks