# Whisq Documentation β€” Full Text > Every page on whisq.dev, concatenated for AI models that prefer a single fetch over crawling. Regenerated on every build. Canonical site: https://whisq.dev/. Machine-readable exports list: https://unpkg.com/@whisq/core@latest/dist/public-api.json. Pages: 86 --- # Custom Elements URL: https://whisq.dev/advanced/custom-elements/ --- Most of the time, you'll use named element functions like `div()`, `button()`, and `input()`. But when you need a tag that isn't in the standard set β€” a web component, an SVG element, or a custom tag β€” use `h()`. ## The h() Function `h()` creates any HTML element by tag name: ```ts // Standard element (same as using div()) h("div", { class: "card" }, "Hello"); // Web component h("my-dropdown", { value: "option1" }, h("my-option", { value: "option1" }, "Option 1"), h("my-option", { value: "option2" }, "Option 2"), ); // Custom element h("app-header", { class: "main-header" }, h("app-logo"), h("app-nav"), ); ``` ## Web Components Whisq works with any web component registered via `customElements.define()`: ```ts // Assume a web component is registered const selectedColor = signal("#4f46e5"); mount( h("color-picker", { value: () => selectedColor.value, onchange: (e) => selectedColor.value = e.target.value, }), document.getElementById("app")!, ); ``` Reactive props and event handlers work the same way as with built-in elements. ## SVG Elements Use `h()` for SVG elements that aren't covered by the standard element functions: ```ts const icon = h("svg", { viewBox: "0 0 24 24", width: "24", height: "24" }, h("circle", { cx: "12", cy: "12", r: "10", fill: "none", stroke: "currentColor" }), h("path", { d: "M12 6v6l4 2", stroke: "currentColor", fill: "none" }), ); ``` ## Wrapping Third-Party Components Create a typed wrapper around a web component for better ergonomics: ```ts interface DatePickerProps { value?: () => string; onchange?: (e: Event) => void; min?: string; max?: string; } function DatePicker(props: DatePickerProps, ...children: any[]) { return h("date-picker", props, ...children); } // Now use it like a native Whisq element DatePicker({ value: () => selectedDate.value, onchange: (e) => selectedDate.value = e.target.value, min: "2024-01-01", }); ``` :::tip[🐱 Whisker Tip] Use `h()` as an escape hatch. For elements you use frequently, create a wrapper function with typed props β€” you get the same DX as built-in elements. ::: ## When to Use h() vs Named Functions | Use case | Approach | |---|---| | Standard HTML (`div`, `button`, `input`) | Named functions β€” `div()`, `button()` | | Web components (`my-component`) | `h("my-component", ...)` | | SVG elements (`circle`, `path`) | `h("circle", ...)` | | Dynamic tag names | `h(tagName, ...)` | ## Next Steps - [Elements](https://whisq.dev/core-concepts/elements/) β€” Built-in element functions - [Components](https://whisq.dev/core-concepts/components/) β€” Whisq's component model - [Performance](https://whisq.dev/advanced/performance/) β€” Optimizing custom element usage --- # Error Boundaries URL: https://whisq.dev/advanced/error-boundaries/ --- When something inside a Whisq component tree throws β€” a render-time error, a signal-driven recomputation, an event handler, an effect, or even a child component's setup β€” you usually want to show fallback UI instead of crashing the whole page. Whisq ships a first-class primitive for exactly this: `errorBoundary()`. ## The `errorBoundary()` primitive Wrap any subtree in `errorBoundary(fallback, child)`: ```ts errorBoundary( (error, retry) => div({ class: "error-boundary" }, p(`Something went wrong: ${error.message}`), button({ onclick: retry }, "Try again"), ), () => RiskyComponent({}), ); ``` - **`fallback`** β€” `(error, retry) => Node`. Called when the wrapped subtree throws. Receives the caught error and a `retry` function that re-runs the child. - **`child`** β€” `() => Node`. The protected subtree. The thunk lets `errorBoundary` re-invoke it on retry. **Why use the primitive instead of a hand-rolled `try`/`catch` in a component?** A `try`/`catch` inside a component setup function only sees errors thrown synchronously while that setup runs. Anything that happens later β€” a child component's setup, a re-render after a signal changes, an event handler, an async callback β€” runs outside that `try` block and is not caught. `errorBoundary()` is integrated with Whisq's render machinery and is scoped to the wrapped subtree, so it sees errors thrown anywhere in that subtree's rendering. **Use the primitive β€” don't roll your own.** For asynchronous failures (network, timers, deferred promises), see [Async data](#async-data) below β€” `resource()` is the right tool, not `errorBoundary()`. ## Granular boundaries Wrap individual sections rather than the entire app β€” if one widget fails, the rest of the page keeps working: ```ts const Dashboard = component(() => div( h1("Dashboard"), errorBoundary( (err) => p("Stats unavailable"), () => StatsWidget({}), ), errorBoundary( (err) => p("Chart failed to load"), () => ChartWidget({}), ), errorBoundary( (err) => p("Table unavailable"), () => TableWidget({}), ), ) ); ``` If the chart throws, the stats and table still render normally. ## Error reporting Silently swallowing errors hides bugs. Always log them β€” either in the fallback callback or via a small reporter helper: ```ts const reportError = (error: Error, where: string) => { console.error(`[${where}]`, error); // fetch("/api/errors", { method: "POST", body: JSON.stringify({ where, message: error.message }) }); }; errorBoundary( (error, retry) => { reportError(error, "ChartWidget"); return div({ class: "error" }, p("This section failed to load."), button({ onclick: retry }, "Retry"), ); }, () => ChartWidget({}), ); ``` ## Async data For network and other async failures, you don't need a separate error boundary at all. `resource()` already exposes loading and error state as signals: ```ts const data = resource(() => fetch("/api/data").then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.text(); }), ); div( when(() => data.loading(), () => p("Loading…")), when(() => !!data.error(), () => div( p(() => `Error: ${data.error()!.message}`), button({ onclick: () => data.refetch() }, "Retry"), ), ), when(() => !!data.data(), () => p(() => data.data()!)), ); ``` Reach for `errorBoundary()` around a subtree that uses `resource()` only when you also want to catch unexpected render-time errors in that subtree β€” `resource()` handles the documented async failure path, `errorBoundary()` is the safety net for everything else. ## Best practices 1. **Wrap at section boundaries, not every component.** Logical units (widgets, panels, routes) are the right granularity. 2. **Always provide actionable fallback UI** β€” explain what failed and offer a retry button when it makes sense. 3. **Always log or report** β€” `console.error` at minimum; a real reporting service in production. 4. **Use `resource()` for async failures**; reserve `errorBoundary()` for unexpected throws. ## Next steps - [Components β€” Error Boundaries](https://whisq.dev/core-concepts/components/#error-boundaries) β€” short reference for the primitive next to component composition basics. - [Data Fetching](https://whisq.dev/guides/data-fetching/) β€” `resource()` with built-in error handling. - [Performance](https://whisq.dev/advanced/performance/) β€” Optimizing component rendering. - [Testing](https://whisq.dev/guides/testing/) β€” Testing error states. --- # Performance URL: https://whisq.dev/advanced/performance/ --- Whisq's signal-based reactivity is efficient by default β€” only the DOM nodes that read a signal update when it changes. But in complex applications, a few patterns can make a significant difference. ## Fine-Grained Signals Prefer many small signals over one big object: ```ts // ❌ Less efficient β€” entire UI re-checks when any field changes const user = signal({ name: "Alice", age: 30, email: "alice@example.com" }); // βœ… More efficient β€” only name UI updates when name changes const name = signal("Alice"); const age = signal(30); const email = signal("alice@example.com"); ``` With fine-grained signals, changing `name.value` only updates DOM nodes that read `name.value`. With a single object signal, every node that reads any property re-evaluates. :::tip[🐱 Whisker Tip] Signals are cheap. Creating 50 small signals is better than 1 large object signal with 50 properties. ::: ## Batch Updates When updating multiple signals at once, wrap them in `batch()`: ```ts const x = signal(0); const y = signal(0); const z = signal(0); // ❌ Three separate UI updates x.value = 1; y.value = 2; z.value = 3; // βœ… One UI update batch(() => { x.value = 1; y.value = 2; z.value = 3; }); ``` `batch()` defers all reactive updates until the function completes, then flushes them once. ## Use peek() in Effects When an effect needs to read a signal without subscribing to it, use `peek()`: ```ts const count = signal(0); const log = signal([]); // ❌ Infinite loop β€” effect reads log.value, which triggers re-run effect(() => { log.value = [...log.value, `Count changed to ${count.value}`]; }); // βœ… peek() reads without creating a dependency effect(() => { const current = log.peek(); log.value = [...current, `Count changed to ${count.value}`]; }); ``` `peek()` reads the signal's current value without tracking it as a dependency. The effect above re-runs when `count` changes but not when `log` changes. :::danger[Common Mistake] ```ts // ❌ WRONG β€” creates dependency, may cause infinite loop effect(() => { items.value = [...items.value, newItem]; }); // βœ… RIGHT β€” peek() avoids the dependency effect(() => { items.value = [...items.peek(), newItem]; }); ``` ::: ## Avoid Unnecessary Computed Values `computed()` caches its result and only re-evaluates when dependencies change. But avoid creating computed values for trivial derivations: ```ts // ❌ Overkill β€” computed overhead for a simple check const isEmpty = computed(() => items.value.length === 0); // βœ… Just inline it when(() => items.value.length === 0, () => p("No items")); ``` Use `computed()` when: - The derivation is expensive (filtering, sorting, mapping large arrays) - Multiple parts of the UI read the same derived value - You want to name the value for readability ## Lazy Component Loading Split large apps by loading components on demand: ```ts const Dashboard = component(() => { const loaded = signal<(() => Node) | null>(null); // Load the heavy component on demand const loadChart = async () => { const { ChartWidget } = await import("./ChartWidget"); loaded.value = () => ChartWidget({}); }; return div( button({ onclick: loadChart }, "Load Chart"), when(() => !!loaded.value, () => loaded.value!()), ); }); ``` ## Profiling with DevTools Use `@whisq/devtools` to attach a runtime hook that browser extensions or console scripts can read. Apps opt in explicitly β€” the hook is a passive surface, not an auto-instrumenter: ```ts if (import.meta.env.DEV) connectDevTools(); ``` The installed hook exposes, via `globalThis.__WHISQ_DEVTOOLS__`: - **`registerSignal(name, signal)` / `getSignals()`** β€” a ledger of named signals and their current values (read via `.peek()`, so inspection doesn't subscribe). - **`registerComponent(name)` / `getComponents()`** β€” a list of active component names. - **`logEvent(type, data)` / `getEvents()` / `clearEvents()`** β€” an append-only event log with timestamps. Full API: [`/api/devtools/`](https://whisq.dev/api/devtools/). ## Performance Checklist | Pattern | Impact | When to use | |---|---|---| | Fine-grained signals | High | Always β€” prefer small signals | | batch() | Medium | When updating 2+ signals together | | peek() | Medium | When reading a signal in an effect without tracking | | Lazy loading | High | For large components not needed at startup | | computed() caching | Medium | For expensive derivations read in multiple places | ## Next Steps - [Signals](https://whisq.dev/core-concepts/signals/) β€” How reactivity works - [Lifecycle](https://whisq.dev/core-concepts/lifecycle/) β€” onMount, onCleanup, effect - [Sandbox](https://whisq.dev/advanced/sandbox/) β€” Isolated execution for untrusted code --- # Portals URL: https://whisq.dev/advanced/portals/ --- A portal renders a component's output into a DOM node that lives outside the parent component's tree. This is useful for modals, tooltips, dropdown menus, and any UI that needs to visually escape its parent's layout. ## How Portals Work in Whisq Since Whisq elements are real DOM nodes (not virtual), creating a portal is straightforward β€” mount your component into any DOM element: ```ts const Modal = component((props: { onClose: () => void }) => div({ class: "modal-overlay", onclick: props.onClose }, div({ class: "modal-content", onclick: (e) => e.stopPropagation(), }, p("This is a modal!"), button({ onclick: props.onClose }, "Close"), ), ) ); const App = component(() => { const showModal = signal(false); let cleanup: (() => void) | null = null; const openModal = () => { showModal.value = true; // Mount modal into a separate DOM node const container = document.getElementById("modal-root")!; cleanup = mount( Modal({ onClose: closeModal }), container, ); }; const closeModal = () => { showModal.value = false; cleanup?.(); cleanup = null; }; return div( button({ onclick: openModal }, "Open Modal"), ); }); ``` Your HTML needs a separate mount point: ```html
``` ## A Reusable Portal Helper Wrap the pattern into a helper function: ```ts function portal(node: Node, target: string | HTMLElement) { const container = typeof target === "string" ? document.querySelector(target)! : target; const dispose = mount(node, container); onCleanup(dispose); return node; } ``` Use it inside components: ```ts const App = component(() => { const open = signal(false); return div( button({ onclick: () => open.value = true }, "Show Tooltip"), when(() => open.value, () => portal( div({ class: "tooltip" }, p("I'm rendered in #tooltip-root")), "#tooltip-root", ) ), ); }); ``` ## Use Cases **Modals and dialogs** β€” render above everything else, outside the app's CSS stacking context: ```ts portal( div({ class: "dialog-overlay" }, div({ class: "dialog" }, /* content */), ), "#modal-root", ); ``` **Tooltips and popovers** β€” position relative to a trigger but render in a top-level container to avoid overflow clipping: ```ts portal( div({ class: "tooltip", style: () => `top: ${y.value}px; left: ${x.value}px`, }, "Tooltip text"), document.body, ); ``` **Notification toasts** β€” render into a fixed notification container: ```ts portal( div({ class: "toast" }, "Saved successfully!"), "#toast-container", ); ``` :::tip[🐱 Whisker Tip] Because Whisq works with real DOM nodes, portals are just `mount()` calls. There's no special API β€” you're mounting a component subtree into a different DOM element. ::: ## Cleanup Always clean up portals when the parent unmounts. The `portal()` helper above uses `onCleanup` to handle this automatically. If you're using `mount()` directly, store the dispose function and call it when done. :::danger[Common Mistake] ```ts // ❌ WRONG β€” portal is never cleaned up mount(Modal({}), document.getElementById("modal-root")); // βœ… RIGHT β€” store dispose and clean up const dispose = mount(Modal({}), document.getElementById("modal-root")); onCleanup(dispose); ``` ::: ## Next Steps - [Components](https://whisq.dev/core-concepts/components/) β€” Component lifecycle - [Lifecycle](https://whisq.dev/core-concepts/lifecycle/) β€” onMount and onCleanup - [Error Boundaries](https://whisq.dev/advanced/error-boundaries/) β€” Handling errors in component trees --- # Sandbox URL: https://whisq.dev/advanced/sandbox/ --- `@whisq/sandbox` provides a secure execution environment for running untrusted code. It's designed for interactive playgrounds, code editors, and educational tools where user-submitted code needs to run safely. ## Setup ```bash npm install @whisq/sandbox ``` ## Basic Usage ```ts const sandbox = createSandbox(); const result = await sandbox.execute(` const x = 2 + 2; x; `); console.log(result); // { success: true, value: 4 } sandbox.dispose(); ``` ## Error Handling When code throws an error, the sandbox catches it: ```ts const result = await sandbox.execute(` throw new Error("something broke"); `); console.log(result); // { success: false, error: "Error: something broke" } ``` Your application doesn't crash β€” the error is captured and returned. ## Timeouts Set a maximum execution time to prevent infinite loops: ```ts const sandbox = createSandbox({ timeout: 3000 }); // 3 seconds const result = await sandbox.execute(` while (true) {} // infinite loop `); console.log(result); // { success: false, error: "Execution timed out" } ``` The default timeout is 5,000ms (5 seconds). ## Blocked APIs The sandbox blocks access to browser APIs that could be dangerous: - `document`, `window`, `globalThis` - `fetch`, `XMLHttpRequest`, `WebSocket` - `localStorage`, `sessionStorage`, `indexedDB` - `navigator`, `location`, `history` - `alert`, `confirm`, `prompt` Attempting to access any of these throws an error: ```ts const result = await sandbox.execute(` fetch("/api/data"); `); // { success: false, error: "fetch is not defined" } ``` ## Communication Send messages between the sandbox and your application: ```ts const sandbox = createSandbox(); // Listen for messages from sandboxed code sandbox.onMessage((msg) => { console.log("From sandbox:", msg); }); await sandbox.execute(` postMessage({ type: "result", value: 42 }); `); ``` ## Building a Code Playground Combine the sandbox with Whisq UI for an interactive playground: ```ts const Playground = component(() => { const code = signal('const x = 2 + 2;\nx;'); const output = signal(""); const sandbox = createSandbox({ timeout: 3000 }); const run = async () => { const result = await sandbox.execute(code.value); output.value = result.success ? String(result.value) : `Error: ${result.error}`; }; return div({ class: "playground" }, textarea({ value: () => code.value, oninput: (e) => code.value = e.target.value, rows: "8", }), button({ onclick: run }, "Run"), pre({ class: "output" }, () => output.value), ); }); mount(Playground({}), document.getElementById("app")!); ``` ## Cleanup Always dispose the sandbox when you're done: ```ts const sandbox = createSandbox(); // ... use the sandbox ... sandbox.dispose(); // clean up resources ``` In a Whisq component, use `onCleanup`: ```ts const PlaygroundComponent = component(() => { let sandbox: Sandbox; onMount(() => { sandbox = createSandbox(); onCleanup(() => sandbox.dispose()); }); // ... use sandbox in event handlers }); ``` ## API Reference ### createSandbox(options?) | Option | Type | Default | Description | |---|---|---|---| | `timeout` | number | 5000 | Maximum execution time in ms | ### sandbox.execute(code) Returns `Promise`: ```ts interface ExecutionResult { success: boolean; value?: unknown; // result of the last expression error?: string; // error message if failed } ``` ### sandbox.onMessage(handler) Listen for `postMessage()` calls from sandboxed code. ### sandbox.dispose() Clean up sandbox resources. ## Next Steps - [Performance](https://whisq.dev/advanced/performance/) β€” Optimizing Whisq applications - [Testing](https://whisq.dev/guides/testing/) β€” Testing components that use sandboxes - [Error Boundaries](https://whisq.dev/advanced/error-boundaries/) β€” Handling errors in UI --- # Claude Code URL: https://whisq.dev/ai/claude-code/ --- Claude Code works with Whisq out of the box. Every Whisq project includes a `CLAUDE.md` file that gives Claude the complete API reference in under 5,000 tokens. ## Setup When you create a new Whisq project, `CLAUDE.md` is included automatically: ```bash npm create whisq@latest my-app cd my-app # CLAUDE.md is already at the project root ``` Claude Code reads `CLAUDE.md` when it opens your project β€” no extra configuration needed. ## What CLAUDE.md Contains The file provides Claude with: - **Complete API reference** β€” every export from `@whisq/core` - **Usage patterns** β€” signal creation, element functions, component structure - **Anti-patterns** β€” what NOT to generate (e.g., `count.value` as a child instead of `() => count.value`) - **Project conventions** β€” file structure, naming, git workflow This is enough for Claude to generate correct Whisq components on the first attempt. ## Example Workflow Open your Whisq project in Claude Code and describe what you need: ``` Create a todo list component with: - An input field to add new items - A list of todos with checkboxes to mark as done - A count of remaining items - A "Clear completed" button ``` Claude will generate working Whisq code using `signal()`, `computed()`, `each()`, and proper reactive patterns β€” because it has the full API in context. ## Tips for Better Results 1. **Name specific APIs** β€” "use `resource()` to fetch data" is better than "load some data from the server." 2. **Describe the component structure** β€” "a form with email and password fields, a submit button that's disabled until valid" gives Claude clear requirements. 3. **Reference the store pattern** β€” "create a store in `stores/cart.ts` with exported signals and functions" and Claude will follow the convention. 4. **Point out mistakes** β€” if Claude generates `span(count.value)`, tell it to use `span(() => count.value)`. It will apply the pattern consistently for the rest of the conversation. ## Using the MCP Server For deeper integration, install the `@whisq/mcp-server`: ```bash npm install -D @whisq/mcp-server ``` This gives Claude Code access to two tools: - **scaffold_component** β€” generate components from descriptions - **validate_code** β€” check code for common Whisq anti-patterns See [MCP Server](https://whisq.dev/ai/mcp-server/) for setup details. ## Next Steps - [MCP Server](https://whisq.dev/ai/mcp-server/) β€” Advanced AI tooling - [LLM Reference](https://whisq.dev/ai/llm-reference/) β€” The complete API in paste-ready format - [AI Integration Guide](https://whisq.dev/guides/ai-integration/) β€” Prompt patterns and tips --- # GitHub Copilot URL: https://whisq.dev/ai/copilot/ --- GitHub Copilot works with Whisq through pattern recognition. The more Whisq code in your project, the better Copilot's suggestions become. ## Getting Started Copilot doesn't read `.cursorrules` or `CLAUDE.md` the same way dedicated AI editors do. Instead, it learns from: - **Open files** β€” keep a few Whisq components open as reference - **Existing code in the project** β€” Copilot picks up patterns from your codebase - **Comments** β€” describe what you want above where you're writing ## Writing Effective Comments Guide Copilot with descriptive comments: ```ts // Whisq component: email signup form // - email input bound to signal // - validation: must contain @ // - submit button disabled when invalid // - show success message after submission const EmailSignup = component(() => { // Copilot will generate the implementation here ``` Copilot sees the imports, the comment, and the `component()` call β€” it has enough context to generate correct Whisq code. ## Patterns Copilot Handles Well **Signal creation and reactive text:** ```ts const name = signal(""); // Copilot correctly suggests: span(() => `Hello, ${name.value}`); ``` **Event handlers:** ```ts input({ // After typing oninput, Copilot suggests the full handler: oninput: (e) => name.value = e.target.value, }); ``` **Conditional rendering:** ```ts // After typing when(, Copilot picks up the pattern: when(() => isLoading.value, () => p("Loading...")); ``` ## Patterns That Need Attention Copilot occasionally generates React-style code in Whisq projects. Watch for these: :::danger[Common Mistake] ```ts // ❌ Copilot might generate JSX syntax return
{count}
; // βœ… Correct Whisq return div(() => count.value); ``` ::: :::danger[Common Mistake] ```ts // ❌ Copilot might suggest useState const [count, setCount] = useState(0); // βœ… Correct Whisq const count = signal(0); ``` ::: ## Tips 1. **Keep 2-3 Whisq files open** β€” Copilot uses open tabs as context. Having good examples visible improves suggestions. 2. **Accept and correct** β€” when Copilot generates almost-right code, accept it and fix the small issues. It learns from your corrections within the session. 3. **Use line-by-line completion** β€” rather than expecting Copilot to generate entire components, let it complete one line at a time. It's more accurate this way. 4. **Start with the import** β€” typing `import { signal, computed, component, div` sets the context. Copilot understands you're writing Whisq. 5. **Use Copilot Chat** β€” paste the content of `CLAUDE.md` into a Copilot Chat session for better results on complex components. ## Next Steps - [Claude Code](https://whisq.dev/ai/claude-code/) β€” Deeper AI integration with CLAUDE.md - [Cursor](https://whisq.dev/ai/cursor/) β€” AI-first editor with .cursorrules support - [LLM Reference](https://whisq.dev/ai/llm-reference/) β€” Complete API for pasting into any AI --- # Cursor URL: https://whisq.dev/ai/cursor/ --- Cursor reads `.cursorrules` from your project root to understand framework conventions. Whisq projects include this file automatically. ## Setup Create a new project β€” `.cursorrules` is included: ```bash npm create whisq@latest my-app cd my-app # .cursorrules is already at the project root ``` Open the project in Cursor and start prompting. Cursor will follow Whisq patterns automatically. ## What .cursorrules Contains The file teaches Cursor: - **Import patterns** β€” always import from `@whisq/core` - **Reactive children** β€” use `() => value` not `value` directly - **Event handling** β€” `onclick`, `oninput` with standard DOM events - **Component structure** β€” `component()` function wrapping signals and a return statement - **Anti-patterns to avoid** β€” no JSX, no `this`, no class components ## Example Workflow In Cursor, use Cmd+K (or Ctrl+K) to open the AI prompt and describe what you need: ``` Create a search component with: - A text input bound to a signal - A filtered list that updates as you type - Use computed() for the filter logic ``` Cursor generates: ```ts const Search = component(() => { const query = signal(""); const items = signal(["Apple", "Banana", "Cherry", "Date", "Elderberry"]); const filtered = computed(() => items.value.filter(i => i.toLowerCase().includes(query.value.toLowerCase()) ) ); return div( input({ placeholder: "Search...", value: () => query.value, oninput: (e) => query.value = e.target.value, }), ul(each(() => filtered.value, (item) => li(item))), ); }); ``` ## Tips for Cursor 1. **Use Cmd+K in a file** β€” Cursor generates inline code that respects the `.cursorrules` context. 2. **Tab completion works well** β€” Cursor's autocomplete picks up Whisq patterns from your existing code and the rules file. 3. **Chat mode for larger features** β€” use Cursor's chat panel for multi-file features like stores + components. 4. **Edit selections** β€” select existing code, press Cmd+K, and describe the change. Cursor will modify the code following Whisq conventions. ## Customizing .cursorrules You can extend the rules file with project-specific conventions: ``` # .cursorrules (append to existing content) ## Project Conventions - Use stores/ directory for shared state - Component files use PascalCase: UserCard.ts - Store files use camelCase: cartStore.ts - All API calls go through stores, not components ``` ## Next Steps - [Claude Code](https://whisq.dev/ai/claude-code/) β€” Using Whisq with Claude Code - [GitHub Copilot](https://whisq.dev/ai/copilot/) β€” Tips for Copilot users - [LLM Reference](https://whisq.dev/ai/llm-reference/) β€” Complete API reference for any AI tool --- # LLM Reference URL: https://whisq.dev/ai/llm-reference/ --- This page contains the Whisq API in a format optimized for AI context windows. Copy and paste it into any AI assistant to get accurate Whisq code generation. Hit an error the reference block didn't prevent? Check [Common Mistakes](https://whisq.dev/common-mistakes/) β€” it's the symptom-first debugging reference. For the positive complement ("what's the right way to do this?"), see [Canonical Patterns](https://whisq.dev/canonical-patterns/). ## How to Use 1. Pick a tier below based on your model's free context. 2. Copy that block (use the copy button in the top-right). 3. Paste it at the start of your AI conversation. 4. Describe the component you want to build. This works with any LLM β€” ChatGPT, Claude, Gemini, Llama, Mistral, or local models. ## Current version **The npm registry is authoritative.** If your AI tool or scaffolder needs to know the current Whisq release, hit the registry first and pin everything downstream to that exact version β€” don't resolve "what's current?" through a CDN `@latest` alias. ```bash # 1. Resolve the current version from the registry (authoritative). version=$(curl -s https://registry.npmjs.org/@whisq/core/latest | jq -r .version) # 2. Fetch version-pinned artefacts at that exact version β€” NOT at @latest. curl -s "https://unpkg.com/@whisq/core@${version}/dist/public-api.json" > public-api.json ``` :::caution[CDN `@latest` aliases can lag npm] `https://unpkg.com/@whisq/core@latest/...` typically lags the npm registry by **minutes to hours** after a publish while the CDN's edge caches warm up. The alias can still point at the previous release even after the npm registry has fully propagated, so never use a CDN `@latest` URL to answer "what's the current version?". Pin by exact version once you've resolved it from the registry. Codex's alpha.9 review ran into exactly this: the CDN-lag window caused the generated feedback to initially resolve `0.1.0-alpha.8` even after npm had published `0.1.0-alpha.9`, and only npm-registry verification corrected it. ::: The `whisqCoreVersion` field in this repo's `package.json` β€” rendered at the bottom of every docs page β€” is the version these docs describe. It's kept in lockstep with the npm `@latest` tag by the release process; if a doc example diverges from what the registry reports, the docs are lagging, not the registry. ## Which tier? - **Minimum** (≀2 K tokens) β€” `signal`, `computed`, `effect`, `batch`, elements, events, conditional and list rendering, components, basic async data, store pattern, anti-patterns. Pick this for small-context models (4 K / 8 K windows) or when the conversation already fills most of the window. - **Complete** (≀5 K tokens) β€” everything in Minimum **plus** class composition (`cx`, `rcx`), scoped CSS and design tokens (`sheet`, `styles`, `theme`), context (`createContext` / `provide` / `inject`), error boundaries, keyed lists, and `resource().refetch()`. Pick this for frontier models or whenever you have headroom. If you're not sure, paste the **Complete** block β€” it's still well under any modern model's context window. ## Reactive shapes β€” pick the right one Four shapes cover every reactive position. Decision flow: *"single signal you own β†’ `bind()`; field inside an item inside a signal-held array β†’ `bindField()`; field that is itself a `Signal` (per-item nested signals) β†’ `bind(item().field)`."* | Shape | Example | Use when | |---|---|---| | Getter child | `span(() => count.value)` | Signal β†’ inline text | | Getter prop | `{ class: () => active.value ? "on" : "off" }` | Signal β†’ single attribute / style | | `class:` array (alpha.8) | `{ class: ["btn", () => loading.value && "btn-loading"] }` | Composing class names from a mix of static + reactive sources β€” eliminates the cx-vs-rcx decision | | `bind()` spread | `input({ ...bind(email) })` | Two-way bind one signal into one form input | | `bindField()` spread | `input({ ...bindField(todos, todo, "done", { as: "checkbox" }) })` | Field inside an item inside a signal-held array | Inside keyed `each(..., { key })`, the callback's `item` is an **accessor function** β€” call `todo()` to read the current item. Getters that close over `todo` directly go stale when the array is replaced. Use `bindField(source, item, key)` for two-way binding inside the loop β€” it identifies the right item via `keyBy` (default `t => t.id`) and produces immutable array writes. The pre-`bindField` "manual event pair" pattern (`{ checked: () => todo().done, onchange: () => toggle(todo().id) }`) is still valid as an escape hatch when your store holds nested signals per-item or when `bindField` doesn't fit (custom write logic, derived fields, etc.). For nested-record paths inside a single signal (`user.profile.email`, `settings.billing.plan`), use [`bindPath()`](https://whisq.dev/api/bindpath/) from `@whisq/core/forms` β€” sub-path import, same discriminator surface as `bind()` / `bindField()`. Three primitives cover every two-way binding shape: `bind()` for one signal, `bindField()` for an array's item field, `bindPath()` for a record's nested-object field. Decision matrix for the three per-row editing shapes (plain immutable / nested signal / extracted child component): [Nested Item Editing guide](https://whisq.dev/guides/nested-item-editing/). For the broader "which primitive when" tables (binding, list rendering, persistence, conditional, reactive class), see [Choosing Patterns](https://whisq.dev/core-concepts/choosing-patterns/). Full taxonomy + common mistakes: [`packages/core/docs/reactive-shapes.md`](https://github.com/whisqjs/whisq/blob/main/packages/core/docs/reactive-shapes.md). **Capture trap.** Never hoist `.value.field` (or `.value`, or `accessor.value.field`) out of a reactive getter β€” you capture the *value*, not the *reactivity*. Read inside the `() =>` every time. See [Hoisting `.value.field` out of a reactive getter](https://whisq.dev/common-mistakes/#hoisting-valuefield-out-of-a-reactive-getter). ## Project structure One-line rule: **one component per file; `main.ts` is for mounting, nothing else.** ```text src/ main.ts # entrypoint β€” mounts App to #app, nothing else App.ts # top-level component β€” routing, layout, error boundaries styles.ts # sheet() definitions at module scope components/ # reusable UI β€” one PascalCase file per component, named export pages/ # route targets (if using @whisq/router) stores/ # shared state β€” one domain per file lib/ # pure utilities, NO Whisq imports ``` Anti-patterns to avoid: - Everything in `main.ts` β€” AI rewrites the whole file to change anything. - `src/lib/index.ts` utility soup β€” kills tree-shaking, invites circular imports. - Default exports β€” don't compose in stack traces or auto-imports. - Lowercase or mismatched component filenames (`button.ts` exporting `Button`) β€” breaks auto-import heuristics. - `stores/store.ts` holding every domain β€” importing one drags in all. - Top-level network calls in a store β€” breaks SSR and tests; use `resource()` or a user action. `localStorage` is the one blessed exception β€” see the [persistence pattern](https://whisq.dev/guides/state-management/#persistence-localstorage). Full conventions + when-to-split guidance: [`packages/core/docs/project-structure.md`](https://github.com/whisqjs/whisq/blob/main/packages/core/docs/project-structure.md). A worked multi-file scaffold with prose walking each file: [Multi-file App Scaffold](https://whisq.dev/getting-started/multi-file-app/). **Intra-file decision** β€” sub-renderer as a plain factory vs `component()`: use a plain factory `(props) => element(...)` for stateless markup; promote to `component()` when it adds `signal()` / `effect()` / lifecycle / `inject()` / cross-file reuse. See [Components β†’ "Is this a component or just a function?"](https://whisq.dev/core-concepts/components/#is-this-a-component-or-just-a-function). ## Minimum ## Complete Complete = everything in Minimum, plus: class composition (`cx`, `rcx`), scoped CSS and design tokens (`sheet`, `styles`, `theme`, `sx`), context (`createContext` / `provide` / `inject`), error boundaries, `bind()` two-way binding, tri-state rendering (`match`), DOM refs (`ref`), keyed lists, reactive collections (`signalMap`, `signalSet`), typed event handlers (`EventHandler`, `WhisqEvent`), portals, transitions, head management, `resource().refetch()`. The block below is the **combined** Minimum + extras, ready to copy in one paste. ## Why this works The reference above stays within its advertised tier budgets β€” under 2 K tokens for Minimum and under 5 K for Complete β€” so it fits any model's context window with room for a real conversation. It covers: - All reactive primitives (signal, computed, effect, batch) - All element functions - Event handling patterns - Conditional and list rendering (with keyed reconciliation in Complete) - Components with lifecycle - Class-name composition (Complete: cx, rcx) - Scoped CSS and design tokens (Complete: sheet, styles, theme) - Context for prop-drill-free state (Complete: createContext, provide, inject) - Error boundaries (Complete) - Async data fetching (with refetch in Complete) - Shared state pattern - Common anti-patterns Because Whisq uses plain JavaScript functions with no special syntax, AI models generate correct code with high accuracy. There's no JSX to misformat, no hook ordering to violate, and no template DSL to hallucinate. ## Next Steps - [Claude Code](https://whisq.dev/ai/claude-code/) β€” Set up for Claude Code with CLAUDE.md - [Cursor](https://whisq.dev/ai/cursor/) β€” Set up .cursorrules for Cursor - [MCP Server](https://whisq.dev/ai/mcp-server/) β€” AI tooling with component scaffolding - [Common Mistakes](https://whisq.dev/common-mistakes/) β€” Symptom-first debugging when a snippet from the block above doesn't work - [How Reactivity Works](https://whisq.dev/core-concepts/reactivity-model/) β€” Diagram + 5 questions for when the snippet works but you want to know why - [Testing](https://whisq.dev/guides/testing/) β€” Render components into JSDOM, query elements, assert on the DOM β€” the canonical test recipe using `@whisq/testing` --- # MCP Server URL: https://whisq.dev/ai/mcp-server/ --- `@whisq/mcp-server` provides AI coding assistants with two tools: **scaffold_component** for generating components from descriptions, and **validate_code** for checking Whisq code against common anti-patterns. ## Installation ```bash npm install -D @whisq/mcp-server ``` The package includes a binary `whisq-mcp` that runs as a stdio-based MCP server. ## Configuration ### Claude Code Add the server to your Claude Code MCP configuration: ```json { "mcpServers": { "Whisq": { "command": "npx", "args": ["whisq-mcp"] } } } ``` ### Cursor In Cursor settings, add: ```json { "mcp.servers": { "Whisq": { "command": "npx", "args": ["whisq-mcp"] } } } ``` ### Other MCP Clients Any MCP-compatible client can connect using the stdio transport: ```bash npx whisq-mcp ``` The server communicates over stdin/stdout using the Model Context Protocol. ## Tools ### scaffold_component Generates a Whisq component from a natural language description. **Parameters:** | Param | Type | Required | Description | |---|---|---|---| | `name` | string | Yes | Component name in PascalCase | | `description` | string | Yes | What the component does | | `props` | string[] | No | Optional prop names | **Example prompt:** ``` Use the scaffold_component tool to create a UserCard component that displays a user's name and email, with props for name and email. ``` **Generated output:** ```ts /** * UserCard β€” Displays a user's name and email. */ const UserCard = component((props: { name: string; email: string }) => { return div({ class: "user-card" }, h3(props.name), p(props.email), ); }); ``` ### validate_code Checks Whisq code for common mistakes and anti-patterns. **Parameters:** | Param | Type | Required | Description | |---|---|---|---| | `code` | string | Yes | TypeScript/JavaScript code to validate | **What it checks:** - Static signal values used as children (should be wrapped in `() =>`) - Direct array mutation (`.push()` instead of spread) - Missing `preventDefault` on form submissions - Import from wrong package paths **Example prompt:** ``` Use validate_code to check this component: const App = component(() => { const count = signal(0); return div(count.value); }); ``` **Response:** ``` Line 3: Signal value used directly as child β€” wrap in () => for reactivity Found: div(count.value) Fix: div(() => count.value) ``` ## How It Works The MCP server runs as a local process alongside your editor. When you (or the AI) invokes a tool: 1. The AI sends a tool call request over the MCP protocol 2. The server processes the request (scaffolding or validation) 3. The result is returned to the AI for use in its response No data leaves your machine. The server runs entirely locally. :::tip[🐱 Whisker Tip] The MCP server complements `CLAUDE.md` β€” the context file teaches the AI the API, while the MCP server gives it active tools to generate and validate code. ::: ## Next Steps - [Claude Code](https://whisq.dev/ai/claude-code/) β€” Using the MCP server with Claude Code - [LLM Reference](https://whisq.dev/ai/llm-reference/) β€” The complete API reference - [AI Integration Guide](https://whisq.dev/guides/ai-integration/) β€” Prompt patterns and workflows --- # Link() URL: https://whisq.dev/api/Link/ --- `Link(props, ...children)` renders an `` that dispatches `router.navigate(href)` on plain left-clicks, preserving modified clicks (⌘-click, middle-click, etc.) for native browser behaviour β€” "open in new tab" works without special handling. ## Signature ```ts function Link( props: { href: string; router: Router; class?: string | (() => string); }, ...children: (string | number | WhisqNode)[] ): WhisqNode; ``` | Prop | Type | Description | |---|---|---| | `href` | `string` | Destination path β€” pushed via `router.navigate(href)` on plain left-click. | | `router` | `Router` | The router instance to dispatch against. Pass the same `createRouter()` return value used by the enclosing `RouterView`. | | `class` | `string \| (() => string)` (optional) | Same reactive-class shape as every other element prop β€” pass a function to recompute on route change (e.g. for active styling). | ## Basic link ```ts Link({ href: "/about", router }, "About Us") ``` ## Active-link styling There's no dedicated `active` / `activeClass` prop β€” the reactive `class` getter is the pattern. Read `router.current.value.path` inside a getter: ```ts Link({ href: "/about", router, class: () => router.current.value.path === "/about" ? "active" : "", }, "About") ``` For a navigation bar where many links share the same rule, factor the active-class function: ```ts const isActive = (href: string) => router.current.value.path === href ? "active" : ""; nav( Link({ href: "/", router, class: () => isActive("/") }, "Home"), Link({ href: "/about", router, class: () => isActive("/about") }, "About"), Link({ href: "/blog", router, class: () => isActive("/blog") }, "Blog"), ) ``` For **prefix-matching active styling** (e.g. `/blog` stays active on `/blog/my-post`), compare with `startsWith`: ```ts class: () => router.current.value.path.startsWith("/blog") ? "active" : "" ``` ## Modified-click pass-through `Link` checks the click event and skips its own navigation if any of the following are true β€” the browser handles the click as a plain anchor: - `event.button !== 0` (middle or right-click) - `event.metaKey`, `event.ctrlKey`, `event.shiftKey`, `event.altKey` So ⌘-click (or Ctrl-click on Windows) opens in a new tab, Shift-click opens in a new window, etc. β€” all native browser behaviours are preserved. ## Semantics - **Plain `` under the hood.** Renders via [`a(...)`](https://whisq.dev/api/elements/) and accepts the same reactive class getter pattern. - **No prefetch, no active class prop.** Both are deliberately absent in the current router β€” if you need them, compose on top: wrap `Link` in your own component that adds a prefetch hook, or extract an `isActive` helper as shown above. - **`href` is a plain string.** It's not reactive β€” if you need a dynamic destination, recompose: render a different `Link` or compute the href at call time. ## See also - [`/api/createrouter/`](https://whisq.dev/api/createrouter/) β€” the router you pass into every `Link`. - [`/api/routerview/`](https://whisq.dev/api/routerview/) β€” where clicks ultimately render. - [`/api/elements/#class-array-form-since-alpha8`](https://whisq.dev/api/elements/#class-array-form-since-alpha8) β€” the class-array form also works inside `Link` when you pass it as the `class` getter's return (a single string). --- # RouterView() URL: https://whisq.dev/api/RouterView/ --- `RouterView(router, depth?)` subscribes to `router.current` and renders the component whose matched route sits at `depth` in the match chain. Lazy components (`() => import(...)`) are resolved on first match; swaps between routes call the previous component's `dispose()`. ## Signature ```ts function RouterView(router: Router, depth?: number): WhisqNode; ``` | Param | Type | Description | |---|---|---| | `router` | `Router` | The router returned by [`createRouter`](https://whisq.dev/api/createrouter/). | | `depth` | `number` (optional, default `0`) | Which level of the match chain to render. `0` = the top-level route; `1` = its child; etc. | ## Mounting the top-level view Most apps have exactly one top-level `RouterView` at the app root: ```ts const router = createRouter({ routes: [ { path: "/", component: Home }, { path: "/about", component: About }, ], }); mount(RouterView(router), document.getElementById("app")!); ``` ## Nested routes A parent route declares `children`, and its component renders a `RouterView` at the next `depth`: ```ts // App.ts mount(RouterView(router), document.getElementById("app")!); ``` ```ts // routes.ts const routes: RouteConfig[] = [ { path: "/users", component: UsersLayout, // renders at depth 0 children: [ { path: "", component: UsersList }, // /users β†’ depth 1 { path: ":id", component: UserDetail }, // /users/:id β†’ depth 1 { path: ":id/edit", component: UserEdit }, // /users/:id/edit β†’ depth 1 ], }, ]; ``` ```ts // UsersLayout.ts ``` Each nested route's params are available to its component via the render callback (via the first argument, which is `Record`). `router.current.value.params` aggregates all params from every matched route. ## Transition interop `RouterView` returns a `WhisqNode`, so it composes with [`transition()`](https://whisq.dev/api/transition/) like any other element: ```ts transition( RouterView(router), { enter: { opacity: [0, 1], duration: 150 }, exit: { opacity: [1, 0], duration: 150 }, }, ) ``` Because the underlying DOM node is a single `
` whose children swap on route change, outer transitions animate the container and not individual routes. For per-route animations, wrap each matched component in its own `transition()` call. ## Lifecycle - **Mount.** `RouterView` attaches an effect on `router.current`. First render resolves the matched component (awaits the `import(...)` if lazy) and appends its DOM node. - **Route change.** The previous component's `dispose()` is called, the container is cleared, the new component is resolved and mounted. - **Stale resolutions are discarded.** If a route changes while a lazy component is still resolving, the resolution's output is dropped (identity check against `router.current.value`). - **Unmount.** Call `node.dispose()` on the returned `WhisqNode` to tear down the effect and the current component. In practice you'd do this alongside `router.dispose()` during HMR or test teardown. ## See also - [`/api/createrouter/`](https://whisq.dev/api/createrouter/) β€” the router that drives the view. - [`/api/link/`](https://whisq.dev/api/link/) β€” navigation links. - [`/api/route-state/`](https://whisq.dev/api/route-state/) β€” reading the current route inside a rendered component. --- # batch() URL: https://whisq.dev/api/batch/ --- Batch multiple signal updates so effects only run once. See [How Reactivity Works β†’ batch()](https://whisq.dev/core-concepts/reactivity-model/#4-when-does-batch-matter) for when this matters in practice. ## Signature ```ts function batch(fn: () => void): void ``` ## Parameters | Param | Type | Description | |---|---|---| | `fn` | `() => void` | Function containing multiple signal writes | ## Examples ```ts const x = signal(0); const y = signal(0); effect(() => console.log(x.value, y.value)); // Logs: 0, 0 batch(() => { x.value = 1; y.value = 2; }); // Logs: 1, 2 β€” only ONCE ``` :::tip[🐱 Whisker Tip] Use `batch()` when updating multiple signals at once. Without it, each write triggers a separate effect re-run. ::: ## Guarantees These four behaviors are pinned by tests in `@whisq/core` and won't change without a version bump. The full rationale β€” including which lines of `reactive.ts` implement each β€” lives in [`packages/core/docs/batch-semantics.md`](https://github.com/whisqjs/whisq/blob/develop/packages/core/docs/batch-semantics.md) in the framework repo. The snippets below assume `import { signal, computed, effect, batch } from "@whisq/core";` at the top of the file (same as the Examples section above). ### Computeds don't recompute mid-batch A `computed()` whose dependency is written inside a batch stays **stale** for the remainder of the batch. It recomputes exactly once after the batch exits, no matter how many times its dependencies were written. ```ts const a = signal(1); const b = signal(2); const sum = computed(() => a.value + b.value); sum.value; // 3 (primes the cache) batch(() => { a.value = 10; b.value = 20; sum.value; // 3 β€” STALE, still the cached value }); sum.value; // 30 (fresh) ``` ### Nested batches are reference-counted Effects flush only at the **outermost** boundary. Inner `batch()` calls exit without running effects. ```ts batch(() => { a.value = 1; batch(() => { b.value = 2; // no flush here }); // no flush here either }); // one flush here β€” effects see a=1, b=2 ``` ### `await` closes the batch `batch(fn)` is **synchronous**. Writes after the first `await` in an async function run **outside** the batch β€” the `finally` block flushes effects the instant `fn()` returns its pending Promise. ```ts batch(async () => { a.value = 1; // inside the batch β€” deferred await fetch("/x"); b.value = 2; // OUTSIDE β€” runs its effect immediately }); ``` For async "transactions," do the async work first and batch only the synchronous write cluster (assume the snippet runs inside an `async` function): ```ts const data = await fetch("/x").then((r) => r.json()); batch(() => { user.value = data.user; settings.value = data.settings; status.value = "ready"; }); ``` ### A throw commits the writes so far Signal writes inside `batch()` are **eager** β€” only the effect runs are deferred. If code throws mid-batch, prior writes stay committed, effects flush in `finally` for those writes, and the error propagates out. ```ts try { batch(() => { a.value = 10; // committed throw new Error("oops"); b.value = 20; // unreachable }); } catch {} a.value; // 10 (committed) b.value; // unchanged (assignment never ran) // The effect reading `a` ran once after the throw, with a.value === 10. ``` There's no rollback β€” don't use `batch()` as a transaction. ## Not guaranteed These are implementation details that work today but may change without a version bump: - **Effect execution order within a flush.** Effects are enqueued in a `Set` and iterated in insertion order per today's ES spec, but this may change as the runtime evolves (priority queues, component-boundary batching). - **Optimizations beyond dedup.** A single effect queued multiple times inside a batch runs exactly once at flush β€” that's guaranteed. Beyond that (e.g., skipping effects whose deps ended the batch with their original values), no promises. --- # bind() URL: https://whisq.dev/api/bind/ --- Spread `bind(signal, options?)` into a form control to wire it to a signal in one line. Covers text inputs (including `textarea` and `select`), numbers, checkboxes, and radio groups. ## Signature ```ts function bind(signal: Signal): TextBind; function bind( signal: Signal, options: { as: "number" }, ): NumberBind; function bind( signal: Signal, options: { as: "checkbox" }, ): CheckboxBind; function bind( signal: Signal, options: { as: "radio"; value: V }, ): RadioBind; ``` ## Parameters | Param | Type | Description | | --------- | ----------------------------------------- | --------------------------------------------------------------- | | `signal` | `Signal` | The signal to read from and write to | | `options` | `{ as: "number" \| "checkbox" \| "radio"; value?: V }` | Kind of input; `radio` also needs the per-radio `value` | ## Returns A plain props object matching the input kind: | Kind | Return type | Props | | ---------- | -------------- | -------------------------------------------------------------- | | default | `TextBind` | `{ value: () => string, oninput: (e) => void }` | | `number` | `NumberBind` | `{ value: () => string, oninput: (e) => void }` (coerces) | | `checkbox` | `CheckboxBind` | `{ checked: () => boolean, onchange: (e) => void }` | | `radio` | `RadioBind` | `{ value: V, checked: () => boolean, onchange: (e) => void }` | ## Examples ```ts const name = signal(""); input({ ...bind(name) }); // text const age = signal(0); input({ type: "number", ...bind(age, { as: "number" }) }); const agreed = signal(false); input({ type: "checkbox", ...bind(agreed, { as: "checkbox" }) }); const role = signal<"admin" | "user">("user"); input({ type: "radio", name: "role", ...bind(role, { as: "radio", value: "admin" }) }); input({ type: "radio", name: "role", ...bind(role, { as: "radio", value: "user" }) }); const bio = signal(""); textarea({ ...bind(bio) }); const tier = signal("free"); select({ ...bind(tier) }, option({ value: "free" }, "Free"), option({ value: "pro" }, "Pro")); ``` See [Forms guide](https://whisq.dev/guides/forms/) for the full pattern including validation and reset. ## Handler collision (dev warning since alpha.9) Spreading `bind(...)` and then overwriting one of its declared handlers silently drops the binding. Alpha.9+ catches it with a `console.warn` in dev (zero cost in production). ```ts // ❌ Direction 1 β€” spread-first, user-handler-second. Dev: console.warn fires. input({ ...bind(draft), oninput: (e) => track(e), // overwrites bind's oninput }); // βœ… Canonical β€” put the custom handler BEFORE the bind spread input({ oninput: (e) => track(e), // user handler first... ...bind(draft), // ...bind wins visibly; this is what you want }); ``` **Why "spread bind last" is the convention.** The framework can only detect the collision in **direction 1** β€” when `bind()` sets the handler and the user later overwrites. The reverse case (user handler first, then `...bind()`) destroys the user's handler without trace β€” by the time the element builder sees the props, the user's handler is already gone. The warning steers you toward the safe convention: **spread bind last** so if there *is* a collision, your own handler visibly "wins" and the absence of the expected bind behaviour is loud instead of silent. ### If you deliberately want to augment bind Chain your handler after reading `bind()`'s output β€” that's a transparent composition that dodges the collision entirely: ```ts const draftBinding = bind(draft); input({ ...draftBinding, oninput: (e) => { draftBinding.oninput(e); // run bind's handler first track(e); // then your own }, }); ``` The warning fires on symbol-keyed-manifest check in the element builder; the three bind helpers (`bind`, `bindField`, `bindPath`) attach the manifest identically, so the rule is the same on all of them. ## Related primitives - [`bindField()`](https://whisq.dev/api/bindfield/) β€” bind a field on an item inside a signal-held array (per-row inputs in lists). - [`bindPath()`](https://whisq.dev/api/bindpath/) β€” bind a field at a nested object path inside a signal-held record (multi-step forms, settings panels). - [Event types](https://whisq.dev/api/event-types/) β€” `WhisqEvent` and `EventHandler` for typed custom handlers when the spread isn't enough. --- # bindField() URL: https://whisq.dev/api/bindField/ --- Spread `bindField(source, item, key, options?)` into a form control to wire it to one field of one item inside a signal-held array. Sibling to [`bind()`](https://whisq.dev/api/bind/) β€” same discriminator shapes β€” but reaches into the per-item-per-field shape that `bind()` doesn't. The realistic case: a checkbox per row in a list of todos, where the source array holds plain objects (no nested signals). ```ts each(() => todos.value, (todo) => input({ type: "checkbox", ...bindField(todos, todo, "done", { as: "checkbox" }), }), { key: (t) => t.id }, ) ``` ## Signature ```ts function bindField( source: Signal, item: () => T, key: K, ): TextBind; function bindField( source: Signal, item: () => T, key: K, options: { as: "number" } & Common, ): NumberBind; function bindField( source: Signal, item: () => T, key: K, options: { as: "checkbox" } & Common, ): CheckboxBind; function bindField( source: Signal, item: () => T, key: K, options: { as: "radio"; value: V } & Common, ): RadioBind; ``` Where `Common` is `{ keyBy?: (item: T) => unknown }`. The exported public type alias is `BindFieldOptions` β€” import it from `@whisq/core` when you need to type an `options` object explicitly. ## Parameters | Param | Type | Description | | --------- | ------------------------------------------------- | ---------------------------------------------------------------------------- | | `source` | `Signal` | The array signal that holds the items | | `item` | `() => T` | Accessor for the current item β€” typically the keyed-`each()` callback's first arg | | `key` | `K extends keyof T` | The field on `T` to bind | | `options.as` | `"number" \| "checkbox" \| "radio"` (optional) | Kind of input β€” same discriminator as `bind()` | | `options.value` | `V` (radio only, required) | The per-radio value, same as `bind()` | | `options.keyBy` | `(item: T) => unknown` (optional) | Identifies which item to rewrite. Default: `t => t.id` | | `options.strict` | `boolean` (optional, since alpha.8) | Pin no-match write behaviour. `true` = throw `WhisqKeyByError` in both envs; `false` = warn-and-discard in both envs. Default: `true` in dev (`process.env.NODE_ENV !== "production"`), `false` in production. | ## Returns A plain props object matching the input kind β€” identical to `bind()`'s return shapes: | Kind | Return type | Props | | ---------- | -------------- | ------------------------------------------------------------- | | default | `TextBind` | `{ value: () => string, oninput: (e) => void }` | | `number` | `NumberBind` | `{ value: () => string, oninput: (e) => void }` (coerces) | | `checkbox` | `CheckboxBind` | `{ checked: () => boolean, onchange: (e) => void }` | | `radio` | `RadioBind` | `{ value: V, checked: () => boolean, onchange: (e) => void }` | ## Examples ### Checkbox per item ```ts interface Todo { id: number; text: string; done: boolean } const todos = signal([ { id: 1, text: "Learn Whisq", done: false }, { id: 2, text: "Build an app", done: false }, ]); ul( each(() => todos.value, (todo) => li( input({ type: "checkbox", ...bindField(todos, todo, "done", { as: "checkbox" }) }), todo().text, ), { key: (t) => t.id }, ), ) ``` ### Editable text field per item ```ts ul( each(() => todos.value, (todo) => li(input({ ...bindField(todos, todo, "text") })), { key: (t) => t.id }, ), ) ``` ### Number input per item ```ts interface CartItem { id: string; sku: string; qty: number } const cart = signal([/* ... */]); each(() => cart.value, (item) => input({ type: "number", min: 0, ...bindField(cart, item, "qty", { as: "number" }), }), { key: (i) => i.id }, ) ``` ### Radio per item (e.g. priority picker) ```ts type Priority = "low" | "med" | "high"; interface Task { id: number; priority: Priority } const tasks = signal([/* ... */]); each(() => tasks.value, (task) => div( (["low", "med", "high"] as const).map((p) => input({ type: "radio", name: `priority-${task().id}`, ...bindField(tasks, task, "priority", { as: "radio", value: p }), }), ), ), { key: (t) => t.id }, ) ``` ### Custom `keyBy` When your item identifier isn't called `id`: ```ts interface User { uuid: string; name: string } const users = signal([/* ... */]); each(() => users.value, (user) => input({ ...bindField(users, user, "name", { keyBy: (u) => u.uuid }), }), { key: (u) => u.uuid }, ) ``` ## Semantics - **Immutable array updates.** Writes produce a new array via `source.value.map(...)` β€” downstream `computed`, `effect`, and keyed `each()` re-reconcile correctly. No in-place mutation, no surprises for code that depends on referential change. - **No-match writes throw `WhisqKeyByError` in dev** (since alpha.8), or warn-and-discard in production. See the dedicated section below. - **Lives across component boundaries.** Pass `todo: () => Todo` as a prop to a child component and call `bindField(todos, props.todo, "done")` inside β€” works the same as inline. The accessor stays live; the source signal is captured once and identifies items by `keyBy`. See [`/api/each/#splitting-into-a-component`](https://whisq.dev/api/each/#splitting-into-a-component). - **Type narrowing.** TypeScript checks that `key` is a valid field of `T` and that the discriminator (`as: "checkbox"` etc.) matches the field's expected runtime type. Misuse compiles to clear errors. - **Runtime guards.** Throws `TypeError: bindField: source must be Signal` if you pass an unwrapped value (e.g. `bindField(todos.value, ...)` instead of `bindField(todos, ...)`), and `TypeError: bindField: item must be a function` if you pass a plain object instead of the keyed-`each()` accessor. ## Dev-mode behaviour and the `strict` option Since alpha.8, a write that can't find an item in the source array whose `keyBy(...)` matches the accessor's key throws `WhisqKeyByError` in dev (was: `console.warn` + discard). The typical cause is a stale accessor (the item was removed from the source after the accessor was created) or a broken `keyBy` function. ```ts // Default in vite dev: throws WhisqKeyByError on no-match writes input({ ...bindField(todos, todo, "done", { as: "checkbox" }) }); // Opt out for a test that deliberately exercises the no-match path: input({ ...bindField(todos, todo, "done", { as: "checkbox", strict: false }), }); ``` | `strict` | Dev (`NODE_ENV !== "production"`) | Production | |-----------------|-----------------------------------|------------| | (omitted, default) | **throws `WhisqKeyByError`** | warn-and-discard | | `true` | throws `WhisqKeyByError` | **throws `WhisqKeyByError`** | | `false` | warn-and-discard | warn-and-discard | `WhisqKeyByError` extends `Error` and carries: | Field | Type | Description | |---|---|---| | `sourceKeys` | `unknown[]` | All keys present in the source array at write time, in source order | | `targetKey` | `unknown` | The key the write was trying to match against | | `field` | `string` | The field name being written (from `bindField`'s `key` argument) | Both the class and its `WhisqKeyByErrorFields` shape are exported from `@whisq/core` ([`/api/imports/`](https://whisq.dev/api/imports/#-main-entry)). Catch it in an [`errorBoundary`](https://whisq.dev/api/errorboundary/) to render a friendly UI for stale-row edits, or let it bubble in tests to surface integration issues. ## Writing through filtered / partitioned views A common shape: iterate a **filtered** view of a list (from `partition()` or a `computed`), but let the per-row input edit the **source**. :::caution[The rule] Pass the **source** signal to `bindField`, not the filtered signal. The accessor's `keyBy` lookup runs against whichever signal you pass, so passing the source is what makes the write target the real array. ::: Why this works: the render callback's accessor (`todo`) just carries a reference to a `Todo` object. That same `Todo` also lives in the source array, so looking it up there by `keyBy(todo())` (default `t => t.id`) matches. `bindField` doesn't care which signal produced the accessor β€” it rewrites whichever array you hand it. ### Worked example ```ts // stores/todos.ts const [pending, completed] = partition(() => todos.value, (t) => !t.done); case "done": return completed.value; default: return todos.value; } }); ``` ```ts // components/TodoList.ts const TodoList = component(() => ul( each(() => filtered.value, (todo) => li( // βœ… Iterate the filtered view, but bind against the SOURCE signal. // bindField's keyBy lookup runs against `todos`, which is where the // item actually lives β€” the write lands on the real array. input({ type: "checkbox", ...bindField(todos, todo, "done", { as: "checkbox" }) }), () => todo.value.text, ), { key: (t) => t.id }, ), ), ); ``` The same rule applies when you split the row into its own component β€” pass both the source signal and the accessor as props, then call `bindField(props.source, props.todo, ...)` inside. The source signal's identity is captured once; the accessor stays live. ### Why not just pass `filtered` to `bindField`? Two shapes of this mistake β€” one caught by the type system, one not. **Shape 1 β€” type-prevented.** `partition()` and `computed()` both return `ReadonlySignal`, and `bindField`'s first parameter is `Signal`. Passing `pending`, `completed`, or a `computed()` result directly is a TypeScript error at the call site. The types catch the most common form of the mistake before runtime. **Shape 2 β€” type-evaded (the real footgun).** A hand-rolled **writable** shadow signal that mirrors the filtered view typechecks but still fails: ```ts // ❌ ANTI-PATTERN β€” hand-rolled writable filter view const filteredCopy = signal([]); effect(() => { filteredCopy.value = todos.value.filter((t) => filter.value === "all" ? true : filter.value === "active" ? !t.done : t.done, ); }); each(() => filteredCopy.value, (todo) => input({ type: "checkbox", // Writes update filteredCopy, not todos β€” the real source never sees the // edit, and the next effect beat overwrites filteredCopy.value anyway. // When the toggled item transitions out of the filter, its id is no // longer in filteredCopy β†’ WhisqKeyByError on the next write in dev. ...bindField(filteredCopy, todo, "done", { as: "checkbox" }), }), { key: (t) => t.id }, ); ``` Two failures at once: writes target the shadow array (which is about to be overwritten by the effect), and once the item stops matching the filter, its accessor can no longer find itself in `filteredCopy` and the write is discarded (or throws `WhisqKeyByError` in dev). The fix is `bindField(todos, todo, ...)` β€” nothing else changes. ## When to use which | Shape | Helper | When | |---|---|---| | Single signal you own | [`bind(signal)`](https://whisq.dev/api/bind/) | One input ↔ one signal | | Field inside an item inside a signal-held array | `bindField(source, item, key)` | Per-row inputs in lists, CRUD grids, editable tables | | Field inside an item where the field is a `Signal` | [`bind(item().done)`](https://whisq.dev/api/bind/) | When you intentionally want per-item reactive isolation β€” toggling one checkbox re-evaluates only that item's signal, not the array. The array signal stays still on toggle, so dependent `computed`/`effect` don't re-fire. See [todo-app example](https://whisq.dev/examples/todo-app/) for the canonical use. | The third shape (nested-signal store + `bind()`) is fine and idiomatic. `bindField()` exists so apps that prefer plain-object item shapes don't have to reach for the manual event-pair escape hatch. See also: [Reactive shapes cheat sheet](https://whisq.dev/ai/llm-reference/#reactive-shapes--pick-the-right-one), [Forms guide β†’ list of items](https://whisq.dev/guides/forms/#binding-fields-inside-list-items), [Nested Item Editing guide](https://whisq.dev/guides/nested-item-editing/) β€” which pattern to pick when (plain immutable / nested signal / extracted child), [Handler collision dev warning](https://whisq.dev/api/bind/#handler-collision-dev-warning-since-alpha9) β€” applies to `bindField()` spreads the same way as `bind()`. --- # bindPath() URL: https://whisq.dev/api/bindPath/ --- Spread `bindPath(source, path, options?)` into a form control to wire it to a field nested inside a signal-held record. Sibling to [`bind()`](https://whisq.dev/api/bind/) and [`bindField()`](https://whisq.dev/api/bindfield/) β€” same discriminator surface β€” but reaches into deep object paths that those primitives don't. The realistic case: a multi-step user-profile form on a `Signal` where fields live at `user.profile.email`, `user.prefs.dark`, etc. ```ts interface User { id: string; profile: { name: string; email: string; age: number }; prefs: { dark: boolean }; } const user = signal({ id: "u1", profile: { name: "", email: "", age: 0 }, prefs: { dark: false }, }); form( input({ ...bindPath(user, ["profile", "name"]) }), input({ type: "email", ...bindPath(user, ["profile", "email"]) }), input({ type: "number", ...bindPath(user, ["profile", "age"], { as: "number" }) }), input({ type: "checkbox", ...bindPath(user, ["prefs", "dark"], { as: "checkbox" }) }), ) ``` ## Import `bindPath()` lives on the `@whisq/core/forms` sub-path so apps that only need `bind()` and `bindField()` (the 80% case) pay no bundle cost. See [`/api/imports/`](https://whisq.dev/api/imports/#whisqcoreforms) for the full sub-path reference. ```ts ``` ## Signature ```ts // Typed overloads for depths 1–4 (TypeScript checks each path key against the // parent shape). Default-discriminator returns TextBind only when the field // resolves to `string`; misuse on a non-string field is a type error. function bindPath( source: Signal, path: readonly [K1], ): T[K1] extends string ? TextBind : never; function bindPath( source: Signal, path: readonly [K1, K2], ): T[K1][K2] extends string ? TextBind : never; // ... and so on for depth 3 and 4 (same shape, more keys). // Loose signature β€” works at any depth, less TS inference. function bindPath( source: Signal, path: readonly PropertyKey[], options: { as: "number" }, ): NumberBind; function bindPath( source: Signal, path: readonly PropertyKey[], options: { as: "checkbox" }, ): CheckboxBind; function bindPath( source: Signal, path: readonly PropertyKey[], options: { as: "radio"; value: V }, ): RadioBind; function bindPath( source: Signal, path: readonly PropertyKey[], options?: PathOptions, ): TextBind; ``` ## Parameters | Param | Type | Description | | --------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------- | | `source` | `Signal` | The signal holding the root record | | `path` | `readonly PropertyKey[]` | A non-empty array of keys describing the field's path. **Object keys only β€” no array indexes.** | | `options.as` | `"number" \| "checkbox" \| "radio"` (optional) | Kind of input β€” same discriminator as `bind()` and `bindField()` | | `options.value` | `V` (radio only, required) | The per-radio value, same as `bind()` | ## Returns A plain props object matching the input kind β€” identical to `bind()`'s return shapes: | Kind | Return type | Props | | ---------- | -------------- | ------------------------------------------------------------- | | default | `TextBind` | `{ value: () => string, oninput: (e) => void }` | | `number` | `NumberBind` | `{ value: () => string, oninput: (e) => void }` (coerces) | | `checkbox` | `CheckboxBind` | `{ checked: () => boolean, onchange: (e) => void }` | | `radio` | `RadioBind` | `{ value: V, checked: () => boolean, onchange: (e) => void }` | ## Examples ### Profile form (text + email + number + checkbox) ```ts interface User { id: string; profile: { name: string; email: string; age: number }; prefs: { dark: boolean }; } const user = signal({ id: "u1", profile: { name: "", email: "", age: 0 }, prefs: { dark: false }, }); form( label("Name", input({ ...bindPath(user, ["profile", "name"]) })), label("Email", input({ type: "email", ...bindPath(user, ["profile", "email"]) })), label("Age", input({ type: "number", ...bindPath(user, ["profile", "age"], { as: "number" }) })), label("Dark mode", input({ type: "checkbox", ...bindPath(user, ["prefs", "dark"], { as: "checkbox" }) })), ) ``` ### Settings with a radio group ```ts interface Settings { billing: { plan: "free" | "pro" | "team" }; } const settings = signal({ billing: { plan: "free" } }); div( (["free", "pro", "team"] as const).map((plan) => label( input({ type: "radio", name: "plan", ...bindPath(settings, ["billing", "plan"], { as: "radio", value: plan }), }), plan, ), ), ) ``` ### Composing with `bindField()` for arrays inside the path `bindPath` does **not** traverse arrays β€” `["items", 0, "done"]` would walk `items[0]` as if `0` were an object key on whatever the array exposes. And `bindPath` requires a `Signal` (a single record), not a `Signal` β€” calling `bindPath(orders, [...])` on an array signal is a `TypeError` at the path-walk level. For per-item fields inside an array, use `bindField()` at the array level. For nested-object fields **on a single record**, use `bindPath` at that record. To combine both β€” a CRUD grid where each row is a record with nested fields β€” factor the per-row render into its own component that owns a per-row `Signal`: ```ts interface Order { id: string; items: Array<{ id: string; sku: string; qty: number }>; shipping: { address: { line1: string; city: string } }; } const orders = signal([/* … */]); // Per-row component with its own Signal derived from the parent. // Two-way sync back to the parent array isn't free β€” wire an effect or // expose an `onChange(updated)` prop. This snippet shows the binding // shape only; pick a sync strategy for your store. const OrderRow = component((props: { initial: Order }) => { const order = signal(props.initial); return div( // bindField β€” per-cell input on the row's nested items array. each(() => order.value.items, (item) => input({ type: "number", ...bindField( // Bind against a derived Signal; in practice you // expose a sub-signal or use a setter helper here. signal(order.value.items), item, "qty", { as: "number" }, ), }), { key: (i) => i.id }, ), // bindPath β€” nested-object field on the row's single record. input({ ...bindPath(order, ["shipping", "address", "city"]) }), ); }); ``` For most apps a simpler split works: keep deeply nested fields out of array elements, or use one `Signal` per top-level entity instead of one `Signal`. `bindPath` is sharpest when paired with single-record state β€” settings panels, profile forms, single-document editors. ## Semantics - **Structural sharing on writes.** Writes produce a new root and new objects at every level on the path; **sibling branches preserve referential identity**. Downstream `computed` / `effect` that depend on unaffected branches don't re-run. - **Missing intermediates.** Reading through a missing intermediate returns `undefined`. Writing **creates** the object structure as needed β€” `bindPath(empty, ["a", "b", "c"])` writes through `{ a: { b: { c: value } } }`. If an intermediate is `null`, `undefined`, or any non-object value (string, number, boolean), the write replaces it with a fresh object. Intentional: lets you wire forms before optional records exist, but be aware that writing into a path whose ancestor is currently a primitive will clobber that primitive. - **Object keys only.** Array traversal is not supported in the path. `["items", 0]` would treat `0` as an object key, not an array index β€” usually not what you want. Use `bindField()` for array-element fields. This keeps `bindPath` predictable and its implementation small. - **Typed overloads for depths 1–4.** TypeScript checks each path key against the parent shape, giving you autocomplete + misuse detection through 4 levels. Deeper paths fall through to the loose signature (same runtime, less TS inference) β€” split into a sub-record signal if you find yourself nesting further. - **TypeError throws.** `bindPath: source must be a Signal` if you pass an unwrapped value (e.g. `bindPath(user.value, …)` instead of `bindPath(user, …)`). `bindPath: path must be a non-empty array of keys` if `path` is empty or not an array. ## When to use which | Shape | Helper | When | |---|---|---| | Single signal you own | [`bind(signal)`](https://whisq.dev/api/bind/) | One input ↔ one signal | | Field inside an item inside a signal-held array | [`bindField(source, item, key)`](https://whisq.dev/api/bindfield/) | Per-row inputs in lists, CRUD grids, editable tables | | Field at a nested object path inside a signal-held record | `bindPath(source, path)` | Multi-step forms, settings panels, `user.profile.email` shapes | The three helpers compose: a CRUD grid where each row is a record with nested fields uses `bindField` at the array level and (after factoring the row into a component with a per-row signal) `bindPath` for the inner-object cells. See also: [Reactive shapes cheat sheet](https://whisq.dev/ai/llm-reference/#reactive-shapes--pick-the-right-one), [Forms guide β†’ binding into nested records](https://whisq.dev/guides/forms/#binding-into-nested-records), [Nested Item Editing guide](https://whisq.dev/guides/nested-item-editing/) β€” when an array row hosts a nested record, the guide's pattern 3 covers the row-as-component split. [Persisted settings recipe](https://whisq.dev/guides/state-management/#recipe--persisted-settings-single-record--bindpath) β€” `bindPath` + `persistedSignal` for nested-field writes on a persisted single record. [Handler collision dev warning](https://whisq.dev/api/bind/#handler-collision-dev-warning-since-alpha9) β€” applies to `bindPath()` spreads the same way as `bind()`. --- # component() URL: https://whisq.dev/api/component/ --- Define a component with a setup function. ## Signature ```ts function component

