Skip to content

React

Signals are framework-agnostic β€” they work the same whether you’re in a custom element, a vanilla script, or a React tree. These two hooks connect them to React’s rendering model via useSyncExternalStore, so React only re-renders components that actually depend on a changed signal.

// integrations/react is included with elements-kit β€” no extra install needed
import { useSignal, useScope } from "elements-kit/integrations/react";

useSignal

Subscribe a React component to any signal or computed value. Returns the current value and re-renders the component whenever it changes.

import {
function signal<T>(): Updater<T> & Computed<T> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
,
function computed<T>(getter: (previousValue?: T) => T): () => T

Creates a lazily-evaluated computed value.

The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter.

Computed values are read-only; they cannot be set directly.

@param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint.

@example

const a = signal(1);
const b = signal(2);
const sum = computed(() => a() + b());
sum(); // β†’ 3
a(10);
sum(); // β†’ 12 (re-evaluated lazily)

computed
} from "elements-kit/signals";
import {
function useSignal<T>(value: () => T): T

Subscribe to any readable signal β€” writable or computed β€” returning its current value.

Accepts any zero-argument callable () => T, which includes both Signal<T> and Computed<T>. Using () => T instead of Computed<T> prevents TypeScript from picking the write overload of Signal<T> during type inference.

@template ― T - The type of the signal value.

@param ― value - A writable Signal<T> or a derived Computed<T>.

@returns ― The current value, updated on every signal change.

@example

const count = signal(0);
const double = computed(() => count() * 2);
function Display() {
const countValue = useSignal(count);
const doubleValue = useSignal(double);
return <div>{countValue} Γ— 2 = {doubleValue}</div>;
}

useSignal
} from "elements-kit/integrations/react";
// Defined outside any component β€” shared reactive state
const
const theme: Updater<"light" | "dark"> & Computed<"light" | "dark">
theme
=
signal<"light" | "dark">(initialValue: "light" | "dark"): Updater<"light" | "dark"> & Computed<"light" | "dark"> (+1 overload)

Creates a mutable reactive signal.

  • Read: call with no arguments β†’ returns the current value and subscribes the active tracking context.
  • Write: call with a value β†’ updates the signal and schedules downstream effects if the value changed.

@example

const count = signal(0);
count(); // β†’ 0 (read)
count(1); // write – effects depending on count will re-run
count(); // β†’ 1

signal
<"light" | "dark">("light");
const
const isDark: () => boolean
isDark
=
computed<boolean>(getter: (previousValue?: boolean | undefined) => boolean): () => boolean

Creates a lazily-evaluated computed value.

The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter.

Computed values are read-only; they cannot be set directly.

@param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint.

@example

const a = signal(1);
const b = signal(2);
const sum = computed(() => a() + b());
sum(); // β†’ 3
a(10);
sum(); // β†’ 12 (re-evaluated lazily)

computed
(() =>
const theme: () => "light" | "dark" (+1 overload)
theme
() === "dark");
function
function ThemeToggle(): JSX$1.Element
ThemeToggle
() {
const
const dark: boolean
dark
=
useSignal<boolean>(value: () => boolean): boolean

Subscribe to any readable signal β€” writable or computed β€” returning its current value.

Accepts any zero-argument callable () => T, which includes both Signal<T> and Computed<T>. Using () => T instead of Computed<T> prevents TypeScript from picking the write overload of Signal<T> during type inference.

@template ― T - The type of the signal value.

@param ― value - A writable Signal<T> or a derived Computed<T>.

@returns ― The current value, updated on every signal change.

@example

const count = signal(0);
const double = computed(() => count() * 2);
function Display() {
const countValue = useSignal(count);
const doubleValue = useSignal(double);
return <div>{countValue} Γ— 2 = {doubleValue}</div>;
}

useSignal
(
const isDark: () => boolean
isDark
);
return (
<
button: Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, JsxNamespaceKeys> & {
[cls: `class:${string}`]: MaybeReactive<boolean>;
ref?: ((el: HTMLButtonElement) => void) | undefined;
} & StyleNamespace & PropNamespace<HTMLButtonElement>
button
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent, JSX.EventHandler<HTMLButtonElement, MouseEvent>> | undefined
onClick
={() =>
const theme: (value: "light" | "dark") => void (+1 overload)
theme
(
const dark: boolean
dark
? "light" : "dark")}>
Switch to {
const dark: boolean
dark
? "light" : "dark"} mode
</
button: Omit<JSX.ButtonHTMLAttributes<HTMLButtonElement>, JsxNamespaceKeys> & {
[cls: `class:${string}`]: MaybeReactive<boolean>;
ref?: ((el: HTMLButtonElement) => void) | undefined;
} & StyleNamespace & PropNamespace<HTMLButtonElement>
button
>
);
}

