Skip to content

Components

A component is a class with a render() method that returns an Element. It combines a store (reactive state) with element construction (JSX). The simplest possible component:

Write a component that owns its state

A typical component owns its state and produces elements from it. @reactive turns class fields into signals; JSX reads them as live bindings.

Share state across components

When state needs to be shared across components, move it into a standalone store β€” a class with @reactive fields and no render(). Components read from the store; the store holds no reference to components.

// counter-store.ts β€” state only
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.reset(): void
reset
() { this.
CounterStore.count: number
count
= 0; }
}
export const
const counter: CounterStore
counter
= new
constructor CounterStore(): CounterStore
CounterStore
();
// Two components, one store
class CounterDisplay {
render() {
return (
<p>{() => counter.count} Γ— 2 = {counter.doubled}</p>
);
}
}
class CounterControls {
render() {
return (
<div>
<button on:click={() => counter.increment()}>+1</button>
<button on:click={() => counter.reset()}>Reset</button>
</div>
);
}
}

The same counter instance can also drive a React component or a custom element β€” see Stores and React integration.

Rendering lists

For keyed list rendering, use the For component β€” it reconciles a reactive array into the DOM without re-rendering stable rows. See For for the full API.

Function components

A function component is a plain function that returns an Element. Props arrive exactly as the caller wrote them β€” the runtime transforms nothing. A prop passed as a signal arrives as that signal; a prop passed as a value arrives as that value.

That makes the declared type the contract on both sides. Declare plain types when a component takes static props only β€” callers then cannot pass a signal:

function
function Greeting(props: {
name: string;
}): JSX$1.Element
Greeting
(
props: {
name: string;
}
props
: {
name: string
name
: string }) {
return <
p: MaybeReactiveProps<WithJsxNamespaces<Omit<JSX.HTMLAttributes<HTMLParagraphElement>, UnsupportedDomKeys>, HTMLParagraphElement>> & {
ref?: ((el: HTMLParagraphElement) => void) | undefined;
children?: Children;
}
p
>Hello, {
props: {
name: string;
}
props
.
name: string
name
}</
p: MaybeReactiveProps<WithJsxNamespaces<Omit<JSX.HTMLAttributes<HTMLParagraphElement>, UnsupportedDomKeys>, HTMLParagraphElement>> & {
ref?: ((el: HTMLParagraphElement) => void) | undefined;
children?: Children;
}
p
>;
}

Declare Props<P> to accept either form on every key. Hand a prop straight to JSX, which subscribes when it is reactive, or read it with resolve:

