Skip to content

Components encapsulate state, logic, and UI into reusable units.

import { signal, component, div, button, span } from "@whisq/core";
const Counter = component((props: { initial?: number }) => {
const count = signal(props.initial ?? 0);
return div({ class: "counter" },
button({ onclick: () => count.value-- }, "-"),
span(() => ` ${count.value} `),
button({ onclick: () => count.value++ }, "+"),
);
});

The setup function runs once. It receives props and returns a WhisqNode.

Counter({ initial: 10 })
mount(Counter({ initial: 0 }), document.getElementById("app")!);
const App = component(() => {
return div(
header(h1("My App")),
main(
Counter({ initial: 0 }),
Counter({ initial: 100 }),
),
);
});

Setup runs once per component instance. Whatever props the component receives at that moment are captured for the rest of its lifetime — they are not reactive by default.

If you come from React, Vue, or Svelte, this is the most important rule to internalise: passing a plain signal.value from a parent does not make the child re-read it later.

Given a shared signal in a store file:

stores/counter.ts
export const count = signal(0);
// ❌ Wrong — reads count.value once at the call site;
// Display shows the initial number forever
import { count } from "./stores/counter";
const Display = component((props: { value: number }) => {
return p(() => `Count: ${props.value}`); // never updates
});
Display({ value: count.value });
// ✅ Right — pass a getter; the child re-reads on every change
import { count } from "./stores/counter";
const Display = component((props: { value: () => number }) => {
return p(() => `Count: ${props.value()}`); // updates on every change
});
Display({ value: () => count.value });

The rule of thumb:

If the value can change after setup, pass a getter (() => signal.value) and call it in the child.

Plain (non-getter) props are the right choice when the value will never change for the life of this component instance:

  • Initial valuesCounter({ initial: 10 }). The child seeds a signal from this once.
  • Stable callbacksButton({ onclick: handleClick }) when handleClick is defined at module scope or in an outer closure that the parent never needs to swap. If the parent ever needs to change which function the child uses (e.g. permission-gated handlers), pass a getter.
  • IdentifiersUser({ id: "u_123" }), Form({ name: "signup" }).
  • Type / variant flags chosen at construction timeCard({ variant: "compact" }) when the variant is decided when the parent renders the child and never reassigned.

When in doubt: if a parent might want to change the value later and have the child reflect it, use a getter. If the value is set at instantiation and forgotten, pass it plain.

  • The setup function runs once and never re-runs — there is no “render lifecycle” to re-feed props into.
  • Reactivity flows through signals, computeds, and effects you create in setup. Wrapping a prop access in a getter (() => props.value) makes that read participate in the same reactive graph as everything else.
  • onMount fires once, after the component is attached. There is no per-update hook because the setup body is the only “render.”

Pass data deeply without prop drilling:

import { createContext, provide, inject, component, div, p } from "@whisq/core";
const ThemeCtx = createContext("light");
const Parent = component(() => {
provide(ThemeCtx, "dark");
return div(Child({}));
});
const Child = component(() => {
const theme = inject(ThemeCtx); // "dark"
return p(`Theme: ${theme}`);
});

Catch errors in child rendering:

import { errorBoundary, div, p, button } from "@whisq/core";
errorBoundary(
(error, retry) => div(
p(`Error: ${error.message}`),
button({ onclick: retry }, "Retry"),
),
() => RiskyComponent({}),
)

See Advanced → Error Boundaries for what the primitive catches, granular wrapping, error reporting, and how it composes with resource().

Once a file grows past a single view, you hit a micro-decision several times per file: “this tiny sub-renderer — do I wrap it in component(() => ...), or just write a plain function that returns element markup?” The rule:

Use component() when the thing has setup logic, lifecycle hooks, context reads, or is a named reusable boundary. Otherwise write a plain factory — a function that returns element markup.

Is the sub-renderer…Reach forExample
Pure markup with props, no state, used locallyPlain factoryconst FilterBtn = (value: string, label: string) => button({ onclick: () => filter.value = value }, label)
Has own local signal() / effect() / refscomponent()component(() => { const open = signal(false); return details({ open: () => open.value }, ...) })
Needs onMount / onCleanupcomponent()Anything subscribing to an external source, timer, observer
Reads inject() from contextcomponent()Components that consume a ThemeCtx / AuthCtx
Reused across files (PascalCase export)component()The canonical “one component per file” case
Reused on the same page only, ≤ 10 linesPlain factoryFilter buttons, footer rows, trivial sub-views

Plain factory — stateless, structural, no lifecycle:

// components/Footer.ts — four filter buttons, each 3 lines of structure
const FilterBtn = (value: "all" | "active" | "done", label: string) =>
button(
{ class: [s.filterBtn, () => filter.value === value && s.filterActive],
onclick: () => (filter.value = value) },
label,
);
export const Footer = component(() =>
footer(FilterBtn("all", "All"), FilterBtn("active", "Active"), FilterBtn("done", "Done")),
);

No component() wrapper on FilterBtn — it’s structured markup with props, no setup. Wrapping would add noise and a fragment boundary you don’t need.

Promoted to component() — same sub-renderer grows local state:

// Now FilterBtn tracks hover state locally — promote to component()
const FilterBtn = component((props: { value: "all" | "active" | "done"; label: string }) => {
const hover = signal(false);
return button(
{
class: [
s.filterBtn,
() => filter.value === props.value && s.filterActive,
() => hover.value && s.filterHover,
],
onmouseenter: () => (hover.value = true),
onmouseleave: () => (hover.value = false),
onclick: () => (filter.value = props.value),
},
props.label,
);
});

The moment hover enters the picture, the factory form can’t express it — a plain function runs once per call, so a signal() inside would reset on every render. component() gives each call its own setup scope. This is the promotion rule: add signal() → promote to component().

Cross-file named component — the top-of-file-split shape:

// components/TodoItem.ts — reused from multiple parents, deserves its own file
import type { ItemAccessor } from "@whisq/core";
type Props = { todo: ItemAccessor<Todo>; onRemove: (id: string) => void };
export const TodoItem = component((props: Props) =>
li(
input({ type: "checkbox", ...bindField(todos, props.todo, "done", { as: "checkbox" }) }),
() => props.todo.value.text,
button({ onclick: () => props.onRemove(props.todo.value.id) }, "×"),
),
);

When a reviewer asks “is this a component?” the answer is: yes if it has setup / lifecycle / context / reuse-across-files; no otherwise. A plain factory is a first-class citizen — don’t reach for component() reflexively.

See also: Choosing Patterns → List rendering for the split-to-own-file decision at the file level, and Nested Item Editing guide for the per-row decision.

Docs current to v0.1.0-alpha.9 . All releases →