Skip to content

signal

A collection of hooks that allow us to use signal features inside React components.

npm

JSR

No need to install the core package @kaiverse/signal separately.

Terminal window
pnpm add @kaiverse/signal-react
import {
useSignal,
useSyncSignal,
useSignalEffect,
useComputed,
useSyncComputed,
useSignalValue,
useSyncSignalValue,
} from '@kaiverse/signal-react'
NameDescription
useSignal/useSyncSignalCreates a Signal that also happens to trigger React re-renders. You get both a Signal (usable in createEffect/createComputed) and React reactivity, same underlying value.
useSignalEffectRuns an imperative effect whenever a Signal read inside it changes — the side-effect counterpart to the hooks below.
useComputed/useSyncComputedSame as useSignal/useSyncSignal, but derived rather than a raw signal.
useSignalValue/useSyncSignalValueReads a Signal inside a React component. Returns a plain reactive value, that acts like React state, not a Signal.

Create a Signal inside React component. It uses useReducer and returns a snapshot of Signal. It works better with concurrent rendering but has temporary tearing issue.

Consider using useSyncSignal which uses useSyncExternalStore that solves tearing issues, but doesn’t work well with concurrent rendering. It’s a trade-off, choose wisely.

Read more about the tearing issue.

Create a Signal inside React component. useSyncSignal is integrated with useSyncExternalStore (uSES) which is a recommended way to use “external stores” in React.

useSyncSignal works well in most cases. However, uSES doesn’t work with concurrent rendering. useSyncSignal’s setter wrapped with startTransition won’t behave as expected. Suspend a render based on a store value returned by uSES will trigger the nearest Suspense fallback instead of showing the old UI. Read more: useSyncExternalStore

useSignal, on the other hand, doesn’t use uSES. It returns a snapshot of Signal and uses useReducer to perform a re-render on Signal changes. As a result, It works better with concurrent rendering but suffers from temporary tearing issue.

It’s a trade-off after all. Choose the one that fits your use case.

Signal effect inside React component. It accepts an imperative function that will run whenever dependencies change. Dependencies are Signals that are used inside the Effect itself.

useSignalEffect can track either global Signal(s) or local (both inside or outside component) Signal(s) or all of them together.

use-signal-effect-example.tsx
import {createSignal, useSignalEffect} from '@kaiverse/signal-react'
const [count, setCount] = createSignal(0)
export function UseSignalEffectExample() {
useSignalEffect(() => {
console.log('count =', count()) // this will run whenever the increment button is clicked
})
return (
<button type="button" onClick={() => setCount(count() + 1)}>
Increment
</button>
)
}

Derived signals inside React component.

Usually used to create a computed Signal from multiple useSignal, Signal(s) or to bind Signal(s), that created outside components, to React reactive system.

import {useSignal, useComputed} from '@kaiverse/signal-react'
import {globalCountSignal} from './global-store'
const [globalCount, setGlobalCount] = globalCountSignal
export function UseComputedExample() {
const [count, setCount] = useSignal(0)
const sum = useComputed(() => count() + globalCount())
return (
<>
<button type="button" onClick={() => setCount((c) => c + 1)}>
count++
</button>
<button type="button" onClick={() => setGlobalCount(globalCount() + 2)}>
Increase global count
</button>
Result: {count()} + {globalCount()} = {sum()}
</>
)
}

The returned value is always the stable Signal getter. options.equals customizes the change comparison, same as createSignal.

Derived signals inside React component. useSyncComputed is integrated with useSyncExternalStore (uSES), the recommended way to use “external stores” in React, and solves the tearing issue.

The value returned is always the stable Signal getter. It shares useSyncSignal’s concurrent rendering caveats, since both are uSES-backed.

useComputed, on the other hand, uses useReducer and returns a snapshot of Signal. It works better with concurrent rendering but has the same temporary tearing issue as useSignal.

Reads the current value of an already-existing Signal inside a React component.

Use this to consume a Signal created outside the component — a module-level store, or a Signal returned by another hook — as opposed to useSignal/useSyncSignal, which both create and own a Signal local to the component.

useSignalValue uses useReducer, same trade-off as useSignal: works better with concurrent rendering, has the temporary tearing issue. See useSyncSignalValue for the uSES-backed twin.

import {useSignalValue} from '@kaiverse/signal-react'
import {globalCountSignal} from './global-store'
const [globalCount] = globalCountSignal
export function UseSignalValueExample() {
const count = useSignalValue(globalCount)
return <p>Global count: {count}</p>
}

Same as useSignalValue, but integrated with useSyncExternalStore instead of useReducer — same trade-off as useSyncSignal: solves the tearing issue, doesn’t work well with concurrent rendering.

use-sync-signal-value-example.tsx
import {useSyncSignalValue} from '@kaiverse/signal-react'
import {globalCountSignal} from './global-store'
const [globalCount] = globalCountSignal
export function UseSyncSignalValueExample() {
const count = useSyncSignalValue(globalCount)
return <p>Global count: {count}</p>
}

When a bundler hot-swaps a module that creates a Signal at module scope, the module re-evaluates and a new Signal instance is created: hooks created from it on first render stay subscribed to the dead instance, so the UI freezes and effects stop firing — while module-level effects are recreated on every save, duplicating on each edit.

Until a dedicated bundler plugin automates this, persist the Signal instance and dispose module-level effects yourself:

import {createSignal, type SignalFactoryReturnType} from '@kaiverse/signal-react'
// Reuse the same Signal instance across HMR updates so one-time hook state
// (useComputed/useSignalValue/useSignalEffect) doesn't end up subscribed to a dead instance.
export const playgroundSignal =
(import.meta.hot?.data.playgroundSignal as SignalFactoryReturnType<number>) ?? createSignal(0)
if (import.meta.hot) {
import.meta.hot.data.playgroundSignal = playgroundSignal
import.meta.hot.accept()
}

A dedicated bundler plugin (@kaiverse/signal-plugins) that automates both is planned.