# 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`](https://react.dev/reference/react/useSyncExternalStore), so React only re-renders components that actually depend on a changed signal.

```jsx
// 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.

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

const count = signal(0);
const doubled = computed(() => count() * 2);

function Counter() {
  const value = useSignal(count);
  const doubleValue = useSignal(doubled);
  return <p>{value} × 2 = {doubleValue}</p>;
}

// Signals live outside React — writing from anywhere triggers a re-render
setInterval(() => count(count() + 1), 1000);
```

```tsx
/** @jsxImportSource react */
import { signal, computed } from "elements-kit/signals";
import { useSignal } from "elements-kit/integrations/react";

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

function ThemeToggle() {
  const dark = useSignal(isDark);

  return (
    <button onClick={() => theme(dark ? "light" : "dark")}>
      Switch to {dark ? "light" : "dark"} mode
    </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.

```tsx
// 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.

```tsx
// `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:

```tsx
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.

```tsx
import { computed, effect, onCleanup } from "elements-kit/signals";
import { useScope } from "elements-kit/integrations/react";

function Tracker() {
  useScope(() => {
    effect(() => {
      const id = setInterval(() => tick(), 1000);
      // onCleanup works inside useScope — cleared on unmount
      onCleanup(() => clearInterval(id));
    });
  });
}
```

```tsx
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

```tsx
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

|                          | `useSignal` | `useScope`          |
| ------------------------ | ----------- | ------------------- |
| Read a signal / computed | ✓           | ✓ (return computed) |
| Run side effects         | —           | ✓                   |
| Group multiple effects   | —           | ✓                   |
| Cleanup on unmount       | automatic   | automatic           |
| StrictMode-safe          | ✓           | ✓                   |

***

## Store

A [store](/stores) 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.

```ts
// store.ts
import { reactive, computed } from "elements-kit/signals";

export class CounterStore {
  @reactive() count = 0;
  doubled = computed(() => this.count * 2);

  increment() { this.count++; }
  decrement() { this.count--; }
  reset()     { this.count = 0; }
}

// Singleton shared across the whole app — or per-tree instances
export const counter = new CounterStore();
```

```tsx
// 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.

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

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

### Augment React's `JSX.IntrinsicElements`

```ts
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.

```ts
// 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:

```ts
declare global {
  interface HTMLElementTagNameMap {
    "x-counter": XCounter;
  }
}

const el = document.querySelector("x-counter"); // XCounter | null
```

Worth doing alongside the React augmentation.

***

## See also

* [Signals](/signals)
* [Stores](/stores)
* [Custom Elements overview](/custom-elements)
* [Solid integration](/integrations/solid)
* [Vue integration](/integrations/vue)
* [Angular integration](/integrations/angular)
* [Svelte integration](/integrations/svelte)
* [Lit integration](/integrations/lit)
* [Astro integration](/integrations/astro)
* [Marko integration](/integrations/marko)
* [Qwik integration](/integrations/qwik)
