Components
Components encapsulate state, logic, and UI into reusable units.
Defining a Component
Section titled “Defining a Component”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.
Using Components
Section titled “Using Components”Counter({ initial: 10 })
mount(Counter({ initial: 0 }), document.getElementById("app")!);Composition
Section titled “Composition”const App = component(() => { return div( header(h1("My App")), main( Counter({ initial: 0 }), Counter({ initial: 100 }), ), );});Props and reactivity
Section titled “Props and reactivity”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.
Wrong vs. right
Section titled “Wrong vs. right”Given a shared signal in a store file:
export const count = signal(0);// ❌ Wrong — reads count.value once at the call site;// Display shows the initial number foreverimport { 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 changeimport { 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.
When static props are fine
Section titled “When static props are fine”Plain (non-getter) props are the right choice when the value will never change for the life of this component instance:
- Initial values —
Counter({ initial: 10 }). The child seeds a signal from this once. - Stable callbacks —
Button({ onclick: handleClick })whenhandleClickis 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. - Identifiers —
User({ id: "u_123" }),Form({ name: "signup" }). - Type / variant flags chosen at construction time —
Card({ 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.
Why it works this way
Section titled “Why it works this way”- 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. onMountfires once, after the component is attached. There is no per-update hook because the setup body is the only “render.”
Context (provide/inject)
Section titled “Context (provide/inject)”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}`);});Error Boundaries
Section titled “Error Boundaries”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().
Is this a component or just a function?
Section titled “Is this a component or just a function?”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.
Decision table
Section titled “Decision table”| Is the sub-renderer… | Reach for | Example |
|---|---|---|
| Pure markup with props, no state, used locally | Plain factory | const FilterBtn = (value: string, label: string) => button({ onclick: () => filter.value = value }, label) |
Has own local signal() / effect() / refs | component() | component(() => { const open = signal(false); return details({ open: () => open.value }, ...) }) |
Needs onMount / onCleanup | component() | Anything subscribing to an external source, timer, observer |
Reads inject() from context | component() | 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 lines | Plain factory | Filter buttons, footer rows, trivial sub-views |
Worked examples
Section titled “Worked examples”Plain factory — stateless, structural, no lifecycle:
// components/Footer.ts — four filter buttons, each 3 lines of structureconst 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 fileimport 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.
Next Steps
Section titled “Next Steps”Docs current to v0.1.0-alpha.9 . All releases →