How it works

useSignal wraps useSyncExternalStore. It creates a signal effect that reads the value and calls React’s onStoreChange callback whenever the signal updates. This is concurrent-mode safe β€” React can interrupt renders without missing updates.

// Accepts any () => T β€” both Signal<T> and Computed<T>
useSignal(count) // Signal<number>
useSignal(doubled) // Computed<number>
useSignal(() => items().length) // inline expression

Components only re-render when their signal dependencies change β€” not on every signal write in the app.


useScope

Creates a signal effect scope tied to the component’s lifetime. All effects registered inside the callback are stopped automatically when the component unmounts.

If the callback returns a Computed<T>, useScope subscribes to it and returns the current value β€” like useSignal but with its own scope for side effects.

StrictMode-safe. The scope is held in a ref, so React’s development-mode double mount/unmount reuses the same scope β€” no duplicate effects, no leaked subscriptions.

import { effect, onCleanup } from "elements-kit/signals";
import { useScope } from "elements-kit/integrations/react";
function Logger() {
useScope(() => {
// All effects here are cleaned up on unmount
effect(() => console.log("count:", count()));
effect(() => console.log("user:", user()));
// onCleanup composes naturally inside the scope
effect(() => {
const ws = new WebSocket(endpoint());
onCleanup(() => ws.close());
});
});
return null;
}

Returning a computed value

import { computed } from "elements-kit/signals";
import { useScope } from "elements-kit/integrations/react";
function ExpensiveMetrics() {
// Compute inside the scope β€” lifecycle managed by the component
const metrics = useScope(() =>
computed(() => ({
total: items().reduce((s, i) => s + i.value, 0),
count: items().length,
}))
);
return (
<dl>
<dt>Total</dt><dd>{metrics?.total}</dd>
<dt>Count</dt><dd>{metrics?.count}</dd>
</dl>
);
}

When to use each hook

useSignaluseScope
Read a signal / computedβœ“βœ“ (return computed)
Run side effectsβ€”βœ“
Group multiple effectsβ€”βœ“
Cleanup on unmountautomaticautomatic
StrictMode-safeβœ“βœ“

Store

A store is a plain class with @reactive fields β€” framework-agnostic reactive state. Reading from a store inside useSignal or useScope creates a live subscription exactly like reading a signal directly.

