Renderer hooks

Two hooks cover most needs: useInvoke for request/response, useEvent for pushed events. Both return signals rather than framework-specific state.

useInvoke

import { useInvoke } from '@cc-heart/electron-use-ipc/renderer'
import { api } from '../shared/api'

const { data, error, loading, call } = useInvoke(api.invoke.getUser)

await call('1')      // (id: string) => Promise<User>
data.get()           // User | undefined
FieldTypeNotes
dataSignal<R | undefined>Last resolved value
errorSignal<unknown | undefined>Last thrown value
loadingSignal<boolean>True while the call is in flight
call(...args: A) => Promise<R>Runs the method; args inferred from the token

call rethrows on failure. That means you can await it for control flow and observe the same failure through the error signal.

Immediate calls

Pass immediate to run the method as soon as the hook is created. The arguments are still type-checked against the token:

const { data } = useInvoke(api.invoke.listUsers, { immediate: [] })
//    data: Signal<User[] | undefined>

useEvent

import { useEvent } from '@cc-heart/electron-use-ipc/renderer'

const updated = useEvent(api.events['user:updated'])
//    Signal<User | undefined>

updated.get()                    // latest payload, or undefined before the first
updated.subscribe((u) => { ... }) // fires on every push

invoke

When you do not need reactive state, call a method directly:

import { invoke } from '@cc-heart/electron-use-ipc/renderer'

const user = await invoke(api.invoke.getUser, '1')

Signals

Signal<T> is the primitive behind both hooks — a value, a setter, and a subscriber set. It is around thirty lines, with no dependency and no framework coupling:

src/core/signal.ts
/** A tiny framework-agnostic reactive primitive (signal). */

export type Listener<T> = (value: T) => void

export interface Signal<T> {
  /** Read the current value. */
  get(): T
  /** Set a new value (or update via function). Notifies subscribers. */
  set(value: T | ((prev: T) => T)): void
  /** Subscribe to changes. Returns an unsubscribe function. */
  subscribe(listener: Listener<T>): () => void
}

export function signal<T>(initial: T): Signal<T> {
  let value = initial
  const listeners = new Set<Listener<T>>()

  return {
    get: () => value,
    set(next) {
      const resolved =
        typeof next === 'function' ? (next as (p: T) => T)(value) : next
      if (Object.is(resolved, value)) return
      value = resolved
      for (const l of listeners) l(value)
    },
    subscribe(listener) {
      listeners.add(listener)
      return () => {
        listeners.delete(listener)
      }
    },
  }
}

get() reads the current value, set() writes it and notifies subscribers (skipping the notification when the value is Object.is-equal), and subscribe() returns an unsubscribe function.

Framework adapters

Because the hooks return signals, binding them to a component tree is a few lines. The two adapters below are complete — neither the core nor the renderer entry imports a UI framework.

site/snippets/adapter-react.tsReact
/**
 * Adapter for React — the library returns framework-agnostic `Signal`s, so
 * binding them to a component tree is a handful of lines.
 *
 * Illustrative content for the landing page; not compiled.
 */
import { useSyncExternalStore } from 'react'
import type { Signal } from '@cc-heart/electron-use-ipc/core'
import { useInvoke, useEvent } from '@cc-heart/electron-use-ipc/renderer'
import { api } from './shared/api'

/** Subscribe a component to a signal. `subscribe` returns an unsubscribe fn. */
export function useSignalValue<T>(signal: Signal<T>): T {
  return useSyncExternalStore(
    (cb) => signal.subscribe(cb),
    () => signal.get(),
  )
}

export function UserCard({ id }: { id: string }) {
  const { data, loading, call } = useInvoke(api.invoke.getUser)
  const user = useSignalValue(data)

  // `call` is (id: string) => Promise<User> — inferred from the token.
  React.useEffect(() => {
    void call(id)
  }, [id])

  if (loading.get()) return <p>Loading…</p>
  return <p>{user?.name}</p>
}
site/snippets/adapter-vue.tsVue
/**
 * Adapter for Vue — same signals, bound with `shallowRef`.
 *
 * Illustrative content for the landing page; not compiled.
 */
import { onScopeDispose, shallowRef, watchEffect } from 'vue'
import type { Signal } from '@cc-heart/electron-use-ipc/core'
import { useEvent, useInvoke } from '@cc-heart/electron-use-ipc/renderer'
import { api } from './shared/api'

/** Turn a signal into a Vue ref that stays in sync. */
export function useSignalRef<T>(signal: Signal<T>) {
  const value = shallowRef(signal.get())
  const unsubscribe = signal.subscribe((next) => {
    value.value = next
  })
  onScopeDispose(unsubscribe)
  return value
}

export function useUser(id: string) {
  const { data, loading, call } = useInvoke(api.invoke.getUser)
  // `call` is (id: string) => Promise<User> — inferred from the token.
  watchEffect(() => void call(id))

  const user = useSignalRef(data)
  const updated = useEvent(api.events['user:updated'])

  return { user, updated, loading: useSignalRef(loading) }
}

The important detail in both is calling the unsubscribe function on teardown. subscribe returns one; useSyncExternalStore and Vue’s onScopeDispose are the hooks that wire it up.

Next

API reference — every export, plus the channel and error semantics.