Skip to content

Components

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().