Learn-skills.dev web-meta-framework-qwik
Qwik resumable framework - zero hydration, $ lazy boundaries, signals, Qwik City file-based routing, routeLoader$, routeAction$, server$ RPC, serialization rules
git clone https://github.com/NeverSight/learn-skills.dev
T=$(mktemp -d) && git clone --depth=1 https://github.com/NeverSight/learn-skills.dev "$T" && mkdir -p ~/.claude/skills && cp -r "$T/data/skills-md/agents-inc/skills/web-meta-framework-qwik" ~/.claude/skills/neversight-learn-skills-dev-web-meta-framework-qwik && rm -rf "$T"
data/skills-md/agents-inc/skills/web-meta-framework-qwik/SKILL.mdQwik Framework Patterns
Quick Guide: Qwik is resumable - it serializes application state on the server and resumes on the client without re-executing framework code (no hydration). Every
suffix marks a lazy-loading boundary where the optimizer splits code into separate chunks. Only the code for the interaction the user triggers gets downloaded. Use$for all components,component$/useSignalfor state,useStorefor server data,routeLoader$for mutations, androuteAction$for ad-hoc server RPC. The critical mental model: anything crossing aserver$boundary must be serializable.$
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
, named constants)import type
(You MUST wrap every component in
- plain functions cannot be lazy-loaded, cannot use hooks, and cannot use component$()
)<Slot />
(You MUST ensure all values captured in a
closure are serializable - non-serializable captures pass type-checking but fail at runtime)$
(You MUST use
for initial server data instead of fetching in routeLoader$
or useTask$
- loaders run before render and integrate with SSR streaming)useResource$
(You MUST use
as a JSX attribute instead of calling preventdefault:click
- event handlers load asynchronously so synchronous Event APIs are unavailable)event.preventDefault()
(You MUST export
and routeLoader$
from route files (routeAction$
or index.tsx
in layout.tsx
) - unexported or misplaced loaders/actions silently do nothing)src/routes/
(You MUST NOT destructure store properties at the top level - destructuring breaks reactivity because you lose the Proxy reference)
</critical_requirements>
Auto-detection: Qwik, component$, useSignal, useStore, useTask$, useVisibleTask$, useComputed$, useResource$, routeLoader$, routeAction$, server$, sync$, QRL, noSerialize, @builder.io/qwik, @builder.io/qwik-city, Qwik City, $(), onClick$, onInput$, Slot, q:slot, preventdefault, stoppropagation, useStylesScoped$, resumable, resumability
When to use:
- Building web apps where instant interactivity matters (zero hydration delay)
- Apps with complex interactivity that would ship too much JS with traditional hydration
- Projects needing fine-grained lazy loading without manual code-splitting
- Full-stack apps with server loaders, actions, and RPC via
server$ - Progressive enhancement where forms work without JavaScript
When NOT to use:
- Static content sites with minimal interactivity (use a static site generator)
- Projects where the team is deeply invested in React ecosystem libraries that have no Qwik equivalents
- Apps that rely heavily on non-serializable runtime state (class instances, closures with side effects)
Key patterns covered:
- Resumability mental model and the
suffix convention$ - Component definition with
, props, andcomponent$<Slot /> - Reactive state:
,useSignal
,useStoreuseComputed$ - Lifecycle:
,useTask$
,useVisibleTask$useResource$ - Event handling:
,onClick$
,preventdefault:clicksync$ - Qwik City routing: file-based routes, layouts, dynamic params
- Server data:
,routeLoader$
,routeAction$server$ - Serialization rules and the
boundary$
Detailed Resources:
- For decision frameworks and anti-patterns, see reference.md
Core patterns:
- examples/core.md - Components, signals, stores, tasks, events, slots
- examples/routing.md - File-based routing, routeLoader$, routeAction$, server$, middleware
- examples/serialization.md - Serialization rules, $ boundary, non-serializable patterns
<philosophy>
Philosophy
Qwik is built on resumability - the idea that the server can serialize the entire application state (component tree, listeners, state) into HTML, and the client can resume exactly where the server left off without re-executing any framework code.
How it differs from hydration frameworks:
Traditional SSR frameworks render HTML on the server, then re-execute all component code on the client to attach event listeners and rebuild the component tree. This is hydration - the client replays the server's work.
Qwik skips this entirely. The server serializes everything into the HTML. When a user clicks a button, only the click handler's code downloads and executes. The framework itself, the component tree, and all other handlers stay unloaded until needed.
The
suffix is the core mechanism. Every function ending in $
$ is a lazy-loading boundary. The Qwik optimizer splits code at each $ marker into separate chunks. This means:
- the component's render function loads only when neededcomponent$()
- the click handler loads only when the user clicksonClick$()
- the loader runs server-side onlyrouteLoader$()
- the task loads when its tracked dependencies changeuseTask$()
The tradeoff: Because code must be serializable to cross
$ boundaries, you cannot capture non-serializable values (class instances, functions, DOM nodes) in $ closures. This constraint is the price of instant interactivity.
When to use Qwik:
- Interactive apps where time-to-interactive matters
- Large apps where traditional hydration downloads too much JS upfront
- Full-stack apps leveraging
/routeLoader$
/routeAction$
for server logicserver$ - Progressive enhancement (Qwik forms work without JS)
When NOT to use Qwik:
- Static content sites with little interactivity
- Projects heavily dependent on React-specific libraries without Qwik equivalents
- Apps requiring extensive non-serializable runtime state
<patterns>
Core Patterns
Pattern 1: Components with component$
Every Qwik component must be wrapped in
component$(). This is not optional - it enables lazy loading, hooks, and <Slot />.
import { component$, useSignal } from "@builder.io/qwik"; interface CounterProps { initial?: number; label: string; } export const Counter = component$<CounterProps>(({ initial = 0, label }) => { const count = useSignal(initial); return ( <div> <span> {label}: {count.value} </span> <button onClick$={() => count.value++}>+</button> </div> ); });
Why good:
component$ enables the optimizer to split this into a lazy chunk, typed props via generic, useSignal for reactive state, onClick$ handler loads only on click
// BAD: Plain function component export const Counter = (props: { label: string }) => { // Cannot use hooks here - useSignal will throw // Cannot use <Slot /> - only works inside component$ return <div>{props.label}</div>; };
Why bad: Without
component$ wrapper, hooks throw at runtime, <Slot /> breaks, optimizer cannot split the code, component is not resumable
Pattern 2: Reactive State - useSignal vs useStore
useSignal holds a single reactive value accessed via .value. useStore holds a reactive object with deep tracking by default.
import { component$, useSignal, useStore } from "@builder.io/qwik"; export const UserProfile = component$(() => { // useSignal for primitives and flat values const isEditing = useSignal(false); const selectedTab = useSignal<"profile" | "settings">("profile"); // useStore for objects/arrays - deep reactivity by default const user = useStore({ name: "Alice", email: "alice@example.com", preferences: { theme: "dark", notifications: true, }, }); return ( <div> <h1>{user.name}</h1> {isEditing.value ? ( <input value={user.name} onInput$={(_, el) => { user.name = el.value; }} /> ) : ( <button onClick$={() => { isEditing.value = true; }} > Edit </button> )} </div> ); });
Why good:
useSignal for simple toggles/selections (accessed via .value), useStore for structured data (mutate properties directly), deep reactivity tracks user.preferences.theme changes automatically
// BAD: Destructuring a store const { name, email } = useStore({ name: "Alice", email: "a@b.com" }); // name and email are now plain strings - NOT reactive // Changing them does nothing to the UI
Why bad: Destructuring extracts primitive values from the Proxy, breaking reactivity - you must keep the store reference intact and access
store.name directly
Pattern 3: Computed Values with useComputed$
useComputed$ derives values from signals/stores. It re-runs only when dependencies change. Synchronous only.
import { component$, useSignal, useComputed$ } from "@builder.io/qwik"; const TAX_RATE = 0.08; const FREE_SHIPPING_THRESHOLD = 100; export const CartSummary = component$(() => { const subtotal = useSignal(85); const tax = useComputed$(() => subtotal.value * TAX_RATE); const shipping = useComputed$(() => subtotal.value >= FREE_SHIPPING_THRESHOLD ? 0 : 9.99, ); const total = useComputed$(() => subtotal.value + tax.value + shipping.value); return ( <div> <p>Subtotal: ${subtotal.value.toFixed(2)}</p> <p>Tax: ${tax.value.toFixed(2)}</p> <p>Shipping: ${shipping.value.toFixed(2)}</p> <p>Total: ${total.value.toFixed(2)}</p> </div> ); });
Why good: Automatic dependency tracking (no dependency arrays), read-only signal prevents accidental mutation, recomputes only when
subtotal changes, named constants for magic numbers
Pattern 4: Tasks and Lifecycle
useTask$ runs before render (server + client). useVisibleTask$ runs after render (browser only). Use track() to declare reactive dependencies.
import { component$, useSignal, useTask$, useVisibleTask$, } from "@builder.io/qwik"; import { server$ } from "@builder.io/qwik-city"; const DEBOUNCE_MS = 300; export const SearchBox = component$(() => { const query = useSignal(""); const results = useSignal<string[]>([]); // Runs before render, re-runs when query changes useTask$(({ track, cleanup }) => { const searchTerm = track(() => query.value); if (!searchTerm) { results.value = []; return; } const debounceTimer = setTimeout(async () => { const data = await fetchResults(searchTerm); results.value = data; }, DEBOUNCE_MS); cleanup(() => clearTimeout(debounceTimer)); }); return ( <div> <input value={query.value} onInput$={(_, el) => { query.value = el.value; }} /> <ul> {results.value.map((r) => ( <li key={r}>{r}</li> ))} </ul> </div> ); }); const fetchResults = server$(async function (term: string) { // Runs on server only - safe to access DB, env vars, etc. const db = this.env.get("DATABASE_URL"); // ... query database return ["result1", "result2"]; });
Why good:
track() explicitly declares what triggers re-runs, cleanup() prevents timer leaks, server$ keeps the fetch server-side
When to use each:
| Hook | Runs | Use for |
|---|---|---|
| Server + client, before render | Data init, side effects on state change |
| Browser only, after render | DOM manipulation, browser APIs, animations |
| Synchronous, auto-tracked | Derived values (formatting, filtering) |
| Server + client, non-blocking | Async data that shouldn't block render |
Pattern 5: Event Handling
Event handlers use the
on{Event}$ convention. Because handlers load asynchronously, synchronous Event APIs (preventDefault, stopPropagation, currentTarget) are NOT available - use declarative attributes instead.
import { component$, useSignal, $ } from "@builder.io/qwik"; export const LoginForm = component$(() => { const email = useSignal(""); // Extracted handler - wrap with $() for reuse const handleSubmit = $((e: SubmitEvent) => { // Submit email.value to server }); return ( <form preventdefault:submit onSubmit$={handleSubmit}> <input type="email" value={email.value} onInput$={(_, el) => { email.value = el.value; }} /> <button type="submit">Login</button> </form> ); });
Why good:
preventdefault:submit replaces e.preventDefault() declaratively, second parameter of onInput$ gives the element directly (avoiding async currentTarget issues), extracted handler uses $() wrapper
// BAD: Calling synchronous Event APIs <form onSubmit$={(e) => { e.preventDefault(); // WRONG - handler is async, this is a no-op e.stopPropagation(); // WRONG - same reason }}>
Why bad: Event handlers are lazy-loaded asynchronously, so
preventDefault() and stopPropagation() execute too late to have any effect - use preventdefault:submit and stoppropagation:submit attributes instead
Pattern 6: Content Projection with Slot
<Slot /> projects child content. Named slots use the q:slot attribute. Only works inside component$().
import { component$, Slot } from "@builder.io/qwik"; export const Card = component$<{ variant?: "default" | "outlined" }>( ({ variant = "default" }) => { return ( <div class={`card card-${variant}`}> <header class="card-header"> <Slot name="header" /> </header> <div class="card-body"> <Slot /> {/* Default slot */} </div> <footer class="card-footer"> <Slot name="footer" /> </footer> </div> ); }, ); // Usage export const Page = component$(() => { return ( <Card variant="outlined"> <h2 q:slot="header">Card Title</h2> <p>This goes in the default slot.</p> <div q:slot="footer"> <button>Action</button> </div> </Card> ); });
Why good: Named slots via
q:slot attribute, default slot for main content, parent and child render independently
Gotcha:
q:slot must be on a direct child of the component. Wrapping slotted content in an intermediate element breaks projection.
Pattern 7: routeLoader$ for Server Data
routeLoader$ runs on the server before the page renders. It must be exported from a route file. Returns a read-only signal.
// src/routes/products/[id]/index.tsx import { component$ } from "@builder.io/qwik"; import { routeLoader$ } from "@builder.io/qwik-city"; export const useProduct = routeLoader$(async (requestEvent) => { const productId = requestEvent.params.id; const product = await db.products.findById(productId); if (!product) { return requestEvent.fail(404, { errorMessage: `Product ${productId} not found`, }); } return product; }); export default component$(() => { const product = useProduct(); // ReadonlySignal return product.value.failed ? ( <p>{product.value.errorMessage}</p> ) : ( <div> <h1>{product.value.name}</h1> <p>${product.value.price}</p> </div> ); });
Why good: Server-only execution, runs before render (no loading states during SSR), type-safe error handling with
fail(), read-only signal prevents accidental client-side mutation
Pattern 8: routeAction$ for Mutations
routeAction$ handles form submissions and mutations. Supports Zod validation. Must be exported from route files.
// src/routes/contact/index.tsx import { component$ } from "@builder.io/qwik"; import { routeAction$, Form, zod$, z } from "@builder.io/qwik-city"; export const useContactAction = routeAction$( async (data, requestEvent) => { // data is validated and typed: { name: string; email: string; message: string } await sendEmail(data); return { success: true }; }, zod$({ name: z.string().min(1), email: z.string().email(), message: z.string().min(10), }), ); export default component$(() => { const action = useContactAction(); return ( <Form action={action}> <input name="name" /> <input name="email" type="email" /> <textarea name="message" /> {action.value?.fieldErrors?.email && ( <p class="error">{action.value.fieldErrors.email}</p> )} {action.value?.failed && <p class="error">{action.value.message}</p>} {action.value?.success && <p>Message sent!</p>} <button type="submit" disabled={action.isRunning}> {action.isRunning ? "Sending..." : "Send"} </button> </Form> ); });
Why good:
<Form> works without JS (progressive enhancement), Zod validation runs server-side with typed errors, action.isRunning for loading state, action.value.failed discriminates success/failure
</patterns>
<red_flags>
RED FLAGS
High Priority Issues
- Using plain functions instead of
- Hooks throw,component$()
breaks, optimizer cannot split code, component is not resumable<Slot /> - Destructuring store properties -
extracts a plain value, breaking reactivity. Always accessconst { name } = store
directlystore.name - Calling
insideevent.preventDefault()
- Handler loads asynchronously, soonClick$
is a no-op. UsepreventDefault()
attributepreventdefault:click - Putting
/routeLoader$
in non-route files without re-exporting - They silently do nothing unless exported fromrouteAction$
orsrc/routes/**/index.tsxlayout.tsx - Capturing non-serializable values in
closures - Class instances, functions, DOM nodes pass type-checking but fail at runtime with serialization errors$
Medium Priority Issues
- Using
whenuseVisibleTask$
would work -useTask$
is browser-only and runs after render; preferuseVisibleTask$
by default for better SSRuseTask$ - Fetching data in
instead ofuseTask$
- Loaders integrate with SSR streaming and run before render;routeLoader$
blocks renderinguseTask$ - Using
-style thinking - Qwik is not an islands framework. Every component is already lazy-loaded at the interaction level. You do not choose what to hydrate.client:load - Over-capturing in
closures - Closing over an entire store when you only need one property forces Qwik to serialize the whole store$
Common Mistakes
- Using
explicitly - Deep is already the default. Passing it is redundant. PassuseStore({ deep: true })
only when you need shallow tracking{ deep: false } - Using arrow functions for store methods - Arrow functions lose
binding. Use regularthis
syntax for methods on storesfunction(){} - Confusing
vs@builder.io/qwik
imports - Components, signals, tasks from@builder.io/qwik-city
. Routing, loaders, actions,@builder.io/qwik
fromserver$@builder.io/qwik-city - Inline
tags in components - Causes double-loading (SSR + client). Use<style>
or CSS modules insteaduseStylesScoped$()
Gotchas & Edge Cases
withoutuseTask$
runs once on mount - Without tracking any signal, it behaves like an initialization hook, not a reactive effecttrack()
blocks rendering - Long async operations inuseTask$
delay the component render. UseuseTask$
for non-blocking asyncuseResource$
second parameter - The callback receivesonInput$
where(event, element)
is the target. Useelement
instead ofel.value
(currentTarget is null in async handlers)event.currentTarget.value- Middleware does NOT run for
calls - Layout-levelserver$
/onRequest
handlers are skipped foronGet
RPC. Useserver$
for middleware that must run onplugin.ts
requestsserver$ - Version skew with
- Client and server must run the same code version. Stale client deployments cause undefined behaviorserver$
uses emoji-based class hashing - Scoped styles apply via emoji characters in selectors. UseuseStylesScoped$
to break out when styling:global()
content<Slot />- Props are shallowly immutable - Reassigning a primitive prop from a child does nothing. Pass a
instead if the child needs to write backSignal - Deep store mutations may not trigger updates - Tracking
requires tracking the specific property, not just the key.store[key].nested
withuseStore
disables deep tracking{ deep: false }
does not work in inline components - Only<Slot />
functions supportcomponent$()
. Arrow functions or plain functions will silently fail<Slot />
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST wrap every component in
- plain functions cannot be lazy-loaded, cannot use hooks, and cannot use component$()
)<Slot />
(You MUST ensure all values captured in a
closure are serializable - non-serializable captures pass type-checking but fail at runtime)$
(You MUST use
for initial server data instead of fetching in routeLoader$
or useTask$
- loaders run before render and integrate with SSR streaming)useResource$
(You MUST use
as a JSX attribute instead of calling preventdefault:click
- event handlers load asynchronously so synchronous Event APIs are unavailable)event.preventDefault()
(You MUST export
and routeLoader$
from route files (routeAction$
or index.tsx
in layout.tsx
) - unexported or misplaced loaders/actions silently do nothing)src/routes/
(You MUST NOT destructure store properties at the top level - destructuring breaks reactivity because you lose the Proxy reference)
Failure to follow these rules will cause silent runtime failures, broken reactivity, serialization errors, or loaders/actions that never execute.
</critical_reminders>