store.ts
import {
function reactive<This extends object, Value>(source?: (self: This) => Signal<Value>): (_target: unknown, context: ClassFieldDecoratorContext<This, Value>) => (this: This, initialValue: Value) => Value

A decorator that makes a class field reactive by automatically wrapping its value in a signal.

The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates.

@example

class Counter {
\@reactive() count: number = 0;
}
const counter = new Counter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes

@remarks ―

Equivalent to manually creating a private signal and getter/setter:

class Counter {
#count = signal(0);
get count() { return this.#count(); }
set count(value) { this.#count(value); }
}

reactive
,
function computed<T>(getter: (previousValue?: T) => T): () => T

Creates a lazily-evaluated computed value.

The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter.

Computed values are read-only; they cannot be set directly.

@param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint.

@example

const a = signal(1);
const b = signal(2);
const sum = computed(() => a() + b());
sum(); // β†’ 3
a(10);
sum(); // β†’ 12 (re-evaluated lazily)

computed
} from "elements-kit/signals";
export class
class CounterStore
CounterStore
{
@
reactive<object, unknown>(source?: ((self: object) => Signal<unknown>) | undefined): (_target: unknown, context: ClassFieldDecoratorContext<object, unknown>) => (this: object, initialValue: unknown) => unknown

A decorator that makes a class field reactive by automatically wrapping its value in a signal.

The field behaves like a normal property (get/set) but reactivity is tracked under the hood. Any reads will subscribe to the signal and any writes will trigger updates.

@example

class Counter {
\@reactive() count: number = 0;
}
const counter = new Counter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes

@remarks ―

Equivalent to manually creating a private signal and getter/setter:

class Counter {
#count = signal(0);
get count() { return this.#count(); }
set count(value) { this.#count(value); }
}

reactive
()
CounterStore.count: number
count
= 0;
CounterStore.doubled: () => number
doubled
=
computed<number>(getter: (previousValue?: number | undefined) => number): () => number

Creates a lazily-evaluated computed value.

The getter is only called when the computed value is read and one of its dependencies has changed since the last evaluation. If nothing has changed the cached value is returned without re-running getter.

Computed values are read-only; they cannot be set directly.

@param ― getter - Pure function deriving a value from other reactive sources. Receives the previous value as an optional optimisation hint.

@example

const a = signal(1);
const b = signal(2);
const sum = computed(() => a() + b());
sum(); // β†’ 3
a(10);
sum(); // β†’ 12 (re-evaluated lazily)

computed
(() => this.
CounterStore.count: number
count
* 2);
CounterStore.increment(): void
increment
() { this.
CounterStore.count: number
count
++; }
CounterStore.decrement(): void
decrement
() { this.
CounterStore.count: number
count
--; }
CounterStore.reset(): void
reset
() { this.
CounterStore.count: number
count
= 0; }
}
// Singleton shared across the whole app β€” or per-tree instances
export const
const counter: CounterStore
counter
= new
constructor CounterStore(): CounterStore
CounterStore
();
Counter.tsx
import { useSignal } from "elements-kit/integrations/react";
import { counter } from "./store";
export function Counter() {
// () => store.field β€” the getter reads the signal, creating a subscription
const count = useSignal(() => counter.count);
const doubled = useSignal(counter.doubled); // Computed<T> works directly
return (
<div>
<p>{count} Γ— 2 = {doubled}</p>
<button onClick={() => counter.increment()}>+1</button>{" "}
<button onClick={() => counter.decrement()}>βˆ’1</button>{" "}
<button onClick={() => counter.reset()}>Reset</button>
</div>
);
}

The store doesn’t know about React. The same counter instance can drive a custom element, a React component, and a plain effect β€” all sharing the same reactive state and updating in sync.


Custom elements in JSX

A custom element built with elements-kit is just class extends HTMLElement registered via customElements.define. React renders it fine at runtime, but React’s JSX.IntrinsicElements is independent of elements-kit’s, so you need a small augmentation to get typed props, narrowed ref, and editor autocomplete on the React side.

Source the prop shape from the class

Two helpers exported from elements-kit/jsx-runtime:

HelperShapeUse when
InstanceProps<I>Public instance fields only (drops the HTMLElement surface)You want to type just the user-defined props
ElementProps<C>Full elements-kit JSX surface β€” attrs, fields, events from static events, slots from [SLOTS], childrenYour element declares typed events or slots and you want callers to see them

For most React wiring InstanceProps<XCounter> is enough.

Augment React’s JSX.IntrinsicElements

import type { InstanceProps } from "elements-kit/jsx-runtime";
import type { XCounter } from "./x-counter";
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"x-counter": React.DetailedHTMLProps<
React.HTMLAttributes<XCounter> & InstanceProps<XCounter>,
XCounter
>;
}
}
}
// <x-counter count={5} ref={(el) => …} /> β€” el: XCounter

React.DetailedHTMLProps is what gives you the standard HTML attribute surface (className, id, onClick, …) and a ref narrowed to XCounter.

Co-locate the augmentation with the class

Put the declare module "react" block in the same file as the element class. Importing the class then brings the augmentation along β€” consumers don’t have to remember to write it themselves.

x-counter.ts
import { defineElement } from "elements-kit/custom-elements";
import { reactive } from "elements-kit/signals";
import type { InstanceProps } from "elements-kit/jsx-runtime";
export class XCounter extends HTMLElement {
@reactive() count = 0;
}
defineElement("x-counter", XCounter);
declare module "react" {
namespace JSX {
interface IntrinsicElements {
"x-counter": React.DetailedHTMLProps<
React.HTMLAttributes<XCounter> & InstanceProps<XCounter>,
XCounter
>;
}
}
}

Typed document.querySelector and createElement

Independent of any JSX framework, augmenting the global HTMLElementTagNameMap gives you typed lookups in plain DOM code:

declare global {
interface HTMLElementTagNameMap {
"x-counter": XCounter;
}
}
const el = document.querySelector("x-counter"); // XCounter | null

Worth doing alongside the React augmentation.


See also