Components API#

Component creation and built-in components.

component#

Create a SignalX component.

TSX
function component<TCombined, TRef, TSlots>(
    setup: (ctx: ComponentSetupContext) => ViewFn | Promise<ViewFn>,
    options?: ComponentOptions
): ComponentFactory<TCombined, TRef, TSlots>;

Parameters#

NameTypeDescription
setup(ctx) => ViewFnSetup function receiving context
options.namestringComponent name for DevTools

Returns#

ComponentFactory - A function usable as a JSX element.

Setup Context#

PropertyTypeDescription
signaltypeof signalCreate reactive state
propsPropsAccessor<TProps>Reactive props
slotsSlotsObject<TSlots>Slot functions
emitEmitFn<TEvents>Emit custom events
elPlatformElementRoot element (after mount)
parentanyParent component
onMounted(fn) => voidRegister mount callback
onUnmounted(fn) => voidRegister unmount callback
onCreated(fn) => voidRegister creation callback
onUpdated(fn) => voidRegister update callback
expose(api) => voidExpose API to parent
update() => voidForce re-render

Examples#

TSX
import { component, type Define } from 'sigx';

type CardProps = 
    & Define.Prop<'title', string, true>
    & Define.Event<'close'>
    & Define.Slot<'default'>
    & Define.Slot<'footer'>;

const Card = component<CardProps>(({ props, slots, emit }) => {
    return () => (
        <div class="card">
            <header>
                {props.title}
                <button onClick={() => emit('close')}>×</button>
            </header>
            <main>{slots.default?.()}</main>
            {slots.footer?.()}
        </div>
    );
}, { name: 'Card' });

// Usage
<Card 
    title="Welcome" 
    onClose={() => setOpen(false)}
    slots={{
        footer: () => <button>OK</button>
    }}
>
    <p>Card content</p>
</Card>

Fragment#

Render children without a wrapper element.

TSX
const Fragment: Symbol;

Usage#

TSX
import { Fragment } from 'sigx';

// Using Fragment component
<Fragment>
    <li>Item 1</li>
    <li>Item 2</li>
</Fragment>

// Using short syntax
<>
    <li>Item 1</li>
    <li>Item 2</li>
</>

lazy#

Create a lazy-loaded component.

TSX
function lazy<T extends ComponentFactory>(
    loader: () => Promise<T | { default: T }>
): LazyComponentFactory<T>;

Parameters#

NameTypeDescription
loader() => Promise<Component>Dynamic import function

Returns#

LazyComponentFactory with:

  • preload(): Promise<T> - Start loading before render
  • isLoaded(): boolean - Check if loaded

Examples#

TSX
import { lazy, Defer } from 'sigx';

const HeavyChart = lazy(() => import('./HeavyChart'));

// Basic usage — Defer shows the fallback while the chunk loads
<Defer fallback={<Spinner />}>
    <HeavyChart />
</Defer>

// Preload on hover
<button onMouseEnter={() => HeavyChart.preload()}>
    Show Chart
</button>

// Check load status
if (HeavyChart.isLoaded()) {
    console.log('Ready');
}

Defer#

Show fallback content while lazy component chunks load. <Defer>'s fallback covers chunk loading only — a pending useData read renders through its own component's match(), never through a wrapper. On the server, the fallback streams with the shell and a single replacement arrives when everything pending beneath it (chunks and keyed data) resolves.

TSX
const Defer: ComponentFactory<DeferProps>;

Props#

NameTypeDescription
fallbackJSXElement | () => JSXElementContent while chunks below load

Examples#

TSX
import { lazy, Defer } from 'sigx';

const Dashboard = lazy(() => import('./Dashboard'));

// Static fallback
<Defer fallback={<div>Loading...</div>}>
    <Dashboard />
</Defer>

// Dynamic fallback
<Defer fallback={() => <Spinner size={size} />}>
    <Dashboard />
</Defer>

// Multiple lazy components — one fallback until all chunks resolve
<Defer fallback={<Loading />}>
    <LazyHeader />
    <LazyContent />
</Defer>

Portal#

Render children to a different DOM location.

TSX
const Portal: ComponentFactory<PortalProps>;

Props#

NameTypeDefaultDescription
tostring | Elementdocument.bodyTarget container
disabledbooleanfalseRender in-place instead

Examples#

TSX
import { Portal } from 'sigx';

// Render to body (default)
<Portal>
    <div class="modal">Modal content</div>
</Portal>

// Render to specific element
<Portal to="#modal-root">
    <div class="modal">Modal content</div>
