Components
Components are the building blocks of SignalX applications. They encapsulate UI logic, state, and rendering in a reusable package.
Defining Components
Use component to create a component:
import { component, render } from 'sigx';
const Greeting = component(() => {
console.log('Greeting component created');
return () => <h1>Hello, World!</h1>;
});
render(<Greeting />, "#sandbox");
The function passed to component is the setup function. It runs once when the component is created and returns a render function.
Setup runs untracked.
setup()— and thecreated/mountedhooks that run during mounting — execute without dependency tracking. Reactive reads in a child's setup do not become dependencies of an ancestor's render effect, so they can't trigger spurious ancestor re-renders. Component reactivity belongs to the per-component render function and to explicitwatch/computedscopes (which create their own subscriptions). Put reactive reads you want tracked inside the returned render function, awatch, or acomputed— not bare insetup().
Setup Context
The setup function receives a context object with everything you need:
import { component, render } from 'sigx';
const MyComponent = component((ctx) => {
// ctx.signal - Create reactive state
const state = ctx.signal({ count: 0 });
// ctx.props - Access component props
// ctx.slots - Access named slots
// ctx.emit - Emit events to parent
// ctx.onMounted - Register mount callback
// ctx.onUnmounted - Register unmount callback
// ctx.expose - Expose API to parent via ref
return () => <div>{state.count}</div>;
});
render(<MyComponent />, "#sandbox");
You can also destructure the context:
import { component, render } from 'sigx';
const MyComponent = component(({ signal, props, slots, emit }) => {
const state = signal({ count: 0 });
return () => <div>{state.count}</div>;
});
render(<MyComponent />, "#sandbox");
Props
Components can receive props from their parent:
import { component, render, type Define } from 'sigx';
type GreetingProps = Define.Prop<'name', string, true>; // required
const Greeting = component<GreetingProps>(({ props }) => {
return () => <h1>Hello, {props.name}!</h1>;
});
render(<Greeting name="SignalX" />, "#sandbox");
Optional Props
Mark props as optional with a false third type parameter:
import { component, render, type Define } from 'sigx';
type ButtonProps =
& Define.Prop<'variant', 'primary' | 'secondary', false> // optional
& Define.Prop<'disabled', boolean, false> // optional
& Define.Slot<'default'>;
const Button = component<ButtonProps>(({ props, slots }) => {
return () => (
<button
class={`btn btn-${props.variant || 'primary'}`}
disabled={props.disabled}
>
{slots.default?.()}
</button>
);
});
render(<Button variant="primary">Click Me</Button>, "#sandbox");
Forwarding Props
A component that wraps an element usually wants the consumer's leftover props to reach that element. That's plain destructuring — take the props the component handles itself, spread the rest:
import { component, render, type Define } from 'sigx';
type ButtonProps =
& Define.Prop<'variant', 'primary' | 'secondary'>
& Define.Slot<'default'>
& Define.Attrs;
const Button = component<ButtonProps>((ctx) => {
return () => {
const { variant, ...rest } = ctx.props;
return (
<button class={`btn btn-${variant ?? 'primary'}`} {...rest}>
{ctx.slots.default?.()}
</button>
);
};
});
// `title` and `onClick` are never named by Button — they ride the rest spread.
const App = component(() => {
return () => (
<Button
variant="primary"
title="forwarded straight through"
onClick={() => console.log('clicked')}
>
Hover me
</Button>
);
});
render(<App />, "#sandbox");
ctx.props carries the props the parent declared and nothing else. key and
ref are peeled off before setup runs — alongside children, slots and
$models — because both are vnode-level concerns the renderer reads off the
vnode directly. So the spread above can't bind the consumer's ref a second
time with the element, and a spread onto a child can't stamp it with a key the
author never wrote.
At the element, framework-internal keys are skipped rather than rendered:
client:* hydration directives and Model-valued props never become DOM
attributes, on the client or in SSR output.
Forwarding is reactive in both directions. A wrapper that spreads its props —
component((ctx) => () => <Child {...ctx.props} />) — re-renders when the parent
starts passing a new prop key, not only when a prop it already passed
changes: enumerating a reactive object subscribes to its key set as well as to
the individual keys. The same holds for rest destructuring forwarded onto an
element.
Note: two spellings of one event on the same element —
onClickfrom the component andonclickarriving through a spread — resolve to the same'click'listener slot, so whichever is patched last wins and the other never runs. Development builds warn once per element when that happens.
Host Attributes
A component accepts host attributes — id, class, style, title, role,
tabIndex, hidden, dir, lang, the rest of the universal set, plus
data-*, aria-* and the camelCase DOM handlers — when it declares
Define.Attrs:
import { component, type Define } from 'sigx';
type ButtonProps =
& Define.Prop<'variant', 'primary' | 'secondary'>
& Define.Attrs;
Declare it only if the component really does forward its leftover props to an
element. The declaration is a promise to the consumer: a type that accepts
title on a component that drops it is exactly the failure the opt-in exists to
prevent.
When the component declares a prop whose name collides with a host attribute,
use Define.WithAttrs<TOwn> — the component's own declaration wins:
// `title` here is a heading, not the HTML tooltip attribute.
type DialogProps = Define.WithAttrs<Define.Prop<'title', string, true>>;
Handlers are advertised in camelCase only. Both spellings reach the same DOM
listener slot, so offering onclick alongside onClick in the type would
advertise a collision.
Note: TypeScript exempts JSX attribute names that aren't valid identifiers from excess-property checking, so
data-*andaria-*compile on any component whatever its props type says. No declaration can make those an error — only forwarding them at runtime makes them work.
Combining Prop Sources
A rest spread covers the common case, and needs no helper. What it can't do is
combine two sources. A JSX spread is lowered by the compiler into a single
object literal before the runtime sees anything, so in
<button {...rest} {...bag} /> later keys clobber earlier ones — if the consumer
and the component both set class, one is lost.
mergeProps(...sources) combines the four kinds of key that must not overwrite:
| Key | How it combines |
|---|---|
class / className | Concatenated in source order into one class |
style | Merged left-to-right into an object; string sources are parsed first |
on* handlers | Chained in source order, grouped by the event they resolve to, so onClick and onclick become one entry |
ref | Chained into one ref that feeds every source's ref |
| everything else | Exact spread semantics — the last source with the key wins |
Sources may be objects or zero-argument thunks, and the result resolves on read, so thunks stay reactive:
import { component, mergeProps, render, type Define } from 'sigx';
type ButtonProps =
& Define.Prop<'variant', 'primary' | 'secondary'>
& Define.Slot<'default'>
& Define.Attrs;
const Button = component<ButtonProps>((ctx) => {
const onActivate = () => console.log('the component handler ran');
const merged = mergeProps(
() => { const { variant: _v, ...rest } = ctx.props; return rest; },
() => ({ class: 'btn', onClick: onActivate })
);
return () => <button {...merged}>{ctx.slots.default?.()}</button>;
});
// The consumer sets `class` and `onClick` too — both sides survive.
const App = component(() => {
return () => (
<Button
variant="primary"
class="danger"
onClick={() => console.log('the consumer handler ran')}
>
Click me
</Button>
);
});
render(<App />, "#sandbox");
Click it and both handlers log, in source order; inspect the button and its
class is btn danger. A plain {...rest} {...bag} spread would have kept only
one of each.
Three things to get right:
- Call it once in setup, as above. The derived
refand the chained handlers are identity-cached, which a per-render call throws away — the renderer then tears down and re-applies refs for nothing. - It is not a defaults helper. It replaces a spread and behaves like one,
so a later source's explicit
undefinedwins. Destructuring with defaults already covers defaulting. - Chaining can't express swallow. A component that gates a consumer's
handler — dropping
onClickwhile disabled — keeps destructuring it out and calling it itself.
Slots
Slots allow parent components to inject content:
import { component, render, type Define } from 'sigx';
type CardProps = Define.Slot<'default'> & Define.Slot<'header'>;
const Card = component<CardProps>(({ slots }) => {
return () => (
<div class="card" style="border: 1px solid #ccc; border-radius: 8px; overflow: hidden;">
<div class="card-header" style="background: #f5f5f5; padding: 12px; border-bottom: 1px solid #ccc;">
{slots.header?.() ?? <span>Default Header</span>}
</div>
<div class="card-body" style="padding: 16px;">
{slots.default?.()}
</div>
</div>
);
});
render(
<Card slots={{ header: () => <h2 style="margin: 0;">Card Title</h2> }}>
<p style="margin: 0;">Card content goes here</p>
</Card>,
"#sandbox"
);
Scoped Slots
Pass data back to the parent:
import { component, render, type Define } from 'sigx';
type ListProps<T> =
& Define.Prop<'items', T[], true>
& Define.Slot<'item', { item: T; index: number }>;
const List = component<ListProps<any>>(({ props, slots }) => {
return () => (
<ul>
{props.items.map((item, index) => (
<li key={index}>{slots.item?.({ item, index })}</li>
))}
</ul>
);
});
const fruits = ['Apple', 'Banana', 'Cherry'];
render(
<List
items={fruits}
slots={{ item: ({ item, index }) => <span>{index + 1}. {item}</span> }}
/>,
"#sandbox"
);
Two ways to fill a scoped slot
A function passed as children fills the default slot and receives the same scoped props. The two forms are equivalent:
// As a function child
<Row>{({ active }) => <span>{active ? 'on' : 'off'}</span>}</Row>
// As a `slots` prop
<Row slots={{ default: ({ active }) => <span>{active ? 'on' : 'off'}</span> }} />
The function child is what makes the asChild render-prop pattern work — a
component that owns behaviour and hands the caller its state, letting the caller
decide the markup:
import { component, render, type Define } from 'sigx';
type ToggleProps = Define.Slot<'default', { on: boolean; toggle: () => void }>;
const Toggle = component<ToggleProps>(({ signal, slots }) => {
const state = signal({ on: false });
const toggle = () => state.on = !state.on;
return () => slots.default?.({ on: state.on, toggle });
});
// The caller supplies the element — Toggle only owns the state.
const App = component(() => {
return () => (
<Toggle>
{({ on, toggle }) => (
<button onClick={toggle} aria-pressed={on}>{on ? 'On' : 'Off'}</button>
)}
</Toggle>
);
});
render(<App />, "#sandbox");
The function is invoked once per accessor call, inside the consumer's render, so
reactivity is preserved — and the client and SSR agree, keeping hydration in
step. A fill returning null or undefined is dropped and an array is
flattened one level, exactly as the slots-prop form behaves.
Element children with a slot="x" attribute are unaffected: a function only
ever fills the default slot, because a function never satisfies the slot-prop
test that routes a child to a named one.
What slot= routes, per child shape
slot= is the HTML attribute, so it routes host elements and fragments only:
| Child | Where it lands |
|---|---|
<div slot="header"> — host element | the header slot |
<><span slot="header"/></> — fragment | the header slot |
{() => …} — function child | the default slot, always |
<Chip slot="badge"/> — component | the default slot |
A component child is the one to know about. The JSX types have always rejected
slot= on a component, so it was reachable only through a cast or from untyped
JS — and it now renders in the default slot, with slot arriving as an ordinary
undeclared prop plus a development warning.
The typed way to fill a named slot with a component is the slots prop, which is
checked against the consumer's declared slots:
// Not routed — Chip lands in the default slot.
<Card><Chip slot="badge"/></Card>
// Routed, and type-checked against Card's declared slots.
<Card slots={{ badge: () => <Chip/> }}/>
Fills are type-checked against the declaration
A slot declaration is a contract on both sides, and TypeScript enforces both halves:
// The declaration is what buys the checking.
type ListProps = Define.Slot<'row', { item: Item; active: boolean }>;
<List>{(p) => <Row item={p.item}/>}</List> // p is INFERRED — no annotation
Two mistakes that used to compile are now errors:
- A fill expecting props the slot does not declare. Fix by declaring them:
Define.Slot<'default', { active: boolean }>. - Calling a props-declaring slot with no arguments —
slots.row?.()whererowdeclares props. Fix by passing them:slots.row?.(props).
Together those were one runtime crash — a fill destructuring { active } on a
slot that declares nothing, invoked with no argument — now unreachable rather
than merely survivable.
The payoff for correct code is inference: an unannotated fill parameter is typed from the declaration, with no annotation and no cast.
A component that declares no default slot keeps children?: any. Children
are legal without a declaration and there is nothing to check them against, so
declaring the slot is what buys the checking. Element and function children still
mix freely in one default slot.
Slot presence
A slot accessor is a callable only when the parent actually provided content;
when it didn't, the slot reads as undefined. So testing presence is a plain
truthiness check, and you always call a slot optionally with ?.():
const Card = component<CardProps>(({ slots }) => {
return () => (
<div class="card">
{/* render a header region only when one was passed */}
{slots.header && <header>{slots.header?.()}</header>}
{/* always call optionally — body is provided or it isn't */}
<div class="body">{slots.default?.()}</div>
{/* fall back when a slot is absent */}
<footer>{slots.footer?.() ?? <small>No footer</small>}</footer>
</div>
);
});
Things to know:
- Always call slots optionally —
slots.default?.(). Invoking a slot the parent never supplied (slots.default()when it'sundefined) throws. - Presence is a plain check —
slots.xfor "is it there?",slots.x?.()to render it,slots.x?.() ?? fallbackto substitute when absent. - Presence is reactive. A slot appearing or disappearing re-renders the
consumer, so a region that depends on
slots.xupdates on its own. - A slot passed via the
slotsprop counts as present regardless of what its function returns — even a slot that returnsnullis "provided", so the presence check reflects what the parent passed, not what it rendered. - SSR matches the client — presence resolves identically on the server and during hydration, so there's no mismatch.
Events
Components can emit events to their parent:
import { component, render, type Define } from 'sigx';
type ButtonProps =
& Define.Event<'click', MouseEvent>
& Define.Event<'customAction', { action: string }>
& Define.Slot<'default'>;
const Button = component<ButtonProps>(({ slots, emit }) => {
return () => (
<button onClick={(e) => {
emit('click', e);
emit('customAction', { action: 'clicked' });
}}>
{slots.default?.()}
</button>
);
});
const App = component(({ signal }) => {
const log = signal({ message: 'Click the button!' });
return () => (
<div>
<Button
onClick={() => log.message = 'Clicked!'}
onCustomAction={({ action }) => log.message = `Action: ${action}`}
>
Click me
</Button>
<p style="margin-top: 8px;">{log.message}</p>
</div>
);
});
render(<App />, "#sandbox");
Exposing Component API
Use expose to make methods available to parent via ref:
import { component, Exposed, render, type Define } from 'sigx';
type CounterExpose = Define.Expose<{
increment: () => void;
reset: () => void;
}>;
const Counter = component<CounterExpose>(({ signal, expose }) => {
const state = signal({ count: 0 });
expose({
increment: () => state.count++,
reset: () => state.count = 0
});
return () => <div style="font-size: 24px; margin-bottom: 12px;">Count: {state.count}</div>;
});
const App = component(() => {
let counterApi: Exposed<typeof Counter>;
return () => (
<div>
<Counter ref={r => counterApi = r!} />
<button onClick={() => counterApi?.increment()}>Increment</button>
<button onClick={() => counterApi?.reset()} style="margin-left: 8px;">Reset</button>
</div>
);
});
render(<App />, "#sandbox");
Component Context Reference
| Property | Description |
|---|---|
signal | Create reactive state |
props | Reactive props accessor |
slots | Named slot functions (default always exists) |
emit | Typed event emitter |
el | The component's root element (after mount) |
parent | Parent component instance (if any) |
onMounted(fn) | Register mount callback |
onUnmounted(fn) | Register unmount callback |
onCreated(fn) | Register created callback |
onUpdated(fn) | Register update callback |
expose(api) | Expose API to parent via ref |
update() | Force re-render (for HMR) |
Two-Way Binding
SignalX provides powerful model binding for two-way data synchronization with form elements and custom components:
import { component, render } from 'sigx';
const FormDemo = component(({ signal }) => {
const state = signal({ name: '', agreed: false });
return () => (
<div>
<input model={() => state.name} placeholder="Your name" />
<label>
<input type="checkbox" model={() => state.agreed} />
I agree
</label>
<p>Hello, {state.name || 'stranger'}! Agreed: {state.agreed ? 'Yes' : 'No'}</p>
</div>
);
});
render(<FormDemo />, "#sandbox");
For complete documentation on two-way binding including:
- All supported native elements (text, checkbox, radio, select, textarea)
- Creating custom components with
Define.Model<T> - Named model props with
model:propName
See the dedicated Two-Way Binding guide.
Fragment
Use Fragment to return multiple elements without a wrapper:
import { component, Fragment, render, type Define } from 'sigx';
const ListItems = component(() => {
return () => (
<Fragment>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</Fragment>
);
});
type ConditionalProps = Define.Prop<'showExtra', boolean, false>;
const ConditionalItems = component<ConditionalProps>(({ props }) => {
return () => (
<ul>
{props.showExtra && (
<>
<li>Extra 1</li>
<li>Extra 2</li>
</>
)}
<li>Always visible</li>
</ul>
);
});
const App = component(({ signal }) => {
const state = signal({ showExtra: true });
return () => (
<div>
<label>
<input type="checkbox" model={() => state.showExtra} /> Show extra items
</label>
<ConditionalItems showExtra={state.showExtra} />
<h4>Using Fragment:</h4>
<ul><ListItems /></ul>
</div>
);
});
render(<App />, "#sandbox");
Component Options
Pass options as a second argument:
import { component, render } from 'sigx';
const MyComponent = component(
(ctx) => {
return () => <div>Hello from named component!</div>;
},
{ name: 'MyComponent' } // Shows in DevTools
);
render(<MyComponent />, "#sandbox");
