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
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.
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.
Server rendering: pass getServerSnapshot for any component React renders
on the server β React requires it there and throws without one. It runs on
the server and again for the hydration render on the client, so it must
return the same value both times: whatever the server put in the HTML. A
signal reading browser state (matchMedia, localStorage, location) does
not qualify, since it answers differently in the browser.
@template β T - The type of the signal value.
@param β value - A writable Signal<T> or a derived Computed<T>.
@param β getServerSnapshot - Returns the value the server rendered. Required
for server-rendered components; omit only for client-only ones.
@returns β The current value, updated on every signal change.
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.
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.
Server rendering: pass getServerSnapshot for any component React renders
on the server β React requires it there and throws without one. It runs on
the server and again for the hydration render on the client, so it must
return the same value both times: whatever the server put in the HTML. A
signal reading browser state (matchMedia, localStorage, location) does
not qualify, since it answers differently in the browser.
@template β T - The type of the signal value.
@param β value - A writable Signal<T> or a derived Computed<T>.
@param β getServerSnapshot - Returns the value the server rendered. Required
for server-rendered components; omit only for client-only ones.
@returns β The current value, updated on every signal change.
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>
Components only re-render when their signal dependencies change β not on every signal write in the app.
Server rendering
React calls getServerSnapshot on the server, and again in the browser for the
hydration render. Both runs must return the same thing β whatever the server put
in the HTML β so it cannot read browser state.
// `theme` is "light" on the server: no matchMedia, no localStorage.
constmode=useSignal(theme, () =>"light");
Immediately after hydration React re-reads the live value and re-renders, so a
dark-mode user ends up on "dark"; the first paint is what the server sent.
It is required for any component React renders on the server. Omit it there
and React throws:
Missing getServerSnapshot, which is required for server-rendered content.
Omit it only for client-only components β an Astro island marked
client:only, for instance β where the server snapshot is never called.
Passing the live signal is the common mistake, and it type-checks:
useSignal(theme, () =>theme()); // β reads the browser during hydration
useSignal(theme, () =>"light"); // β a value the server committed to
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.
// Compute inside the scope β lifecycle managed by the component
constmetrics=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
useSignal
useScope
Read a signal / computed
β
β (return computed)
Run side effects
β
β
Group multiple effects
β
β
Cleanup on unmount
automatic
automatic
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.
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
classCounter {
\@reactive() count:number=0;
}
constcounter=newCounter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes
@remarks β
Equivalent to manually creating a private signal and getter/setter:
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.
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
classCounter {
\@reactive() count:number=0;
}
constcounter=newCounter();
counter.count++; // Triggers reactivity
console.log(counter.count); // Subscribes to changes
@remarks β
Equivalent to manually creating a private signal and getter/setter:
classCounter {
#count=signal(0);
getcount() { returnthis.#count(); }
setcount(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
consta=signal(1);
constb=signal(2);
constsum=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
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
Three type helpers exported from elements-kit/custom-elements read the raw platform surface off the class. All three take the constructor (typeof XCounter); PropertiesOf also accepts an instance type.
Helper
Shape
Use when
PropertiesOf<typeof XCounter>
Public instance fields, all optional β the HTMLElement surface is dropped; Slot-typed fields accept a Node
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, type PropertiesOf } from"elements-kit/custom-elements";