</Portal>

// Render to element reference
<Portal to={containerElement}>
    <Tooltip />
</Portal>

// Conditional portal
<Portal disabled={inline}>
    <Dropdown />
</Portal>

isLazyComponent#

Check if a component is lazy-loaded.

TSX
function isLazyComponent(component: any): component is LazyComponentFactory;

Examples#

TSX
import { lazy, isLazyComponent } from 'sigx';

const LazyComp = lazy(() => import('./Comp'));
const RegularComp = component(() => () => <div />);

isLazyComponent(LazyComp);    // true
isLazyComponent(RegularComp); // false

getCurrentInstance#

Get the current component's setup context.

TSX
function getCurrentInstance(): ComponentSetupContext | null;

Returns#

The current component context, or null if not in a component.

Examples#

TSX
import { getCurrentInstance } from 'sigx';

function useCustomHook() {
    const ctx = getCurrentInstance();
    if (!ctx) {
        throw new Error('Must be called in component setup');
    }
    
    ctx.onMounted(() => {
        console.log('Component mounted');
    });
}

Data Loading#

useData#

The keyed async read. Every read has a key — the reactive trigger, the cache / SSR identity, and the fetcher's input. A static string key is SSR-transferable (runs on the server, serialized under the key into window.__SIGX_ASYNC__, restored on hydration, deduped per key). A reactive getter returning a string or tuple re-runs on change; a falsy result holds the read in idle. There is no unkeyed form.

TSX
// Static key: SSR + hydration state transfer.
function useData<T>(
    key: string,
    fetcher: (arg: string, ctx: { signal: AbortSignal }) => Promise<T>,
    opts?: AsyncOptions,
): AsyncState<T>;
// Reactive key (string or tuple); falsy ⇒ 'idle'.
function useData<T, const K extends KeyValue>(
    key: () => K | Falsy,
    fetcher: (arg: K, ctx: { signal: AbortSignal }) => Promise<T>,
    opts?: AsyncOptions,
): AsyncState<T>;

interface AsyncOptions {
    server?: boolean; // default true; false ⇒ client-only keyed read
}

Render every state with match(); refresh() re-runs in place (stale-while-revalidate) and never rejects. See the Data Loading guide.

useAction#

The async write — the manual counterpart to useData, triggered by .run(input). run never rejects; it resolves a settled RunResult<T>. In-flight writes are never aborted.

TSX
function useAction<T, In = void>(
    fn: (input: In, ctx: { signal: AbortSignal }) => Promise<T>,
    opts?: ActionOptions,
): AsyncAction<T, In>;

all#

Combine several AsyncStates into one all-or-nothing state — a single match() for a whole view. A pure derived view; no fetching.

TSX
// Object form (named value/errors records).
function all<S extends Record<string, AsyncState<unknown>>>(sources: S): AllState<…>;
// Rest-tuple form (positional).
function all<S extends readonly AsyncState<unknown>[]>(...sources: S): AllState<…>;

AllState<T, E> extends AsyncState<T> with an errors collect-all counterpart to first-error-wins .error.

useStream#

Accumulate a streamed AsyncIterable<string> into a reactive string — progressive text (LLM-token-style), SSR-aware via the key.

TSX
function useStream(
    key: string,
    source: () => AsyncIterable<string>
): { readonly value: string };

Helper Functions#

mergeProps#

Combine several prop sources into one object, without the key collisions a JSX spread produces.

TSX
type MergeSource =
    | Record<string, any>
    | (() => Record<string, any>)
    | null
    | undefined;

function mergeProps(...sources: MergeSource[]): Record<string, any>;
KeyHow it combines
class / classNameConcatenated in source order, emitted as class
styleMerged into an object left-to-right; string sources are parsed
on* handlersChained in source order, grouped by resolved event name
refChained into one ref feeding every source's ref
everything elseExact spread semantics — last source with the key wins, including an explicit undefined

Sources may be objects or zero-argument thunks; the result resolves on read, so thunk sources stay reactive. Call it once in setup — the derived ref and chained handlers are identity-cached, and a per-render call discards that cache.

TSX
const merged = mergeProps(
    () => { const { variant: _v, ...rest } = ctx.props; return rest; },
    () => ({ class: 'btn', onClick: onActivate })
);
return () => <button {...merged}>{ctx.slots.default?.()}</button>;

See Combining Prop Sources for the guide.

supportsMoveBefore#

Check if browser supports state-preserving DOM moves.

TSX
function supportsMoveBefore(): boolean;

moveNode#

