React

Use signals in React components with useSignal and useScope.

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";
App.tsxmain.tsx
/** @jsxImportSource react */
import { signal, computed, effect } from "elements-kit/signals";
import { useSignal, useScope } from "elements-kit/integrations/react";

// Signals are defined outside React — global reactive state.
// Any component that reads them re-renders when they change.
const count = signal(0);
const doubled = computed(() => count() * 2);

export default function App() {
  // Subscribe — re-renders only when count or doubled change
  const value = useSignal(count);
  const doubleValue = useSignal(doubled);

  // Scope: effects tied to this component's lifetime
  useScope(() => {
    effect(() => {
      console.log("count changed:", count());
    });
  });

  return (
    <div style={{ fontFamily: "sans-serif", padding: "2rem" }}>
      <h2>Signal Counter</h2>
      <p>
        Count: <strong>{value}</strong>
        {" — "}
        Doubled: <strong>{doubleValue}</strong>
      </p>
      <button onClick={() => count(count() + 1)}>+1</button>{" "}
      <button onClick={() => count(count() - 1)}>−1</button>{" "}
      <button onClick={() => count(0)}>Reset</button>
      <p style={{ fontSize: "0.8em", color: "#888", marginTop: "1rem" }}>
        Open the console to see effect logs.
      </p>
    </div>
  );
}

useSignal

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

import { signal, computed } from "elements-kit/signals";
import { useSignal } from "elements-kit/integrations/react";

const count = signal(0);
1 / 4
/** @jsxImportSource react */
import { ,  } from "elements-kit/signals";
import {  } from "elements-kit/integrations/react";

// Defined outside any component — shared reactive state
const  = <"light" | "dark">("light");
const  = (() => () === "dark");

function () {
  const  = ();

  return (
    < ={() => ( ? "light" : "dark")}>
      Switch to { ? "light" : "dark"} mode
    </>
  );
}

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.

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.
const mode = 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.

import { effect } from "elements-kit/signals";
import { useScope } from "elements-kit/integrations/react";

function Analytics() {
// Effects run when the component mounts
useScope(() => {
effect(() => console.log("page:", currentPage()));
effect(() => console.log("user:", currentUser()));
});
}
1 / 3
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 { ,  } from "elements-kit/signals";

export class  {
  @()  = 0;
   = (() => this. * 2);

  () { this.++; }
  () { this.--; }
  ()     { this. = 0; }
}

// Singleton shared across the whole app — or per-tree instances
export const  = new ();
// 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

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.

HelperShapeUse when
PropertiesOf<typeof XCounter>Public instance fields, all optional — the HTMLElement surface is dropped; Slot-typed fields accept a NodeTyping property assignments — prop bindings, wrappers, helper functions
AttributesOf<typeof XCounter>Every key of static [ATTRIBUTES], valued string | nullTyping the raw HTML attribute surface — setAttribute, server-rendered markup
EventsOf<typeof XCounter>The static events map, verbatim — event name to event typeTyping addEventListener, framework event bindings, HTMLElementEventMap augmentations

For most React wiring PropertiesOf<typeof XCounter> is enough.

Augment React's JSX.IntrinsicElements

import type { PropertiesOf } from "elements-kit/custom-elements";
import type { XCounter } from "./x-counter";

declare module "react" {
  namespace JSX {
    interface IntrinsicElements {
      "x-counter": React.DetailedHTMLProps<
        React.HTMLAttributes<XCounter> & PropertiesOf<typeof 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, type PropertiesOf } from "elements-kit/custom-elements";
import { reactive } from "elements-kit/signals";

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> & PropertiesOf<typeof 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