import {
function resolve<T>(value: MaybeReactive<T>): T

Resolve a

MaybeReactive

to its current value. Calls the getter when reactive (a signal or computed); returns the value as-is otherwise β€” an unbranded function is a value, not a source, so a callback survives intact.

This is how a function component reads a prop it declared MaybeReactive: the runtime hands props over exactly as the caller wrote them, so the value may be either form. Reading inside an effect or a JSX getter subscribes.

@example

resolve(5); // 5
resolve(count); // current count value β€” signal
resolve(props.label); // current value, whichever form the caller passed
resolve(() => 5); // the function itself β€” unbranded, so not a source

resolve
} from "elements-kit/signals";
import type {
type Props<P> = { [K in keyof P]: undefined extends P[K] ? (P[K] & undefined) | MaybeReactive<Exclude<P[K], P[K] & undefined>> : MaybeReactive<P[K]>; }

Props of a function component that accepts reactive values β€” the same type as

MaybeReactiveProps

, under the name components reach for. Each key holds a plain value or a reactive source; the runtime hands the prop over exactly as the caller wrote it.

Read a key with resolve(props.x), or pass it straight into JSX, which accepts either form. Omit it and declare the plain value type when a component takes static props only β€” callers then cannot pass a signal.

@example

function Greeting(props: Props<{ name: string; excited?: boolean }>) {
return <p>Hello, {props.name}{() => (resolve(props.excited) ? "!" : ".")}</p>;
}

Props
} from "elements-kit/jsx-runtime";
function
function Greeting(props: Props<{
name: string;
excited?: boolean;
}>): JSX$1.Element
Greeting
(
props: MaybeReactiveProps<{
name: string;
excited?: boolean;
}>
props
:
type Props<P> = { [K in keyof P]: undefined extends P[K] ? (P[K] & undefined) | MaybeReactive<Exclude<P[K], P[K] & undefined>> : MaybeReactive<P[K]>; }

Props of a function component that accepts reactive values β€” the same type as

MaybeReactiveProps

, under the name components reach for. Each key holds a plain value or a reactive source; the runtime hands the prop over exactly as the caller wrote it.

Read a key with resolve(props.x), or pass it straight into JSX, which accepts either form. Omit it and declare the plain value type when a component takes static props only β€” callers then cannot pass a signal.

@example

function Greeting(props: Props<{ name: string; excited?: boolean }>) {
return <p>Hello, {props.name}{() => (resolve(props.excited) ? "!" : ".")}</p>;
}

Props
<{
name: string
name
: string;
excited?: boolean | undefined
excited
?: boolean }>,
) {
return (
<
p: MaybeReactiveProps<WithJsxNamespaces<Omit<JSX.HTMLAttributes<HTMLParagraphElement>, UnsupportedDomKeys>, HTMLParagraphElement>> & {
ref?: ((el: HTMLParagraphElement) => void) | undefined;
children?: Children;
}
p
>
Hello, {
props: MaybeReactiveProps<{
name: string;
excited?: boolean;
}>
props
.
name: MaybeReactive<string>
name
}
{() => (
resolve<boolean | undefined>(value: MaybeReactive<boolean | undefined>): boolean | undefined

Resolve a

MaybeReactive

to its current value. Calls the getter when reactive (a signal or computed); returns the value as-is otherwise β€” an unbranded function is a value, not a source, so a callback survives intact.

This is how a function component reads a prop it declared MaybeReactive: the runtime hands props over exactly as the caller wrote them, so the value may be either form. Reading inside an effect or a JSX getter subscribes.

@example

resolve(5); // 5
resolve(count); // current count value β€” signal
resolve(props.label); // current value, whichever form the caller passed
resolve(() => 5); // the function itself β€” unbranded, so not a source

resolve
(
props: MaybeReactiveProps<{
name: string;
excited?: boolean;
}>
props
.
excited?: MaybeReactive<boolean> | undefined
excited
) ? "!" : ".")}
</
p: MaybeReactiveProps<WithJsxNamespaces<Omit<JSX.HTMLAttributes<HTMLParagraphElement>, UnsupportedDomKeys>, HTMLParagraphElement>> & {
ref?: ((el: HTMLParagraphElement) => void) | undefined;
children?: Children;
}
p
>
);
}

Props<P> is an alias for MaybeReactiveProps<P> β€” each key is T | Computed<T>. Use MaybeReactive<T> on individual keys when only some props should accept a signal.

Because nothing is wrapped, an omitted optional prop is plain undefined, so ?? defaults work as written:

const placeholder = resolve(props.placeholder) ?? "Ask anything…";

Opt into getter props

When a body would rather read one uniform shape than branch on which form arrived, convert the props with computedProps. Every key becomes a getter β€” including keys the caller omitted β€” so reads never need resolve:

import {
function computedProps<P extends object>(raw: P & NoArgFnProps<P>): ComputedProps<Unwrap<P>>

Turn props into a bag of per-key getters, so a body reads one shape no matter which form the caller passed. Opt-in: the JSX runtime hands function components their props untouched.

Every key is callable, including one the caller omitted. A getter is always truthy, so defaults go on the call: props.excited() ?? "…". Keys are branded sources, so they keep working when forwarded to a child component.

Function props are the limit: a prop taking arguments is rejected, and a zero-arg one types as its return value. Read those off the raw props.

@example

const count = signal(0);
const props = computedProps({ count, label: "n" });
props.count(); // 0 β€” subscribes to count
props.label(); // "n"

computedProps
} from "elements-kit/signals";
import type {
type Props<P> = { [K in keyof P]: undefined extends P[K] ? (P[K] & undefined) | MaybeReactive<Exclude<P[K], P[K] & undefined>> : MaybeReactive<P[K]>; }

Props of a function component that accepts reactive values β€” the same type as

MaybeReactiveProps

, under the name components reach for. Each key holds a plain value or a reactive source; the runtime hands the prop over exactly as the caller wrote it.

Read a key with resolve(props.x), or pass it straight into JSX, which accepts either form. Omit it and declare the plain value type when a component takes static props only β€” callers then cannot pass a signal.

@example

function Greeting(props: Props<{ name: string; excited?: boolean }>) {
return <p>Hello, {props.name}{() => (resolve(props.excited) ? "!" : ".")}</p>;
}

Props
} from "elements-kit/jsx-runtime";
function
function Chat(raw: Props<{
placeholder?: string;
layout?: string;
}>): JSX$1.Element
Chat
(
raw: MaybeReactiveProps<{
placeholder?: string;
layout?: string;
}>
raw
:
type Props<P> = { [K in keyof P]: undefined extends P[K] ? (P[K] & undefined) | MaybeReactive<Exclude<P[K], P[K] & undefined>> : MaybeReactive<P[K]>; }

Props of a function component that accepts reactive values β€” the same type as

MaybeReactiveProps

, under the name components reach for. Each key holds a plain value or a reactive source; the runtime hands the prop over exactly as the caller wrote it.

Read a key with resolve(props.x), or pass it straight into JSX, which accepts either form. Omit it and declare the plain value type when a component takes static props only β€” callers then cannot pass a signal.

@example

function Greeting(props: Props<{ name: string; excited?: boolean }>) {
return <p>Hello, {props.name}{() => (resolve(props.excited) ? "!" : ".")}</p>;
}

Props
<{
placeholder?: string | undefined
placeholder
?: string;
layout?: string | undefined
layout
?: string }>) {
const
const props: ComputedProps<Unwrap<MaybeReactiveProps<{
placeholder?: string;
layout?: string;
}>>>
props
=
computedProps<MaybeReactiveProps<{
placeholder?: string;
layout?: string;
}>>(raw: MaybeReactiveProps<{
placeholder?: string;
layout?: string;
}> & NoArgFnProps<MaybeReactiveProps<{
placeholder?: string;
layout?: string;
}>>): ComputedProps<Unwrap<MaybeReactiveProps<{
placeholder?: string;
layout?: string;
}>>>

Turn props into a bag of per-key getters, so a body reads one shape no matter which form the caller passed. Opt-in: the JSX runtime hands function components their props untouched.

Every key is callable, including one the caller omitted. A getter is always truthy, so defaults go on the call: props.excited() ?? "…". Keys are branded sources, so they keep working when forwarded to a child component.

Function props are the limit: a prop taking arguments is rejected, and a zero-arg one types as its return value. Read those off the raw props.

@example

const count = signal(0);
const props = computedProps({ count, label: "n" });
props.count(); // 0 β€” subscribes to count
props.label(); // "n"

computedProps
(
raw: MaybeReactiveProps<{
placeholder?: string;
layout?: string;
}>
raw
);
return (
<
input: MaybeReactiveProps<WithJsxNamespaces<Omit<JSX.InputHTMLAttributes<HTMLInputElement>, UnsupportedDomKeys>, HTMLInputElement>> & {
ref?: ((el: HTMLInputElement) => void) | undefined;
children?: Children;
}
input
placeholder?: MaybeReactive<string> | undefined
placeholder
={
const props: ComputedProps<Unwrap<MaybeReactiveProps<{
placeholder?: string;
layout?: string;
}>>>
props
.
placeholder: () => string | undefined
placeholder
() ?? "Ask anything…"}
data-layout: Computed<string | undefined>
data-layout
={
const props: ComputedProps<Unwrap<MaybeReactiveProps<{
placeholder?: string;
layout?: string;
}>>>
props
.
layout: Computed<string | undefined>
layout
}
/>
);
}

Note where the default sits. A getter is always truthy, so props.placeholder ?? "…" would never fall back β€” inside a bag the default goes on the call: props.placeholder() ?? "…".

Every key is a reactive source, whether the caller passed a signal or a plain value. That means a getter keeps working when you forward it to a child component β€” the child can call it, resolve it, or convert its own props with computedProps without wrapping it twice.

Function props

computedProps infers its shape from the argument, and a callable cannot be told apart from a getter β€” Computed<T> is () => T. So a prop that takes arguments (a render prop, a handler with parameters) is rejected rather than mistyped:

// βœ— computedProps: a prop that takes arguments cannot be inferred here
const props = computedProps({ render: (item: string) => item.length });

Read those off the raw props instead.

A zero-arg function prop cannot be rejected β€” Signal<T> and Computed<T> are zero-arg callables themselves, so banning them would ban reactive props. It types as its return value while the runtime hands back the function, so read those raw too.

Reach for computedProps when a component has several props it reads repeatedly. For one or two props, raw reads and resolve are less machinery.

See also

  • Elements β€” JSX β†’ DOM, prop namespaces.
  • For β€” keyed list rendering.
  • Stores β€” shared reactive state.
  • Custom elements β€” components as native HTML tags.