Move a DOM node with state preservation when available.

TSX
function moveNode(
    parent: Element,
    node: Node,
    anchor?: Node | null
): void;

Types#

ComponentFactory#

TSX
type ComponentFactory<TCombined, TRef, TSlots> = 
    ((props: ComponentProps) => JSXElement) & {
        __setup: SetupFn;
        __name?: string;
        __props: TCombined;
        __ref: TRef;
        __slots: TSlots;
    };

LazyComponentFactory#

TSX
type LazyComponentFactory<T> = T & {
    preload: () => Promise<T>;
    isLoaded: () => boolean;
    __lazy: true;
};

DeferProps#

TSX
type DeferProps =
    & Define.Prop<'fallback', JSXElement | (() => JSXElement)>
    & Define.Slot<'default'>;

PortalProps#

TSX
type PortalProps = 
    & Define.Prop<'to', string | Element>
    & Define.Prop<'disabled', boolean>;

ViewFn#

TSX
type ViewFn = () => JSXElement | JSXElement[] | undefined;

ComponentOptions#

TSX
interface ComponentOptions {
    name?: string;
}

AsyncState#

AsyncState<T> is a discriminated union, so a state check narrows value with no cast:

TSX
type AsyncState<T> =
    | AsyncIdle<T> | AsyncPending<T> | AsyncReady<T> | AsyncRefreshing<T> | AsyncErrored<T>;

// Every member carries these, narrowed per state:
//   state    'idle' | 'pending' | 'ready' | 'refreshing' | 'errored'
//   value    the best data the cell has — see below
//   hasValue presence — a resolved null IS a value
//   error    Error | null
//   loading  state === 'pending' ONLY (a refresh reads state === 'refreshing')

interface AsyncStateBase<T> {          // the shared methods
    match<R>(arms: MatchArms<T, R>): R | undefined;
    refresh(): Promise<void>;          // re-run in place; NEVER rejects (failures land on .error)
}

interface AsyncReady<T> extends AsyncStateBase<T> {
    readonly state: 'ready';
    readonly value: T;                 // narrowed — no cast
    readonly hasValue: true;
    readonly error: null;
    readonly loading: false;
}
TSX
if (q.state === 'ready') q.value   // T — narrowed
if (q.hasValue) q.value            // T — narrowed

value is the best data the cell has, under one rule: kept across a same-key refresh() and across a failed fetch, cleared on key change. So an errored cell that once succeeded still holds its last-good value, and 'errored' genuinely splits on hasValue.

value is not a presence signal: a fetch that legitimately resolves null has a value, and the cell stays ready. Read presence from state or from hasValue, which is present on useData cells, @sigx/cache cells, all() and SSR-side state. AsyncStateName is exported.

Two patterns break under the union. q.error && !q.value no longer means "errored" — an errored cell that once had data still holds it, so content beside an error banner stays on screen instead of blanking. That is the intended SWR posture; to blank deliberately, check q.state === 'errored'. And interface X extends AsyncState<T> no longer compiles — intersect instead: type X<T> = AsyncState<T> & { … }.

MatchArms#

TSX
interface MatchArms<T, R> {
    idle?: () => R;                                    // defaults to `pending`
    pending?: () => R;                                 // omitted ⇒ renders nothing
    error?: (e: Error, ctx: ErrorArmContext<T>) => R;  // omitted ⇒ bubbles to errorScope / app onError
    ready: (v: T) => R;                                // required — the only route to a present T
}

type ErrorArmContext<T> =
    & { retry: () => void }
    & ({ value: T; hasValue: true } | { value: null; hasValue: false });

The error arm's second parameter is a context object, not positional arguments: error: (e, { retry }) => …. The last-good value is ctx.value, guarded by ctx.hasValue — so a legitimately-null last-good finally reads as present. match renders the error arm on every 'errored' state; it never falls back to ready.

AsyncAction#

TSX
interface AsyncAction<T, In> {
    readonly state: 'idle' | 'pending' | 'ready' | 'errored';
    readonly value: T | null;      // last successful result
    readonly error: Error | null;
    readonly loading: boolean;      // state === 'pending' — the double-submit guard
    match<R>(arms: MatchArms<T, R>): R | undefined;
    run(input: In): Promise<RunResult<T>>; // never rejects; in-flight runs never aborted
    reset(): void;                  // back to 'idle'; clears value/error
}

type RunResult<T> = { ok: true; value: T } | { ok: false; error: Error };

A superseded run resolves { ok: false, error: SupersededError } and never writes state.