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:

TSX
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 the created/mounted hooks 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 explicit watch/computed scopes (which create their own subscriptions). Put reactive reads you want tracked inside the returned render function, a watch, or a computed — not bare in setup().

Setup Context#

The setup function receives a context object with everything you need:

TSX
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:

TSX
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:

TSX
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:

TSX
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:

TSX
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 — onClick from the component and onclick arriving 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:

TSX
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:

TSX
// `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-* and aria-* 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:

KeyHow it combines
class / classNameConcatenated in source order into one class
styleMerged left-to-right into an object; string sources are parsed first
on* handlersChained in source order, grouped by the event they resolve to, so onClick and onclick become one entry
refChained into one ref that feeds every source's ref
everything elseExact 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:

TSX
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 ref and 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 undefined wins. Destructuring with defaults already covers defaulting.
  • Chaining can't express swallow. A component that gates a consumer's handler — dropping onClick while disabled — keeps destructuring it out and calling it itself.

Slots#

Slots allow parent components to inject content:

TSX
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:

TSX
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:

TSX
// 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:

TSX
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:

ChildWhere it lands
<div slot="header"> — host elementthe header slot
<><span slot="header"/></> — fragmentthe header slot
{() => …} — function childthe default slot, always
<Chip slot="badge"/>componentthe 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:

TSX
// 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:

TSX
// 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 argumentsslots.row?.() where row declares 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 ?.():

TSX
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 optionallyslots.default?.(). Invoking a slot the parent never supplied (slots.default() when it's undefined) throws.
  • Presence is a plain checkslots.x for "is it there?", slots.x?.() to render it, slots.x?.() ?? fallback to substitute when absent.
  • Presence is reactive. A slot appearing or disappearing re-renders the consumer, so a region that depends on slots.x updates on its own.
  • A slot passed via the slots prop counts as present regardless of what its function returns — even a slot that returns null is "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:

TSX
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:

TSX
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#

PropertyDescription
signalCreate reactive state
propsReactive props accessor
slotsNamed slot functions (default always exists)
emitTyped event emitter
elThe component's root element (after mount)
parentParent 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:

TSX
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:

TSX
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:

TSX
import { component, render } from 'sigx';

const MyComponent = component(
    (ctx) => {
        return () => <div>Hello from named component!</div>;
    },
    { name: 'MyComponent' }  // Shows in DevTools
);

render(<MyComponent />, "#sandbox");

Next Steps#