>( setup: (props: P) => ComponentSetupReturn, ): ComponentDef

// Since alpha.9 β€” setup can return a WhisqNode OR a function child type ComponentSetupReturn = WhisqNode | (() => unknown); ``` ## Parameters | Param | Type | Description | |---|---|---| | `setup` | `(props: P) => ComponentSetupReturn` | Setup function β€” runs once. Returns either a `WhisqNode` (element-shaped β€” the canonical form) or a `() => unknown` function child (since alpha.9 β€” for branching components). | :::caution `setup` runs **once** and props are captured at that moment β€” they are not reactive by default. Pass [getter functions](https://whisq.dev/core-concepts/signals/#getters-and-accessors) (`() => signal.value`) when a parent needs to update a child's value over time. See [Components β†’ Props and reactivity](https://whisq.dev/core-concepts/components/#props-and-reactivity). ::: ## Returns `ComponentDef

` β€” a callable that accepts props and returns a `WhisqNode`. ## Examples ```ts const Counter = component((props: { initial?: number }) => { const count = signal(props.initial ?? 0); return div( button({ onclick: () => count.value-- }, "-"), span(() => ` ${count.value} `), button({ onclick: () => count.value++ }, "+"), ); }); mount(Counter({ initial: 10 }), document.getElementById("app")!); ``` ## Function-child root (since alpha.9) When a component's entire job is to branch β€” render loading / error / data, or swap between views β€” setup can return the function directly. No wrapper element required. ```ts const Screen = component(() => match( [() => view.value === "loading", () => p("loading")], [() => view.value === "data", () => DataView({})], () => p("empty"), ), ); ``` Works for any zero-arg function return β€” `when()`, `match()`, `each()` result, or a plain `() => someNode`. Under the hood, `component()` wraps the function in a fragment bounded by start/end markers (the same pattern `errorBoundary` and keyed `each` already use). Branch switches dispose the previous `WhisqNode` before inserting the next β€” no node leaks. Pick the shape by what the component *is*: - **Element root** (`div`, `section`, custom semantic element) β€” use when you need to attach class / style / events to the root. This is the canonical form. - **Function-child root** β€” use when the component *is* the branch. No wrapper DOM, no sacrificial `div`. `onMount` / `onCleanup` hooks registered inside setup fire normally in both shapes. **Before alpha.9**, the function-child form threw `WhisqStructureError` at mount; you had to wrap in a neutral element. See the [Using match() as a component root](https://whisq.dev/api/match/#using-match-as-a-component-root) section for migration notes. --- # computed() URL: https://whisq.dev/api/computed/ --- Create a read-only signal that auto-updates when dependencies change. ## Signature ```ts function computed(fn: () => T): ReadonlySignal ``` ## Parameters | Param | Type | Description | |---|---|---| | `fn` | `() => T` | Computation function β€” reads other signals | ## Returns `ReadonlySignal` β€” same as Signal but read-only (no `.set()`, `.update()`, or `.value =`). ## Examples ```ts const count = signal(3); const double = computed(() => count.value * 2); double.value; // 6 count.value = 5; double.value; // 10 β€” auto-updated ``` :::tip[🐱 Whisker Tip] Computed values are lazy and cached β€” they don't recompute until read, and reading twice without changes returns the cached value. ::: ## Related primitives - [`signal()`](https://whisq.dev/api/signal/) β€” the mutable primitive `computed()` reads from. - [`effect()`](https://whisq.dev/api/effect/) β€” side effects triggered by signal/computed changes. - [`batch()`](https://whisq.dev/api/batch/) β€” group writes so dependent computeds re-read only once. - [`partition()`](https://whisq.dev/api/partition/) β€” convenience helper that returns two computeds splitting an array signal by a predicate. --- # createContext() URL: https://whisq.dev/api/createContext/ --- Create a context object that components can `provide()` and descendants can `inject()`. ## Signature ```ts function createContext(defaultValue: T): Context ``` ## Parameters | Param | Type | Description | | -------------- | ---- | ------------------------------------------------------------ | | `defaultValue` | `T` | Returned by `inject()` when no ancestor has called `provide()` | ## Returns `Context` β€” pass to [`provide()`](https://whisq.dev/api/provide/) and [`inject()`](https://whisq.dev/api/inject/). ## Examples ```ts 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}`); }); ``` See [Components β†’ Context](https://whisq.dev/core-concepts/components/#context-provideinject) for the full pattern. --- # createRouter() URL: https://whisq.dev/api/createRouter/ --- `createRouter(config)` builds a router around a single `current` signal. Subscribing to that signal is how every other piece of router-aware UI (`RouterView`, `Link`, components that read `params` / `query`) stays reactive. Ships from the separate package `@whisq/router`, versioned lockstep with `@whisq/core`: ```bash npm install @whisq/router ``` ## Signature ```ts function createRouter(config: RouterConfig): Router; ``` ## Quick start ```ts const router = createRouter({ routes: [ { path: "/", component: Home }, { path: "/users/:id", component: UserDetail }, { path: "*", component: NotFound }, ], }); mount(RouterView(router), document.getElementById("app")!); ``` ## `RouterConfig` | Field | Type | Description | |---|---|---| | `routes` | `RouteConfig[]` | Route table. First match wins; order matters. | | `beforeEach` | `NavigationGuard` (optional) | Runs before every navigation. Return `false` to cancel, a string to redirect, or `void` / `true` to proceed. | | `afterEach` | `AfterGuard` (optional) | Runs after every successful navigation. Side-effect only β€” return value is ignored. | | `scrollBehavior` | `ScrollBehaviorOption` (optional, default `"top"`) | `"top"` scrolls to 0,0; `"restore"` remembers and restores per-path scroll on back/forward; `"auto"` and `false` leave scroll untouched. | ## `RouteConfig` | Field | Type | Description | |---|---|---| | `path` | `string` | Pattern: static (`/about`), param (`/users/:id`), or wildcard (`*`). `:name` extracts into `params.name`. | | `component` | `RouteComponent` | Either a sync `(params) => WhisqNode` or a lazy `() => import("./X")` whose default export is the component. | | `children` | `RouteConfig[]` (optional) | Nested routes. When a route has children, the parent matches as a prefix; children render into a depth-1 `RouterView`. | | `meta` | `Record` (optional) | Arbitrary per-route metadata. Merged into the resolved `RouteState.meta` in matched order. | | `beforeEnter` | `NavigationGuard` (optional) | Per-route guard. Runs after the global `beforeEach` for every matched route in the chain. | Lazy components work by returning a promise: ```ts { path: "/admin", component: () => import("./routes/Admin") } // ./routes/Admin.ts must `export default` the component. ``` ## Guards β€” `NavigationGuard` and `AfterGuard` ```ts type NavigationGuard = ( to: RouteState, from: RouteState | null, ) => boolean | string | void; type AfterGuard = (to: RouteState, from: RouteState | null) => void; ``` **Return-value semantics for `NavigationGuard`:** | Returned value | Effect | |---|---| | `true` / `undefined` | Proceed | | `false` | Cancel navigation; URL is reverted on push-navigations (back/forward keep the new URL) | | `string` | Redirect to that path; the redirect itself is pushed as a replacement of the current history entry | Guards run in order: global `beforeEach` first, then each matched route's `beforeEnter` top-down. The first `false` or string short-circuits the chain. ```ts createRouter({ routes: [...], beforeEach: (to) => { if (to.meta.requiresAuth && !isLoggedIn()) return "/login"; }, }); ``` ## `Router` interface ```ts interface Router { current: ReadonlySignal; navigate(path: string): void; back(): void; forward(): void; dispose(): void; } ``` | Member | Description | |---|---| | `current` | The reactive `RouteState` signal. Read as `router.current.value` (or pass `() => router.current.value` to `computed` / effects). | | `navigate(path)` | Push-state navigation. Runs guards. Equivalent to what `Link` dispatches on click. | | `back()` | Calls `window.history.back()`. `popstate` re-runs guards. | | `forward()` | Calls `window.history.forward()`. | | `dispose()` | Detaches the internal `popstate` listener. Call when hot-reloading or tearing down tests β€” without this the listener leaks across module re-loads. | `current` is a `ReadonlySignal` β€” you cannot write to it directly. Navigation is the only way to change the route. ## `ScrollBehaviorOption` | Value | Behaviour | |---|---| | `"top"` (default) | Scroll to `(0, 0)` on every navigation | | `"restore"` | Save scroll on leave; restore on back/forward. Forward navigations still scroll to top | | `"auto"` | Leave scroll untouched (browser default on `popstate` β€” useful with native anchor scrolling) | | `false` | Same as `"auto"` β€” never touches `window.scrollTo` | Scroll actions (both the `"top"` scrollTo and `"restore"`'s saved-position restore) are deferred to `requestAnimationFrame` so the new route has mounted before the scroll lands. ## Semantics - **Single signal per router.** `router.current` is the only reactive input β€” everything downstream (`RouterView`, `Link`'s active class, components reading `params`) derives from it. - **Guards are synchronous.** There's no async-guard ceremony β€” if you need async (e.g. auth check), gate the redirect on a cached signal instead of awaiting inside the guard. - **Wildcards are terminal.** A `{ path: "*" }` route matches everything and should come last in the table. - **Per-route params merge up.** Nested routes see their own params plus all ancestors' params in `RouteState.params`, in matched order. ## See also - [`/api/routerview/`](https://whisq.dev/api/routerview/) β€” rendering the matched component (and nested routes). - [`/api/link/`](https://whisq.dev/api/link/) β€” push-state navigation anchors. - [`/api/route-state/`](https://whisq.dev/api/route-state/) β€” reading the current route reactively. - [`/guides/routing/`](https://whisq.dev/guides/routing/) β€” routing patterns and end-to-end examples. - [`/examples/router-basics/`](https://whisq.dev/examples/router-basics/) β€” small multi-page example with params, nested routes, and a guard. --- # cx() URL: https://whisq.dev/api/cx/ --- Compose class names with conditional logic (static). :::tip[Since alpha.8] For class composition directly on an element prop, prefer the **array form on `class:`** β€” it accepts strings, falsy shorthands, and reactive getters in one place, with no separate `cx` / `rcx` decision. See [`/api/elements/#class-array-form-since-alpha8`](https://whisq.dev/api/elements/#class-array-form-since-alpha8). Reach for `cx()` when you need to compose class strings outside an element prop (utility helpers, returning a string from a function, etc.). ::: ## Signature ```ts function cx(...args: ClassValue[]): string ``` ## Parameters Accepts strings, falsy values, or objects with boolean values. ## Examples ```ts div({ class: cx("btn", isPrimary && "btn-primary", isLarge && "btn-lg") }) div({ class: cx("card", { active: true, disabled: false }) }) // Result: "card active" ``` --- # DevTools URL: https://whisq.dev/api/devtools/ --- `@whisq/devtools` is a runtime hook that attaches a read-only inspection surface to `globalThis.__WHISQ_DEVTOOLS__`. A browser extension or a console script can read the hook to inspect signals, components, and a logged event stream. Apps opt in by calling register / log functions themselves β€” **there is no auto-instrumentation**. Ships as a separate package, versioned lockstep with `@whisq/core`: ```bash npm install --save-dev @whisq/devtools ``` ## Signature ```ts function connectDevTools(): void; function disconnectDevTools(): void; ``` ## Quick start Wire the hook once, guarded by the dev flag, before `mount()`: ```ts // src/main.ts if (import.meta.env.DEV) connectDevTools(); mount(App({}), document.getElementById("app")!); ``` Then register the signals and components you want to inspect. No magic β€” you pick what's interesting: ```ts // stores/todos.ts if (import.meta.env.DEV) { // Safe: globalThis.__WHISQ_DEVTOOLS__ exists after connectDevTools(). globalThis.__WHISQ_DEVTOOLS__?.registerSignal("todos", todos); } ``` ## Runtime hook (`window.__WHISQ_DEVTOOLS__`) After `connectDevTools()`, the hook lives on the global object under `__WHISQ_DEVTOOLS__` and implements the `DevToolsHook` interface: ```ts interface DevToolsHook { version: string; // Signals registerSignal(name: string, signal: ReadonlySignal): void; unregisterSignal(name: string): void; getSignals(): SignalInfo[]; // Components registerComponent(name: string): void; unregisterComponent(name: string): void; getComponents(): string[]; // Event log logEvent(type: string, data: Record): void; getEvents(): DevToolsEvent[]; clearEvents(): void; } interface SignalInfo { name: string; value: unknown; } interface DevToolsEvent { type: string; data: Record; timestamp: number; } ``` | Method | Use | |---|---| | `registerSignal(name, signal)` | Add a named signal to the hook's ledger. | | `unregisterSignal(name)` | Remove a named signal β€” useful in HMR boundaries or component unmount. | | `getSignals()` | Snapshot of all registered signals. Reads via `signal.peek()`, so inspection does **not** subscribe the reader. | | `registerComponent(name)` | Mark a component as active. | | `unregisterComponent(name)` | Remove an active component. | | `getComponents()` | Snapshot of active component names. | | `logEvent(type, data)` | Append a timestamped event to the log (type + free-form data). | | `getEvents()` | Snapshot of the event log (copy; mutating the return doesn't touch the hook). | | `clearEvents()` | Empty the event log. | Note on `version`: the hook reports its own internal protocol version (currently `"0.0.1-alpha.0"`), independent of the `@whisq/devtools` package version. That string is what browser extensions check for compatibility. ## `disconnectDevTools()` Removes `globalThis.__WHISQ_DEVTOOLS__`. Useful when an HMR or test run re-initialises the app: ```ts // Register teardown BEFORE connecting β€” the dispose hook runs on every HMR // cycle, and we want it ready for the first hot update after this module load. if (import.meta.hot) { import.meta.hot.dispose(() => disconnectDevTools()); } if (import.meta.env.DEV) connectDevTools(); ``` Without teardown, a hot reload can leave a stale hook pointing at disposed signals β€” `getSignals()` would then `.peek()` values that no longer fire subscribers. ## When to call `connectDevTools()` - **Dev builds only.** Guard with `import.meta.env.DEV` (or your bundler's equivalent) so the hook doesn't ship to production. - **Before `mount()`.** Component setup functions can then call `registerComponent(...)` / `registerSignal(...)` during first render. - **Once, at module scope.** Calling it again overwrites the hook β€” idempotent in practice but wasteful. ## Browser extension There is no official Whisq browser extension yet. The hook is designed to be forward-compatible with one β€” the `version` field and the shape of `DevToolsHook` are the contract a future extension will consume. Until then, the hook is inspectable from the browser console: ```js // In the browser console, on a running dev build: __WHISQ_DEVTOOLS__?.getSignals(); __WHISQ_DEVTOOLS__?.getComponents(); __WHISQ_DEVTOOLS__?.getEvents(); ``` ## Semantics - **Passive surface.** `connectDevTools()` only exposes the hook object. It does not monkey-patch `signal()`, wrap `component()`, or intercept effects. What the hook knows is exactly what you've told it. - **No subscriptions.** `getSignals()` reads via `.peek()`, so nothing in the hook tracks dependencies β€” inspecting from the console won't re-run effects. - **No-op on double-register.** `registerSignal(name, …)` with an existing name replaces the previous entry via `Map.set` β€” no throw, no warn. Same for `registerComponent` (`Set.add` ignores duplicates). - **`getSignals()` / `getEvents()` return copies.** `getEvents()` spreads into a new array, so mutating the return doesn't touch the hook's internal log. Registered signals are held by reference, but the `SignalInfo` snapshot is fresh on every call. ## See also - [`/api/imports/#whisqdevtools`](https://whisq.dev/api/imports/#whisqdevtools) β€” sub-path imports for DevTools. - [Performance guide β†’ Profiling with DevTools](https://whisq.dev/advanced/performance/#profiling-with-devtools) β€” where the hook fits in a performance-investigation workflow. - [`signal()`](https://whisq.dev/api/signal/) β€” `peek()` (untracked read) is what `getSignals()` uses to snapshot values without subscribing. --- # each() URL: https://whisq.dev/api/each/ --- Render a reactive list with optional keyed DOM reconciliation. Two overloads β€” non-keyed (fresh nodes on every change) and keyed (LIS-based diffing with **hybrid accessors** for per-item reactivity β€” see the `ItemAccessor` interface below). ## Signature ```ts // Non-keyed β€” item is a plain T, nodes are recreated on every source change. function each( items: () => T[], render: (item: T, index: number) => WhisqNode, ): () => Child[]; // Keyed β€” item / index are HYBRID accessors (callable + signal-shaped). // DOM nodes are reused across source changes. function each( items: () => T[], render: (item: ItemAccessor, index: ItemAccessor) => WhisqNode, options: { key: (item: T) => unknown }, ): WhisqNode; // since alpha.8 β€” `ItemAccessor` is exported from "@whisq/core" interface ItemAccessor { (): T; // call form β€” what existed pre-alpha.8 readonly value: T; // .value form β€” canonical for new code peek(): T; // peek form β€” read without subscribing } ``` ## Parameters | Param | Type | Description | |---|---|---| | `items` | `() => T[]` | Reactive array of items | | `render` (non-keyed) | `(item: T, index: number) => WhisqNode` | Receives plain values; called on every source change | | `render` (keyed) | `(item: ItemAccessor, index: ItemAccessor) => WhisqNode` | Receives hybrid accessors β€” read via `item.value` (canonical), `item()`, or `item.peek()` | | `options.key` | `(item: T) => unknown` | Key function. Presence switches to the keyed overload. **Receives the value, not an accessor.** | ## Example β€” keyed ```ts const todos = signal([ { id: 1, text: "Learn Whisq" }, { id: 2, text: "Build an app" }, ]); ul( each( () => todos.value, (todo) => li(() => todo.value.text), // .value β€” canonical since alpha.8 { key: (t /* value, not accessor */) => t.id }, ), ) ``` The `todo()` call form (pre-alpha.8) still works and is structurally compatible with helpers typed as `() => T` β€” `bindField(todos, todo, "done", { as: "checkbox" })` and similar accept either form unchanged. New code should prefer the `.value` shape for consistency with the rest of the reactive-access rule. :::tip[🐱 Whisker Tip] Use the `key` option when items have stable identities. LIS-based diffing only moves, inserts, or removes changed DOM nodes, and the per-entry signals keep reactive getters fresh when a same-keyed item is replaced. ::: ## Reactive fields inside keyed `each()` The keyed callback's `item` is `() => T` β€” not a plain `T`. When to call `todo()` and when to wrap it in a getter isn't about whether the field "changes" β€” it's about whether the **position** you're using it in needs to re-evaluate. Four archetypes, from a `Todo = { id: string, text: string, done: Signal }`: | Position | Shape (alpha.8 canonical) | Why | |---|---|---| | Static text child | `todo.value.text` (or legacy `todo().text`) | Text renders once; `text` is a plain string. Reading at render time gives the current item. | | `bind()` on a per-item signal | `bind(todo.value.done, { as: "checkbox" })` | `bind()` receives a **signal reference**, and the `Signal` drives its own reactivity. Reading `.value` once captures the stable signal object. | | Re-evaluated position reading a signal's value | `() => todo.value.done.value && s.doneText` (inside `rcx` or a prop getter) | `rcx()` / prop getters *re-run* when dependencies change. The getter closes over `todo`, so on re-run it sees the current entry and reads `.done.value` from the stable signal. | | Event handler | `onchange: () => toggle(todo.value.id)` | Fires on user interaction β€” re-reads the current item at click time, even if the array was reshuffled since render. | ```ts each( () => todos.value, (todo) => li( input({ type: "checkbox", checked: () => todo.value.done.value, // reactive: getter re-runs, reads .value onchange: () => toggle(todo.value.id), // fresh read at click time }), todo.value.text, // static snapshot β€” plain string field ), { key: (t /* value, not accessor */) => t.id }, ) ``` **When `bind(todo.value.done, ...)` is safe:** the signal object at `todo.value.done` has stable identity β€” the store creates each todo's `done` signal once and never replaces it. If your store swaps signal objects under a same key (e.g. `todos.value = todos.value.map(t => t.id === x ? { ...t, done: newSignal } : t)`), the snapshot goes stale; use the re-evaluated shape (pass `rcx`/prop-getters or re-build the binding). The legacy `todo()` form continues to work everywhere `.value` works (the accessor is structurally `() => T`), so existing call sites do not need to migrate. Mix-and-match within a single render is fine but discouraged for readability β€” pick one form per file. ## Splitting into a component For lists past about a dozen lines of per-item render, factor the render into its own component. The accessor passes through the boundary as a prop typed as `ItemAccessor` β€” read inside the component the same way: ```ts // src/components/TodoItem.ts type Todo = { id: number; text: string; done: Signal }; type TodoItemProps = { todo: ItemAccessor; // hybrid accessor β€” read via .value (or call form) onRemove: (id: number) => void; }; ``` If your store **does** swap item objects under a same key (e.g. `todos.value = todos.value.map(t => t.id === id ? { ...t, text: newText } : t)`), wrap the text read in a getter β€” `() => props.todo.value.text` β€” so the reconciler's per-entry signal drives a re-read. The todo-app example's store keeps object identity per key, which is why the snapshot form is safe there. Call site: ```ts ul({ class: s.list }, each( () => todos.value, (todo) => TodoItem({ todo, onRemove: removeTodo }), { key: (t) => t.id }, ), ) ``` ### Why it works - **The accessor is a hybrid object.** Passing `todo` into `TodoItem({ todo })` copies the reference. Inside the component, `props.todo.value` (or `props.todo()`) calls into the same accessor that the reconciler owns β€” the reconciler writes fresh values into the per-entry signal, so reads always return the current item. - **Destructuring at the prop boundary is safe.** `(props)` or `({ todo, onRemove })` both work: you're copying the accessor reference, not reading through it. The footgun is destructuring the *result*: `const { done } = props.todo.value` captures at setup time and won't update when the reconciler swaps entries. - **The same four-archetype rule applies** β€” snapshot reads (`props.todo.value.text`), `bind()` on a stable signal (`bind(props.todo.value.done, ...)`), re-evaluated getters (`() => props.todo.value.done.value && ...`), event handlers (`() => props.onRemove(props.todo.value.id)`). Same shapes, just inside a component. The pre-alpha.8 call form (`props.todo: () => Todo`, read as `props.todo()`) still works unchanged β€” `ItemAccessor` is structurally assignable to `() => T`. Migrate at your own pace. See the [Reactive shapes cheat sheet](https://whisq.dev/ai/llm-reference/#reactive-shapes--pick-the-right-one) for the full four-shape taxonomy. For the decision matrix on which per-item-editing pattern to reach for (plain immutable / nested signal / extracted child), see the [Nested Item Editing guide](https://whisq.dev/guides/nested-item-editing/). For the canonical wrong/why/right pairings on `ItemAccessor` reads, see [Keyed `each()` mistakes in `/common-mistakes/`](https://whisq.dev/common-mistakes/#keyed-each-mistakes) and [Hoisting `.value.field` out of a reactive getter](https://whisq.dev/common-mistakes/#hoisting-valuefield-out-of-a-reactive-getter) for the general stale-capture trap. ## Related primitives - [`when()`](https://whisq.dev/api/when/), [`match()`](https://whisq.dev/api/match/) β€” pair with `each()` for empty-state messaging around a list. - [`bindField()`](https://whisq.dev/api/bindfield/) β€” two-way bind a field on the items `each()` iterates. - [`partition()`](https://whisq.dev/api/partition/) β€” split the source array into two derived signals before rendering (e.g. active vs done). - [`signalMap()`](https://whisq.dev/api/signalmap/), [`signalSet()`](https://whisq.dev/api/signalset/) β€” iterate reactive collections whose membership changes. --- # effect() URL: https://whisq.dev/api/effect/ --- Run a side effect that re-executes when its signal dependencies change. See [How Reactivity Works](https://whisq.dev/core-concepts/reactivity-model/) for the underlying mental model. ## Signature ```ts function effect(fn: () => void | (() => void)): () => void ``` ## Parameters | Param | Type | Description | |---|---|---| | `fn` | `() => void \| (() => void)` | Effect function. Can return a cleanup function. | ## Returns `() => void` β€” dispose function that stops the effect. ## Examples ```ts const count = signal(0); const dispose = effect(() => { console.log(`Count: ${count.value}`); }); // Logs: "Count: 0" count.value = 1; // Logs: "Count: 1" dispose(); // Stop watching count.value = 2; // No log ``` ### With cleanup ```ts effect(() => { const timer = setInterval(() => tick(), 1000); return () => clearInterval(timer); }); ``` ## Related primitives - [`signal()`](https://whisq.dev/api/signal/) β€” the mutable primitive; `effect()` re-runs when a tracked signal changes. - [`computed()`](https://whisq.dev/api/computed/) β€” derived values; prefer over `effect()` when you need a readable result. - [`onCleanup()`](https://whisq.dev/api/oncleanup/) β€” component-scoped cleanup for effects started inside a component. - [`batch()`](https://whisq.dev/api/batch/) β€” batch multiple writes into one effect re-run. --- # Element Functions URL: https://whisq.dev/api/elements/ --- Every HTML element is a typed function. ## Signature ```ts function div(props?: CommonProps | Child, ...children: Child[]): WhisqNode function span(props?: CommonProps | Child, ...children: Child[]): WhisqNode function button(props?: ButtonProps | Child, ...children: Child[]): WhisqNode function input(props?: InputProps): WhisqNode // ... and 30+ more ``` ## Two Call Signatures ```ts // With props + children div({ class: "card", id: "main" }, h1("Title"), p("Content")) // Without props β€” just children div(h1("Title"), p("Content")) // Single text child h1("Hello Whisq") ``` ## Available Elements **Layout:** `div`, `span`, `main`, `section`, `article`, `aside`, `header`, `footer`, `nav` **Text:** `h1`–`h6`, `p`, `strong`, `em`, `small`, `pre`, `code` **Interactive:** `button`, `a` **Forms:** `form`, `input`, `textarea`, `select`, `option`, `label` **Lists:** `ul`, `ol`, `li` **Table:** `table`, `thead`, `tbody`, `tr`, `th`, `td` **Media:** `img`, `video`, `audio` **Misc:** `br`, `hr`, `iframe` ## Reactive Props Pass a function for any prop to make it reactive: ```ts div({ class: () => active.value ? "on" : "off" }) div({ style: () => `color: ${color.value}` }) div({ hidden: () => !visible.value }) ``` ### `class:` array form (since alpha.8) `class:` also accepts an **array** of sources. Strings are class names; falsy values (`false | null | undefined | 0 | ""`) are filtered out; functions are reactive (re-run when tracked signals change). If any element is a function, the whole array is applied reactively; otherwise it's applied once at mount. ```ts div({ class: [ "btn", // static () => `btn-${variant.value}`, // reactive loading.value && "btn-loading", // static conditional shorthand () => isDisabled.value && "disabled", // reactive conditional ], }); ``` Eliminates the [`cx`](https://whisq.dev/api/cx/) vs [`rcx`](https://whisq.dev/api/rcx/) decision for the common case β€” reach for those only when composing class strings outside an element prop. #### Rules The runtime walks the array once and joins the kept sources with a single space. Exact behaviour: - **Skipped (filtered out):** `false`, `null`, `undefined`, `0`, `""`. This is what makes the `cond && "active"` shorthand work β€” a false `cond` short-circuits to the falsy boolean and is dropped. - **Strings are kept as-is.** They are not parsed, trimmed, or split on spaces β€” `"btn primary"` is added verbatim, so you can still pack multiple class names into a single string if you want. - **Truthy non-strings (not functions) are silently dropped.** Numbers other than `0`, plain objects, arrays, and `true` are not coerced via `String()` β€” they fall through the loop and are not added. The TypeScript type (`ClassArraySource`) refuses them at the boundary; this rule is the runtime contract for cases that slip through (e.g. `any`, untyped data). - **Nested arrays are NOT flattened.** A nested array is a non-string non-function value, so it's dropped rather than recursed into: ```ts // ❌ "btn" only β€” the inner array is dropped div({ class: ["btn", ["primary", "lg"]] }); // βœ… Spread the inner array, or flatten it yourself div({ class: ["btn", ...["primary", "lg"]] }); ``` - **Functions are called in a reactive getter**, and their **return value follows the same rules**: a truthy string is kept, a falsy value (`false | null | undefined | 0 | ""`) is skipped. A function that returns an array is not flattened β€” the array ends up joined via its default `.toString()` (comma-joined), which is almost certainly a bug. Return a pre-joined string instead: ```ts // ❌ Produces `class="btn a,b"` β€” Array.toString runs div({ class: ["btn", () => active.value ? ["a", "b"] : ""] }); // βœ… Return a string with spaces β€” or use separate entries div({ class: ["btn", () => active.value && "a b"] }); div({ class: ["btn", () => active.value && "a", () => active.value && "b"] }); ``` ### ARIA attributes (since alpha.9) For guide-level treatment (focus management, semantic HTML choices, escape hatch), see the [Accessibility guide](https://whisq.dev/guides/accessibility/). The reference below is the prop surface and serialisation rules. Every element accepts typed `aria-*` props. Mirrors the `data-*` pattern but with a wider value type, because ARIA uses both enum-strings (`aria-live: "polite"`) and predicate-booleans (`aria-expanded`, `aria-hidden`, `aria-pressed`). Types are `ReactiveProp` β€” static values, reactive getters, or `undefined` to remove the attribute. ```ts // Static button({ "aria-label": "Remove" }, "Γ—"); // Reactive button({ "aria-expanded": () => menuOpen.value }, "Menu"); div({ "aria-live": "polite", "aria-atomic": true }, () => status.value); // Conditional removal div({ "aria-hidden": () => visible.value ? undefined : true }); ``` Serialisation rules: | Value | Serialised as | |---|---| | `"polite"` / string | `"polite"` | | `true` | `"true"` (per ARIA spec β€” fixed in alpha.9) | | `false` | `"false"` | | `undefined` / `null` | attribute removed | **Note**: pre-alpha.9, `true` serialised to the empty string `aria-expanded=""`, which is **invalid** per the ARIA spec (not equivalent to `aria-expanded="true"`). Alpha.9 routes `aria-*` through a dedicated branch in the prop applier to produce the correct string forms. ## Events ```ts button({ onclick: () => count.value++ }, "Click") input({ oninput: (e) => name.value = e.target.value }) ``` --- # errorBoundary() URL: https://whisq.dev/api/errorBoundary/ --- Wrap a subtree with a fallback that renders when something inside throws. Prefer this over a hand-rolled `try`/`catch` in a component setup. ## Signature ```ts function errorBoundary( fallback: (error: Error, retry: () => void) => WhisqNode, child: () => WhisqNode, ): WhisqNode ``` ## Parameters | Param | Type | Description | | ---------- | --------------------------------------------------- | ------------------------------------------------------------------------ | | `fallback` | `(error: Error, retry: () => void) => WhisqNode` | Rendered when the wrapped subtree throws. `retry` re-runs `child` | | `child` | `() => WhisqNode` | The protected subtree, wrapped in a thunk so `retry` can re-invoke it | ## Examples ```ts errorBoundary( (error, retry) => div({ class: "error-boundary" }, p(`Something went wrong: ${error.message}`), button({ onclick: retry }, "Try again"), ), () => RiskyComponent({}), ); ``` See [Advanced β†’ Error Boundaries](https://whisq.dev/advanced/error-boundaries/) for granular wrapping, error reporting, and how the primitive composes with [`resource()`](https://whisq.dev/api/resource/). --- # Event types URL: https://whisq.dev/api/event-types/ --- The framework's exported types for typed event handlers and accessor / error shapes. Type-only imports (no runtime cost) β€” use them when you want a named handler whose `currentTarget` narrows to the element it's attached to, or when you're typing a prop that receives a keyed-`each()` accessor. ```ts ``` ## `EventHandler` The handler signature for a typed DOM event with a narrowed `currentTarget`. ```ts type EventHandler = ( event: E & { currentTarget: T }, ) => void; ``` Use when you want to extract a named handler from a component setup β€” types stay correct, `currentTarget` narrows to the right element type, and the return type stays `void` (handlers never need to return). ```ts const onSubmit: EventHandler = (e) => { e.preventDefault(); e.currentTarget.reset(); }; form({ onsubmit: onSubmit }, /* ... */); ``` ## `WhisqEvent` Look up the event type from a DOM event name (as used in `HTMLElementEventMap`) and narrow the `currentTarget` to a target element. ```ts type WhisqEvent< K extends keyof HTMLElementEventMap, T extends Element = Element, > = HTMLElementEventMap[K] & { currentTarget: T }; ``` Use when you want to name the exact event, instead of writing `KeyboardEvent & { currentTarget: HTMLInputElement }` by hand: ```ts function onSearchKey(e: WhisqEvent<"keydown", HTMLInputElement>) { if (e.key === "Enter") submit(e.currentTarget.value); } input({ onkeydown: onSearchKey }); ``` ## When to use which | If you have... | Reach for... | Example | |---|---|---| | The event class (`SubmitEvent`, `MouseEvent`) | `EventHandler` | `EventHandler` | | The event name (`"keydown"`, `"input"`) | `WhisqEvent` | `WhisqEvent<"keydown", HTMLInputElement>` | Both are interchangeable in practice; pick by which name you already have in your head. ## `ItemAccessor` (since alpha.8) The hybrid accessor passed to a keyed `each()`'s render callback. ```ts interface ItemAccessor { (): T; // call form β€” pre-alpha.8 backwards-compat readonly value: T; // .value form β€” canonical for new code peek(): T; // peek form β€” read without subscribing } ``` Use when typing a prop that receives a keyed-`each()` accessor β€” typically when extracting per-row markup into a child component: ```ts type TodoItemProps = { todo: ItemAccessor; onRemove: (id: string) => void }; const TodoItem = component((props: TodoItemProps) => li({ onclick: () => props.onRemove(props.todo.value.id) }, () => props.todo.value.text), ); ``` `ItemAccessor` is structurally assignable to `() => T`, so it works unchanged with helpers typed as `() => T` (e.g. `bindField`). See [`/api/each/`](https://whisq.dev/api/each/) and [Nested Item Editing guide](https://whisq.dev/guides/nested-item-editing/). ## Dev-mode error classes Both error classes are runtime exports (not pure types) β€” you can `instanceof` them, but production builds strip the throw sites in most cases. ### `WhisqKeyByError` (since alpha.8) Thrown by `bindField()` in dev mode (or with `strict: true` in any env) when a write can't find an item in the source array whose `keyBy(...)` matches the accessor's key. Carries `sourceKeys`, `targetKey`, and `field` for debugging. ```ts interface WhisqKeyByErrorFields { sourceKeys: unknown[]; // keys present in the source at write time targetKey: unknown; // the key the write was looking for field: string; // the field bindField was writing } class WhisqKeyByError extends Error implements WhisqKeyByErrorFields { /* ... */ } ``` Full reference and the `strict` matrix: [`/api/bindfield/#dev-mode-behaviour-and-the-strict-option`](https://whisq.dev/api/bindfield/#dev-mode-behaviour-and-the-strict-option). ### `WhisqStructureError` Thrown by element / `each()` / `match()` / `component()` in dev mode when an API receives a value it can't render. Carries `expected`, `received`, `element`, and an optional `hint`. ```ts interface WhisqStructureErrorFields { expected: string; received: string; element: string; hint?: string; callsite?: string; } class WhisqStructureError extends Error implements WhisqStructureErrorFields { /* ... */ } ``` Full coverage of every dev-mode error message and its fix: [`/common-mistakes/#about-whisqstructureerror`](https://whisq.dev/common-mistakes/#about-whisqstructureerror). ## See also - [`bindField()`](https://whisq.dev/api/bindfield/) β€” uses `WhisqKeyByError` and `EventHandler` shapes. - [`each()`](https://whisq.dev/api/each/) β€” origin of `ItemAccessor`. - [Forms guide](https://whisq.dev/guides/forms/) β€” typed handler patterns in action. - [LLM reference β€” Typed Event Handlers](https://whisq.dev/ai/llm-reference/) β€” canonical snippet. --- # h() URL: https://whisq.dev/api/h/ --- Create an element by tag name. Use `h()` for web components, SVG, custom tags, or any element not covered by the named element functions (`div()`, `button()`, etc.). ## Signature ```ts function h( tag: string, props?: Props, ...children: Child[] ): WhisqNode ``` ## Parameters | Param | Type | Description | | ---------- | ----------- | ------------------------------------------------- | | `tag` | `string` | Tag name (`"div"`, `"my-component"`, `"circle"`) | | `props` | `Props` | Optional props object β€” same shape as named elements | | `children` | `Child[]` | Child nodes, strings, or reactive getters | ## Examples ```ts // Web component h("color-picker", { value: () => selectedColor.value, onchange: (e) => (selectedColor.value = e.target.value), }); // SVG h("svg", { viewBox: "0 0 24 24", width: "24", height: "24" }, h("circle", { cx: "12", cy: "12", r: "10", fill: "currentColor" }), ); // Dynamic tag name const tagName = "section"; h(tagName, { class: "card" }, "Hello"); ``` See [Custom Elements](https://whisq.dev/advanced/custom-elements/) for web-component, SVG, and wrapper patterns. --- # Imports URL: https://whisq.dev/api/imports/ --- Every public `@whisq/*` package and entry point, verified against the published `package.json` `exports` field. Use this page to confirm where a given symbol lives before writing an import line. If a symbol isn't listed here, it either hasn't been published yet or it's internal (not part of the public API). ## `@whisq/core` The primary package. Most application imports come from here. ### `.` (main entry) ```ts ``` **Reactive primitives:** `signal`, `computed`, `effect`, `batch` Β· types: `Signal`, `ReadonlySignal` **Element helpers** (every HTML element is exported): `div`, `span`, `a`, `img`, `button`, `input`, `textarea`, `select`, `option`, `label`, `form`, `h1`, `h2`, `h3`, `h4`, `h5`, `h6`, `p`, `strong`, `em`, `small`, `pre`, `code`, `ul`, `ol`, `li`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, `header`, `footer`, `nav`, `main`, `section`, `article`, `aside`, `audio`, `video`, `iframe`, `br`, `hr` Β· type: `WhisqNode` **Hyperscript helper:** `h` (for tags not pre-exported), `raw` (inject HTML strings) **Rendering:** `mount`, `each`, `match`, `when`, `errorBoundary`, `portal`, `transition` **Components & lifecycle:** `component`, `onMount`, `onCleanup`, `resource`, `createContext`, `provide`, `inject`, `useHead` Β· types: `ComponentDef`, `Resource`, `ResourceOptions`, `ResourceSourceOptions`, `InjectionKey` **Refs:** `ref` Β· types: `ElementRef`, `Ref` **Event types:** `EventHandler`, `WhisqEvent` (types only) **Bind:** [`bind`](https://whisq.dev/api/bind/) (single-signal two-way binding with `as: "text" | "number" | "checkbox" | "radio"` options), [`bindField`](https://whisq.dev/api/bindfield/) (field-inside-signal-array binding for list items, with `keyBy` defaulting to `t => t.id`) Β· types: `BindOptions`, `TextBind`, `NumberBind`, `CheckboxBind`, `RadioBind`, `BindFieldOptions` **Styling:** `sheet`, `theme`, `styles`, `sx`, `cx`, `rcx` Β· types: `StyleObject`, `ThemeOptions` **Dev diagnostics:** `WhisqStructureError` (runtime class for dev-mode structure violations), `WhisqKeyByError` (runtime class thrown by `bindField` on no-match writes in dev mode β€” see [`/api/bindfield/`](https://whisq.dev/api/bindfield/#dev-mode-behaviour-and-the-strict-option)) Β· types: `WhisqStructureErrorFields`, `WhisqKeyByErrorFields` ### `./collections` Reactive collections with per-key / per-value subscriptions, plus collection helpers. Lives on a sub-path so the main entry stays under 5 KB. ```ts ``` **Exports:** `signalMap()`, `signalSet()`, [`partition()`](https://whisq.dev/api/partition/) (since alpha.8 β€” split a signal-held array into matching / not-matching derived signals) ### `./persistence` localStorage-backed signals. The [State Management guide](https://whisq.dev/guides/state-management/#persistence-localstorage) covers the full option surface (`storage`, `serialize`/`deserialize`, `schema`) and the SSR/quota/schema-validation behaviors the helper handles internally. ```ts const todos = persistedSignal("whisq.todos", []); ``` **Exports:** `persistedSignal()` β€” creates a `Signal` that loads from localStorage on module init and writes on every change. See [`PersistedSignalOptions`](https://github.com/whisqjs/whisq/blob/main/packages/core/src/persistence.ts) in the framework source for configuration options (serializer, skip-initial-write, etc.). ### `./forms` Deep-path form bindings. Sub-path so apps that only need `bind()` and `bindField()` (the 80% case) pay no bundle cost β€” the default `bind` / `bindField` exports stay on the main entry. ```ts form( input({ ...bindPath(user, ["profile", "name"]) }), input({ type: "checkbox", ...bindPath(user, ["prefs", "dark"], { as: "checkbox" }) }), ); ``` **Exports:** [`bindPath()`](https://whisq.dev/api/bindpath/) β€” two-way binding for a field at an arbitrary object path inside a signal-held record. Mirrors `bind()` / `bindField()` discriminator shapes (text / number / checkbox / radio). See [`/api/bindpath/`](https://whisq.dev/api/bindpath/) for the full reference. ### `./ids` Random-id generation. Sub-path so apps that don't need client-side id generation (e.g. SSR apps where the server hands out ids) pay no bundle cost. Since alpha.8. ```ts const todo = { id: randomId(), text: "Learn Whisq", done: false }; ``` **Exports:** [`randomId()`](https://whisq.dev/api/randomid/) β€” returns a UUID-v4-shaped string. Prefers native `crypto.randomUUID()` when available; falls back to a `Math.random` synthesis with the same v4 shape for older targets. Suitable for UI row keys and `each({ key })` ids β€” **not** a security primitive. ## `@whisq/router` Signal-based router for Whisq applications β€” a single `router.current` signal, push-state navigation, nested routes, guards, and scroll restoration. ```ts ``` API reference: | Export | Page | |---|---| | `createRouter()` | [`/api/createrouter/`](https://whisq.dev/api/createrouter/) | | `RouterView()` | [`/api/routerview/`](https://whisq.dev/api/routerview/) | | `Link()` | [`/api/link/`](https://whisq.dev/api/link/) | | Reading route state | [`/api/route-state/`](https://whisq.dev/api/route-state/) | End-to-end walkthrough: [Router basics example](https://whisq.dev/examples/router-basics/). Patterns: [Routing guide](https://whisq.dev/guides/routing/). ## `@whisq/ssr` Server-side rendering for Whisq applications. ```ts ``` Used from a server framework (Vite SSR, Node HTTP, etc.) to produce initial HTML. Pairs with `@whisq/vite-plugin` for the build-side plumbing. ## `@whisq/vite-plugin` Vite plugin for Whisq β€” file-based routing, HMR, optimized builds. ```ts // vite.config.ts ``` ## `@whisq/testing` Testing utilities for Whisq components β€” `render`, `screen` queries, `fireEvent`. ```ts ``` See [`/guides/testing/`](https://whisq.dev/guides/testing/) for the patterns the docs use. ## `@whisq/devtools` DevTools runtime hook β€” signal inspection, component tree, effect tracking. ```ts if (import.meta.env.DEV) connectDevTools(); ``` Installs `globalThis.__WHISQ_DEVTOOLS__` β€” a passive surface a browser extension or console script can read. Apps opt in by calling `registerSignal` / `registerComponent` / `logEvent` themselves; there's no auto-instrumentation. Full API: [`/api/devtools/`](https://whisq.dev/api/devtools/). ## `@whisq/sandbox` Sandboxed code execution β€” isolated environment with configurable limits. Used internally by the whisq.dev playground. ```ts ``` Primarily for tooling that needs to run untrusted Whisq code (playgrounds, snippet runners, MDX live examples). End-user app code doesn't typically import this. ## Versioning All packages release in lockstep under the same `0.x.y-alpha.N` tag. The `whisqCoreVersion` field in this repo's `package.json` pins the version these docs describe β€” currently the version at the bottom of every docs page. The drift-check script (`scripts/check-llm-reference-sync.mjs`) verifies the LLM reference card stays in sync with `@whisq/core`'s published public-api manifest. There's no equivalent check for the other packages yet β€” if a secondary-package example on this site doesn't compile against a fresh install, the package probably moved faster than the docs did. **For AI/scaffolder workflows:** resolve the current version from the npm registry, never from a CDN `@latest` alias β€” see [LLM Reference β†’ Current version](https://whisq.dev/ai/llm-reference/#current-version). --- # inject() URL: https://whisq.dev/api/inject/ --- Read the value of a context provided by an ancestor. Call from inside a `component()` setup. Returns the context's `defaultValue` when no ancestor has called [`provide()`](https://whisq.dev/api/provide/). ## Signature ```ts function inject(context: Context): T ``` ## Parameters | Param | Type | Description | | --------- | ------------ | ------------------------------------------------- | | `context` | `Context` | The context object returned by `createContext()` | ## Returns `T` β€” the nearest ancestor's provided value, or the context's default if none. ## Examples ```ts const ThemeCtx = createContext("light"); const Child = component(() => { const theme = inject(ThemeCtx); return p(`Theme: ${theme}`); }); ``` See [`provide()`](https://whisq.dev/api/provide/) for the producer side and [Components β†’ Context](https://whisq.dev/core-concepts/components/#context-provideinject) for the full pattern. --- # match() URL: https://whisq.dev/api/match/ --- Render one of several UI branches based on which predicate is true. Use `match()` when `when()` would chain three or more times β€” the canonical example is the `resource()` loading/error/data tri-state. For the "when, match, or inline ternary?" decision, see [Choosing Patterns β†’ Conditional rendering](https://whisq.dev/core-concepts/choosing-patterns/#conditional-rendering--when-match-or-inline-ternary). ## Signature ```ts type MatchRender = () => WhisqNode | string | null; type MatchBranch = readonly [() => boolean, MatchRender]; function match(...branches: MatchBranch[]): () => Child; function match(...args: [...MatchBranch[], MatchRender]): () => Child; ``` ## Parameters Each branch is a tuple `[predicate, render]`. An optional trailing bare render function (not wrapped in a tuple) acts as a fallback when no predicate matches. | Param | Type | Description | | ----------- | --------------------------------------------------- | ------------------------------------------------ | | `branches` | `MatchBranch[]` | `[() => boolean, () => WhisqNode \| string \| null]` tuples | | `fallback` | `MatchRender` (trailing, optional) | Rendered when no branch predicate is truthy | ## Returns `() => Child` β€” a reactive child that re-evaluates its branches on every read. Pass it as a child to any element. ## Semantics - **First-true-wins.** Branches are evaluated top-to-bottom; the first with a truthy predicate renders. Later branches are skipped. - **Fallback is optional.** Without one, `match()` renders `null` when no branch matches. - **Reactive.** Like any function child, `match()` re-evaluates whenever any signal it touches changes. ## What `match()` isn't `match()` is a **predicate chain**, not pattern matching or value dispatch. It does not accept an object map of values to branches. ```ts // ❌ Object form β€” not supported. Renders nothing and throws at render time. match({ loading: () => p("Loading…"), error: () => p("Failed"), data: () => ul(/* ... */), }); // βœ… Predicate chain β€” first-true-wins. match( [() => state.value === "loading", () => p("Loading…")], [() => state.value === "error", () => p("Failed")], [() => state.value === "data", () => ul(/* ... */)], ); ``` If you actually need value dispatch (one signal, many discrete cases) and the predicates are noisy to write, use a `switch` inside a getter child: ```ts const state = signal<"loading" | "error" | "data">("loading"); div(() => { switch (state.value) { case "loading": return p("Loading…"); case "error": return p("Failed"); case "data": return ul(/* ... */); } }); ``` The `switch`-in-a-getter form re-evaluates whenever `state` changes (the getter is a reactive position). Use it when every branch maps cleanly to one value of one signal; reach for `match()` when branches mix predicates against different signals (the canonical example: `loading()` / `error()` / `data()` accessors on `resource()`). :::caution[If you hit `WhisqStructureError: match: expected a branch tuple…`] You passed an object literal (or other non-tuple) where the framework expected `[() => boolean, () => WhisqNode]` tuples. The dev-mode validator catches this at the call site so you don't fall through to a confusing native error. Convert each entry to a tuple β€” see the `state.value === "loading"` example above. Full debugging entry: [Common Mistakes β†’ match() given an object](https://whisq.dev/common-mistakes/#match-given-an-object-instead-of-tuples). The validator is stripped from production bundles via `process.env.NODE_ENV` guards β€” see [About `WhisqStructureError`](https://whisq.dev/common-mistakes/#about-whisqstructureerror) for the wider story. ::: ## Examples ```ts const users = resource(() => fetch("/api/users").then((r) => r.json())); div( match( [() => users.loading(), () => p("Loading…")], [() => !!users.error(), () => div( p(() => `Error: ${users.error()!.message}`), button({ onclick: () => users.refetch() }, "Retry"), )], [() => !!users.data(), () => ul(each(() => users.data()!, (u) => li(u.name)))], ), ); ``` Ordering matters β€” put the most specific predicate first: ```ts const count = signal(0); match( [() => count.value > 10, () => p("More than ten")], [() => count.value > 0, () => p("Some")], // skipped when count > 10 () => p("None"), // fallback ); ``` See [Conditional Rendering](https://whisq.dev/core-concepts/conditional-rendering/#match--three-or-more-branches) for the full narrative arc. ## Using `match()` as a component root When a component's job is to branch β€” render loading / error / data, or swap between views β€” `match()` can be the component root directly since alpha.9. No wrapper element required. ```ts const StatusView = component(() => match( [() => loading.value, () => Spinner({})], [() => !!error.value, () => ErrorPanel({ err: error.value })], () => DataView({}), ), ); ``` This works because alpha.9 widened `component()`'s setup return to accept a `() => unknown` function in addition to a `WhisqNode` β€” the framework wraps the function in a fragment bounded by start/end markers internally. See [`/api/component/#function-child-root-since-alpha9`](https://whisq.dev/api/component/#function-child-root-since-alpha9) for the mechanism. ### When to wrap in an element anyway If you need the component root to carry class / style / events, wrap in an element: ```ts // Wrapping the branch in a semantic element when the root needs class / style / events. ``` Pick the shape by intent: function-child root when the component *is* the branch, element root when the component is "a branch wearing class / aria attributes". ### Pre-alpha.9 behaviour Before alpha.9, `component(() => match(...))` threw `WhisqStructureError` at mount β€” the framework required a `WhisqNode` return, and `match()` returns a function. The wrapping-element pattern above was the only option. Migrating to alpha.9+ is zero-diff for that shape (the wrapper still works); dropping the wrapper is opt-in. --- # mount() URL: https://whisq.dev/api/mount/ --- Attach a WhisqNode to a DOM element. ## Signature ```ts function mount(node: WhisqNode, container: Element): () => void ``` ## Parameters | Param | Type | Description | |---|---|---| | `node` | `WhisqNode` | The node to mount | | `container` | `Element` | Target DOM element | ## Returns `() => void` β€” dispose function that unmounts and cleans up all effects. ## Examples ```ts const dispose = mount( div("Hello Whisq"), document.getElementById("app")!, ); // Later: unmount dispose(); ``` --- # onCleanup() URL: https://whisq.dev/api/onCleanup/ --- Register a function that runs when the component unmounts. ## Signature ```ts function onCleanup(fn: () => void): void ``` ## Parameters | Param | Type | Description | |---|---|---| | `fn` | `() => void` | Cleanup function | ## Examples ```ts const Sub = component(() => { const sub = eventBus.subscribe(handler); onCleanup(() => sub.unsubscribe()); return div("Listening"); }); ``` --- # onMount() URL: https://whisq.dev/api/onMount/ --- Register a callback that runs after the component is inserted into the DOM. ## Signature ```ts function onMount(fn: () => void | (() => void)): void ``` ## Parameters | Param | Type | Description | |---|---|---| | `fn` | `() => void \| (() => void)` | Mount callback. Can return a cleanup function. | ## Examples ```ts const App = component(() => { onMount(() => { console.log("mounted!"); return () => console.log("cleanup!"); }); return div("Hello"); }); ``` :::tip[🐱 Whisker Tip] Must be called inside a `component()` setup function. Throws if called outside. ::: --- # partition() URL: https://whisq.dev/api/partition/ --- Split a signal-held array into two derived signals β€” **the first matches the predicate, the second doesn't**. Both sides re-compute when the source changes; source order is preserved on both sides. Shipped from the **sub-path** `@whisq/core/collections`, not the main entry. ## Signature ```ts function partition( source: () => T[], predicate: (item: T) => boolean, ): [ReadonlySignal, ReadonlySignal]; ``` ## Parameters | Param | Type | Description | | ----------- | --------------------- | ---------------------------------------------------------------------------- | | `source` | `() => T[]` | Reactive accessor for the source array. Typically `() => mySignal.value`. | | `predicate` | `(item: T) => boolean` | Returns `true` to send the item to the first side (`matching`), `false` for the second side (`notMatching`). | ## Returns A tuple `[matching, notMatching]` of `ReadonlySignal`. Each side is an independent `computed()` β€” subscribing to one does **not** subscribe to the other, so an effect reading only `pending.value` doesn't re-run when only items in `done` change. ## Example β€” todo "active vs done" ```ts interface Todo { id: string; text: string; done: boolean } const todos = signal([ { id: "1", text: "Learn Whisq", done: false }, { id: "2", text: "Ship it", done: true }, ]); const [pending, done] = partition(() => todos.value, (t) => !t.done); // Read either side independently β€” subscribers don't cross-pollinate. p(() => `${pending.value.length} left`); p(() => `${done.value.length} done`); button({ onclick: () => todos.value = pending.value }, "Clear completed"); ``` ## Why not just two `computed`s? You could write: ```ts const pending = computed(() => todos.value.filter((t) => !t.done)); const done = computed(() => todos.value.filter((t) => t.done)); ``` That works and is fine for short cases. `partition` is a one-line replacement that: - Keeps the predicate in one place β€” the `notMatching` side is automatically the inverse, so there's no risk of the two predicates drifting out of sync. - Returns a tuple destructured at the call site, matching how partitioning reads in most other languages and libraries. Reactivity behaviour is identical to two `computed()`s β€” both sides use reference-equality on the produced arrays (matching `computed()` semantics). If you need structural-equality semantics, that's the caller's job β€” wrap the result in your own equality-checked `computed()`. ## Semantics - **Source order preserved.** Both sides receive items in the order they appear in `source()`. No re-sorting. - **Reference equality on output.** Re-runs only fire when the partitioned array would change in content (per `computed()`'s default equality). - **Sub-path import.** Lives on `@whisq/core/collections` to keep the main entry under 5 KB. See also: [`computed()`](https://whisq.dev/api/computed/), [`/api/imports/#collections`](https://whisq.dev/api/imports/#collections), [State Management guide](https://whisq.dev/guides/state-management/), [`bindField()` β†’ Writing through filtered / partitioned views](https://whisq.dev/api/bindfield/#writing-through-filtered--partitioned-views) β€” pass the source signal to `bindField`, not the partitioned side, when editing per-row inside a filtered `each()`. --- # persistedSignal() URL: https://whisq.dev/api/persistedSignal/ --- A `Signal` backed by `localStorage` (or `sessionStorage`). Loads from storage on module init, writes on every change. SSR-safe, quota-safe, schema-validated. Use for small, human-readable JSON state β€” settings, draft form data, todo lists, theme preferences. Shipped from the **sub-path** `@whisq/core/persistence`, not the main entry. The "no import-time I/O" rule for `fetch()` doesn't apply here β€” `localStorage` reads are synchronous and local-only, so the read-once-at-module-load shape is the natural fit. ## Signature ```ts function persistedSignal( key: string, initial: T, options?: PersistedSignalOptions, ): Signal; interface PersistedSignalOptions { storage?: "local" | "session"; serialize?: (value: T) => string; deserialize?: (raw: string) => T; schema?: (raw: unknown) => T; onSchemaFailure?: (err: unknown, raw: string) => void; // since alpha.8 } ``` ## Parameters | Param | Type | Description | | --------- | ----------------------------- | ------------------------------------------------------------------------ | | `key` | `string` | Storage key (namespace your app's keys to avoid collisions). | | `initial` | `T` | Initial value used on first visit, on SSR, or when validation rejects. | | `options` | `PersistedSignalOptions?` | See **Options** below. | ## Returns A standard [`Signal`](https://whisq.dev/api/signal/) β€” same `.value` / `.peek()` / `.update()` / `.subscribe()` surface. The persistence is invisible to consumers; reads and writes look exactly like a plain `signal()`. ## Quick start ```ts // stores/todos.ts interface Todo { id: string; text: string; done: boolean } // Mutate normally β€” every assignment persists. }; ``` The first read on first visit returns `[]` (the `initial`); subsequent reads return whatever was last written. ## Options ### `storage` `"local"` (default; persists across tabs and reloads) or `"session"` (per-tab, cleared when the tab closes). Both backends share the same SSR / quota / schema-validation behaviour. ```ts const draft = persistedSignal("draft", "", { storage: "session" }); ``` ### `serialize` / `deserialize` Override the default `JSON.stringify` / `JSON.parse` for non-JSON storage formats β€” compressed strings, custom encodings, BigInt-aware serialization. ```ts const big = persistedSignal("big", 0n, { serialize: (v) => v.toString(), deserialize: (raw) => BigInt(raw), }); ``` ### `schema` Validate the deserialized value before adopting it. Return `T` on success; throw to reject and fall back to `initial`. Most useful for version migrations: ```ts const settings = persistedSignal("settings", DEFAULT_SETTINGS, { schema: (raw) => { // alpha shape had `theme: string`; current shape has `theme: { mode, accent }`. if (typeof raw === "object" && raw && typeof (raw as any).theme === "string") { return { ...(raw as object), theme: { mode: (raw as any).theme, accent: "blue" } } as Settings; } if (typeof raw === "object" && raw && typeof (raw as any).theme === "object") { return raw as Settings; } throw new Error("settings: unrecognized stored shape"); }, }); ``` ### `onSchemaFailure` (since alpha.8) Diagnostic hook fired synchronously **before** the helper falls back to `initial` when `deserialize` throws (malformed stored JSON) or `schema` throws (validator rejects). Receives the thrown error and the exact raw string read from storage. ```ts const todos = persistedSignal("todos", [], { schema: validateTodosShape, onSchemaFailure: (err, raw) => { Sentry.captureException(err, { extra: { key: "todos", raw } }); }, }); ``` | Behaviour | Detail | | --------------------------------- | ---------------------------------------------------------------------- | | **Not invoked** on first visit | `raw` would be `null`, not a failure. | | **Not invoked** on storage errors | Private mode / disabled storage β€” environment fault, not schema fault. | | Callback errors caught | If the callback throws, the exception is logged via `console.warn`. | ## Behaviors - **SSR-safe.** On the server (`typeof window === "undefined"`), returns a plain signal initialized to `initial`, with no storage subscription. Hydration on the client picks up where the server left off. - **Schema-validated.** If the stored JSON is malformed or `schema` throws, the signal falls back to `initial` rather than crashing at mount. Triggers `onSchemaFailure` if set. - **Quota-safe.** If a write throws (`QuotaExceededError`, private-mode Safari), the helper logs a warning and keeps the in-memory value β€” the app keeps working. - **Module-scope intent.** Call `persistedSignal` at module scope in your `stores/` file, **not inside components**. The write effect lives for the module lifetime by design β€” it has no disposal hook. ## When to reach for it (and when not) | Use it for | Don't use it for | |---|---| | Settings, theme, draft form data | Cache for server-side data (`resource()` is the better fit) | | Todo lists, small in-memory caches | Anything > ~5 MB (the LocalStorage quota varies by browser) | | Per-tab UI state (`storage: "session"`) | Cross-tab synchronization (no `storage` event handling) | | State that survives a page reload | Secrets, auth tokens (LocalStorage is readable by every script on the origin) | For larger or structured data, reach for a dedicated IndexedDB library and wire its load/save into a regular `signal()` + `effect()` pair. The "module-scope read once, effect-write on change" shape still applies. For server-derived state with a local cache, compose `resource()` with `persistedSignal()` β€” see [recipe 3 in the persistence guide](https://whisq.dev/guides/state-management/#persistence-localstorage). ## See also - [State Management guide β†’ Persistence](https://whisq.dev/guides/state-management/#persistence-localstorage) β€” the longer-form story including the inline hand-roll alternative for nested-signal stores. - [`/api/imports/#persistence`](https://whisq.dev/api/imports/#persistence) β€” sub-path import reference. - [`signal()`](https://whisq.dev/api/signal/) β€” the underlying primitive. - [`resource()`](https://whisq.dev/api/resource/) β€” for server-derived data; can be paired with `persistedSignal` as a freshness cache. - [Persisted settings recipe](https://whisq.dev/guides/state-management/#recipe--persisted-settings-single-record--bindpath) β€” compose `persistedSignal` with `bindPath` for single-record UIs (theme, filters, notification prefs). --- # portal() URL: https://whisq.dev/api/portal/ --- Render a `WhisqNode` inside a different DOM target than its logical parent. Useful for modals, tooltips, and dropdowns that need to escape the parent's `overflow: hidden` or stacking context. ## Signature ```ts function portal(target: Element, content: WhisqNode): WhisqNode; ``` ## Parameters | Param | Type | Description | | --------- | ------------ | ------------------------------------------------- | | `target` | `Element` | DOM element to teleport `content` into | | `content` | `WhisqNode` | Any element / component call β€” the portal payload | ## Returns A `WhisqNode` that renders a marker comment at the logical location and the real content inside `target`. Disposing the returned node removes the teleported content. ## Examples ```ts const open = signal(false); div( button({ onclick: () => open.value = true }, "Open modal"), when(() => open.value, () => portal(document.body, div({ class: "modal-overlay" }, div({ class: "modal-card" }, "Hello"), )), ), ); ``` The modal renders inside `` instead of nested under the logical parent, so it can span the full viewport regardless of ancestor overflow rules. --- # provide() URL: https://whisq.dev/api/provide/ --- Make a [`createContext()`](https://whisq.dev/api/createcontext/) value available to all descendant components. Call from inside a `component()` setup. ## Signature ```ts function provide(context: Context, value: T): void ``` ## Parameters | Param | Type | Description | | --------- | ------------ | ----------------------------------------------------------- | | `context` | `Context` | The context object returned by `createContext()` | | `value` | `T` | Value visible to descendant components via `inject()` | ## Examples ```ts const ThemeCtx = createContext("light"); const App = component(() => { provide(ThemeCtx, "dark"); return div(Page({})); }); ``` See [`inject()`](https://whisq.dev/api/inject/) for the consumer side and [Components β†’ Context](https://whisq.dev/core-concepts/components/#context-provideinject) for the full pattern. --- # randomId() URL: https://whisq.dev/api/randomId/ --- Generate a UUID-v4-shaped random identifier β€” a string like `"3f5d2c1b-9a4e-4b87-bf1a-7c2d8e3f9a01"`. The canonical default for keyed-`each` row keys, todo `id` fields, and any other "I just need a stable unique string" use case. Shipped from the **sub-path** `@whisq/core/ids`, not the main entry. Since alpha.8. ## Signature ```ts function randomId(): string; ``` ## Example ```ts interface Todo { id: string; text: string; done: boolean } const todos = signal([]); const addTodo = (text: string) => { todos.value = [...todos.value, { id: randomId(), text, done: false }]; }; ``` ## How it works - **Prefers `crypto.randomUUID()`** when the platform provides it β€” all modern browsers, Node 19+, Deno, Bun. - **Falls back to a `Math.random` synthesis** for older targets (pre-19 Node, old Safari). Same v4 shape (`xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` where `y ∈ {8, 9, a, b}`), but with weaker entropy. - **Same output shape on both paths**, so callers don't have to branch on platform. ## Not a security primitive :::caution Suitable for UI row ids and keyed-`each` keys. **Not** suitable for security tokens, password reset codes, session ids, or anything where unpredictability matters β€” the fallback path is not cryptographically strong, and even native `crypto.randomUUID()` isn't intended for cryptographic secrets. For tokens, use the platform's `crypto.getRandomValues()` (or a server-side secret generator) and don't reach for `randomId()`. ::: ## Why a sub-path? `@whisq/core/ids` lives on its own sub-path so apps that don't generate ids client-side (e.g. an SSR app where the server hands out ids) pay no bundle cost. Same convention as `/persistence`, `/forms`, and `/collections`. See also: [`/api/imports/#ids`](https://whisq.dev/api/imports/#ids), [Todo App example](https://whisq.dev/examples/todo-app/). --- # raw() URL: https://whisq.dev/api/raw/ --- Inject pre-built HTML into the DOM. Use for markdown output, third-party HTML, etc. ## Signature ```ts function raw(htmlString: string | (() => string)): WhisqNode ``` ## Parameters | Param | Type | Description | |---|---|---| | `htmlString` | `string \| (() => string)` | HTML string (static or reactive) | ## Examples ```ts div({ class: "content" }, h1("My Post"), raw(markdownToHtml(post.body)), ) ``` :::danger[Common Mistake] `raw()` does **NOT** sanitize HTML. Never pass user input directly β€” always sanitize first to prevent XSS. ::: --- # rcx() URL: https://whisq.dev/api/rcx/ --- Compose class names reactively β€” returns a getter function for use with reactive `class` props. :::tip[Since alpha.8] For reactive class composition directly on an element prop, prefer the **array form on `class:`** β€” it accepts strings, falsy shorthands, and reactive getters in one place, with no separate `cx` / `rcx` decision. See [`/api/elements/#class-array-form-since-alpha8`](https://whisq.dev/api/elements/#class-array-form-since-alpha8). Reach for `rcx()` when you need to compose a reactive class string outside an element prop. ::: ## Signature ```ts function rcx(...args: (string | (() => string | false | null | undefined) | false | null | undefined)[]): () => string ``` Any positional argument can be a plain string, a falsy value (`false` / `null` / `undefined`), or a getter function returning string-or-falsy. Falsy values and falsy getter returns are skipped; remaining classes are space-joined. ## Examples ### One static class ```ts div({ class: rcx("btn") }) ``` ### Static base + reactive toggle ```ts const loading = signal(false); div({ class: rcx("btn", () => loading.value && "btn-loading"), }) ``` ### All reactive ```ts const theme = signal<"light" | "dark">("light"); const size = signal<"sm" | "md" | "lg">("md"); div({ class: rcx( () => `theme-${theme.value}`, () => `size-${size.value}`, ), }) ``` ### Mixing with `sheet()` classes ```ts const s = sheet({ item: { padding: "0.5rem" }, active: { background: "var(--color-accent-low)" }, }); const selected = signal(false); li({ class: rcx(s.item, () => selected.value && s.active), }) ``` ### Multiple toggles ```ts const variant = signal<"primary" | "secondary">("primary"); const loading = signal(false); div({ class: rcx( "btn", () => variant.value === "primary" && "btn-primary", () => variant.value === "secondary" && "btn-secondary", () => loading.value && "btn-loading", ), }) ``` --- # ref() URL: https://whisq.dev/api/ref/ --- Create an `ElementRef` (a `Signal`) that Whisq populates with a DOM element after mount and resets to `null` on unmount. Pass it as the `ref` prop on any element and read `.value` β€” **not `.current`** β€” after `onMount`. :::caution[Not React] Whisq refs are plain signals. Read them with `.value`, never `.current` β€” that's the most common mistake for developers coming from React. See [Common Mistakes β†’ `.current` instead of `.value`](https://whisq.dev/common-mistakes/#current-instead-of-value-on-a-ref). ::: ## Signature ```ts function ref(): ElementRef; ``` Where `ElementRef` is a named type alias exported from `@whisq/core`: ```ts type ElementRef = Signal; ``` The alias is structurally identical to `Signal` β€” its job is ergonomic. Tooltips read `ElementRef` instead of `Signal`, so the concept has a name. ## Returns An `ElementRef` that starts at `null` and is set to the element after mount. Read via `.value`. ## Examples ```ts const Focus = component(() => { const inputEl = ref(); // ElementRef onMount(() => inputEl.value?.focus()); // .value β€” not .current return input({ ref: inputEl, placeholder: "Autofocused" }); }); ``` Because it's a regular signal, you can also read it reactively: ```ts const el = ref(); effect(() => { if (el.value) console.log("mounted", el.value.getBoundingClientRect()); }); div({ ref: el }, "Hello"); ``` ## Naming a prop or field that holds a ref Use the `ElementRef` type when you need to declare the shape of an object, prop, or collection of refs: ```ts type FormRefs = { email: ElementRef; submitBtn: ElementRef; }; ``` ## Callback-ref form A function that receives the element is also supported on the `ref` prop. `Ref` β€” exported as a type from `@whisq/core` β€” is the union of both shapes: ```ts type Ref = ElementRef | ((el: T | null) => void); ``` ## Backward compatibility `ElementRef` is a type alias over `Signal`, not a new type. Code that declared `const r: Signal = ref()` still type-checks without changes β€” the alias exists for readability, not for nominal typing. --- # resource() URL: https://whisq.dev/api/resource/ --- Fetch async data with reactive loading, error, and data states. ## Signature ```ts function resource(fetcher: () => Promise): Resource ``` ## Parameters | Param | Type | Description | |---|---|---| | `fetcher` | `() => Promise` | Async function that returns data | ## Returns | Property | Type | Description | |---|---|---| | `.data()` | `T \| undefined` | Resolved data | | `.loading()` | `boolean` | True while fetching | | `.error()` | `Error \| undefined` | Rejection error | | `.refetch()` | `void` | Re-execute the fetcher | ## Examples ```ts const users = resource(() => fetch("/api/users").then(r => r.json()) ); div( when(() => users.loading(), () => p("Loading...")), when(() => !!users.error(), () => p(() => users.error()!.message)), when(() => !!users.data(), () => ul(each(() => users.data()!, (u) => li(u.name))) ), ) ``` --- # Reading route state URL: https://whisq.dev/api/route-state/ --- There's no `useRoute()` hook in `@whisq/router` β€” reading the current route is done directly via `router.current`, a `ReadonlySignal`. Every other router primitive (`RouterView`, `Link`'s active-class) is built on subscribing to that signal. This page covers the reactive-access shape: what `RouteState` exposes, how to read it inside components, and which read form (`.value` vs callable getter) to reach for in which context. ## `RouteState` ```ts interface RouteState { path: string; params: Record; query: Record; matched: MatchedRoute[]; meta: Record; } interface MatchedRoute { route: RouteConfig; params: Record; } ``` | Field | Type | Description | |---|---|---| | `path` | `string` | Normalised pathname β€” leading slash, no trailing slash (except `"/"`). | | `params` | `Record` | All `:name` captures across the matched chain, flattened. Nested routes contribute their own params plus ancestors'. | | `query` | `Record` | Parsed query string. Single values only β€” repeated keys are overwritten. Empty object when no query. | | `matched` | `MatchedRoute[]` | The full chain of matched routes, top-down. Length = nesting depth. Useful for breadcrumbs. | | `meta` | `Record` | Merged `meta` from every matched route, in matched order (child wins on key collision). Useful for per-route flags like `{ requiresAuth: true }`. | ## Reading inside a component Use the callable getter form (`() => router.current.value.`) anywhere Whisq expects a reactive value β€” text children, reactive props, inside `when()` / `match()`: ```ts ``` The render callback's `params` argument is a `Record` aggregated from all matched routes **up to and including this view's `depth`** β€” sugar so shallow components don't need to import `router`: ```ts // Reads the params passed by RouterView for this depth. ``` This is a **plain object snapshot** captured when the component mounts. It's equal to `router.current.value.params` when the component sits at the deepest matched level, but a shallower view's render sees a proper subset (its own params plus ancestors', not descendants'). `RouterView` re-mounts the component on every route change, so the snapshot is always for the current match β€” no stale captures across navigations. But inside an **already-mounted** component, if you need a value that reads reactively without a re-mount (uncommon β€” happens mostly with hash-only changes or programmatic rewrites), reach for the signal: ```ts // Always reads the current value β€” works even if the framework // short-circuits a re-mount for an identical route swap. h1(() => `User ${router.current.value.params.id}`) ``` ## Reading inside an effect `router.current.value` establishes a subscription, so the effect re-runs on every route change: ```ts effect(() => { const route = router.current.value; document.title = route.meta.title as string | undefined ?? "App"; }); ``` If you only care about a specific field, read just that to keep the effect's dependency set tight: ```ts effect(() => { const tab = router.current.value.query.tab ?? "profile"; loadTab(tab); }); ``` The effect still re-runs on every navigation (since `router.current` is a single signal), but reading the narrow projection up front makes the intent obvious and keeps the effect body focused. ## `peek()` β€” read without subscribing For one-shot reads in non-reactive contexts (event handlers, analytics calls): ```ts button({ onclick: () => { const current = router.current.peek().path; analytics.track("logout", { from: current }); }, }, "Log out") ``` `.peek()` reads the current value without entering the tracking scope β€” the effect or component around this button won't re-render on route change just because it read the path. ## Patterns - **Breadcrumbs** β€” iterate `router.current.value.matched`, reading `meta.title` (or another per-route field) on each `MatchedRoute`. - **Per-route page title** β€” `effect(() => document.title = router.current.value.meta.title ?? "App")` as shown above. Prefer this over `useHead` inside per-route components when the title logic lives outside the routes. - **Query-string-driven state** β€” treat `router.current.value.query.x` as the source of truth for a query-gated UI mode; write back via `router.navigate` with a rebuilt query string. ## See also - [`/api/createrouter/`](https://whisq.dev/api/createrouter/) β€” creates the `Router` that owns `current`. - [`/api/routerview/`](https://whisq.dev/api/routerview/) β€” renders the component matched by `current`. - [`/guides/routing/#accessing-route-state`](https://whisq.dev/guides/routing/#accessing-route-state) β€” the routing guide's take on this pattern. --- # sheet() URL: https://whisq.dev/api/sheet/ --- Generate scoped class names from JavaScript objects. Auto-injects a `