Qwik Patterns
Purpose
Apply production patterns to Qwik applications: resumability-driven architecture, fine-grained reactivity, lazy loading boundaries, Qwik City routing with server functions, and prefetch optimization.
Agent Protocol
Trigger
Exact user phrases: "Qwik pattern", "Qwik resumable", "Qwik City", "useSignal", "useStore", "Qwik lazy loading", "Qwik optimizer", "$()", "component$()", "routeLoader$", "routeAction$".
Input Context
Before activating, verify:
- package.json has @builder.io/qwik and @builder.io/qwik-city.
- Vite config has Qwik plugins.
- Prefetch strategy is configured (PrefetchServiceWorker).
Output Artifact
No file output. Produces resumability plan, component patterns, route design as text.
Response Format
Resumability Plan: {lazy boundary map}
Component Pattern: {component$ / $() usage}
Route Design: {loaders + actions + layout}
No preamble. No postamble. No explanations. No filler/hedging/transitions. Compress output — why use many token when few do trick.
Completion Criteria
- All components use component$() for lazy loading.
- Event handlers use $ suffix (onClick$, onInput$).
- Closures passed across lazy boundaries use $().
- State uses useSignal (primitive) or useStore (object).
- Data loading uses routeLoader$() with typed returns.
- Mutations use routeAction$() with Form component.
- PrefetchServiceWorker enabled in root layout.
- useVisibleTask$ used sparingly and only for client-only effects.
Max Response Length
2560 tokens.
Workflow
Step 1: Resumability Pattern
export default component$(() => {
const count = useSignal(0)
return (
<button onClick$={() => count.value++}>
{count.value}
</button>
)
})
No hydration: HTML contains serialized state (data-qwik) + QRL references to lazy-loadable chunks. Browser resumes execution without replaying component tree.
Step 2: Dollar Sign API Boundaries
export default component$(() => {
const state = useStore({ items: [], filter: '' })
const log = $((msg: string) => {
console.log(msg, state.items)
})
return (
<input onInput$={(_, el) => state.filter = el.value} />
)
})
Every $ creates a separate lazy-loadable chunk. The optimizer extracts these into individual bundles.
Step 3: Fine-Grained Reactivity
export default component$(() => {
const count = useSignal(0)
const name = useSignal('')
const form = useStore({
email: '',
password: '',
errors: {} as Record<string, string>,
})
const isFormValid = useComputed$(() =>
form.email.includes('@') && form.password.length >= 8
)
return (
<div>
<p>Count: {count.value}</p>
<p>Form valid: {isFormValid.value}</p>
</div>
)
})
Reactivity is fine-grained — Qwik tracks reads at the property level, not via virtual DOM diffing.
Step 4: Qwik City Routing
src/routes/
layout.tsx -> root layout
index.tsx -> /
about/index.tsx -> /about
blog/
layout.tsx -> blog layout
index.tsx -> /blog
[slug]/index.tsx -> /blog/:slug
dashboard/
layout.tsx -> authenticated layout
index.tsx -> /dashboard
api/
users/index.ts -> /api/users (resource route)
All routes, layouts, data loaders, and actions are lazy-loaded. Use <Slot /> for layout insertion points.
Step 5: Route Loaders and Actions
// src/routes/dashboard/index.tsx
import { routeLoader$, routeAction$, Form } from '@builder.io/qwik-city'
export const useUserData = routeLoader$(async ({ cookie, redirect }) => {
const token = cookie.get('token')?.value
if (!token) throw redirect(302, '/login')
const user = await db.user.findUnique({ where: { token } })
return user as User
})
export const useUpdateProfile = routeAction$(async (form, { fail }) => {
const name = form.get('name')
if (!name || name.length < 2) return fail(400, { message: 'Name too short' })
await db.user.update({ data: { name } })
return { success: true }
})
export default component$(() => {
const user = useUserData()
const action = useUpdateProfile()
return (
<Form action={action}>
<input name="name" value={user.value.name} />
{action.value?.failed && <p>{action.value.fieldErrors?.message}</p>}
<button type="submit">Update</button>
</Form>
)
})
routeLoader$ runs on server, serializes result into HTML. routeAction$ handles form mutations with progressive enhancement.
Step 6: Prefetch Optimization
// src/root.tsx
import { PrefetchServiceWorker } from '@builder.io/qwik/prefetch-service-worker'
export default component$(() => {
return (
<html>
<head>
<PrefetchServiceWorker />
</head>
<body>
<Slot />
</body>
</html>
)
})
PrefetchServiceWorker preloads QRL chunks for links in viewport, enabling instant navigation without waterfall.
Step 7: Server Functions
// src/components/createUser.ts
import { server$ } from '@builder.io/qwik-city'
export const createUser = server$(async (data: CreateUserInput) => {
const user = await db.user.create({ data })
await email.sendWelcome(user.email)
return user
})
server$() marks a function to always run on the server. Callable from client components as if local.
Step 8: useVisibleTask$ for Client Effects
export default component$(() => {
const elemRef = useSignal<Element>()
useVisibleTask$(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) analytics.trackView(entry.target.id)
})
})
if (elemRef.value) observer.observe(elemRef.value)
return () => observer.disconnect()
})
return <div ref={elemRef} id="tracked-element">Content</div>
})
Component Architecture
Component Decision Tree
Does the component need interactivity?
No -> Static HTML, no component$() needed (plain function component)
Yes -> Does it need state?
No -> Use component$() with props only
Yes -> Is the state simple (number, string)?
Yes -> useSignal
No -> useStore (object, array)
Does the component need side effects?
No -> Skip useVisibleTask$
Yes -> Is it a data fetch?
No -> Is it DOM measurement, animation, or analytics?
Yes -> useVisibleTask$ with proper cleanup
Yes -> routeLoader$ in parent, pass as props
Component Composition Patterns
// Parent provides state, children receive props
export default component$(() => {
const items = useSignal<string[]>([])
return (
<div>
<ItemList items={items.value} />
</div>
)
})
// Or use context for deep passing
import { createContextId, useContextProvider, useContext } from '@builder.io/qwik'
export const ThemeContext = createContextId<string>('theme')
export const Root = component$(() => {
useContextProvider(ThemeContext, 'dark')
return <Slot />
})
export const Child = component$(() => {
const theme = useContext(ThemeContext)
return <div class={theme}>Content</div>
})
Inline Component Pattern (No $)
For non-interactive wrapper components that don't need lazy loading:
// No component$() — this is an eager, lightweight wrapper
export function Container(props: { title: string; children?: any }) {
return (
<section>
<h2>{props.title}</h2>
{props.children}
</section>
)
}
Slot Pattern with Fallback
export const Card = component$(() => {
return (
<div class="card">
<div class="card-header">
<Slot name="header" />
</div>
<div class="card-body">
<Slot />
</div>
</div>
)
})
Lazy-Loaded Third-Party Library
export default component$(() => {
useVisibleTask$(async () => {
const Chart = await import('chart.js')
// Chart is only downloaded when this component becomes visible
new Chart(ctx, { /* options */ })
})
return <canvas ref={canvasRef} />
})
Infinite Scroll Pattern
export default component$(() => {
const items = useSignal<Item[]>([])
const page = useSignal(1)
const loading = useSignal(false)
const loadMore = $(async () => {
if (loading.value) return
loading.value = true
const newItems = await fetch(`/api/items?page=${page.value}`).then(r => r.json())
items.value = [...items.value, ...newItems]
page.value++
loading.value = false
})
return (
<div>
{items.value.map(item => <ItemCard item={item} />)}
<button onClick$={loadMore}>
{loading.value ? 'Loading...' : 'Load More'}
</button>
</div>
)
})
Debounced Search Pattern
export default component$(() => {
const query = useSignal('')
const results = useSignal<SearchResult[]>([])
const search = $(async (q: string) => {
if (q.length < 2) { results.value = []; return }
results.value = await fetch(`/api/search?q=${q}`).then(r => r.json())
})
return (
<div>
<input
onInput$={async (_, el) => {
query.value = el.value
await search(el.value)
}}
placeholder="Search..."
/>
<ul>
{results.value.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
</div>
)
})
State Management Patterns
useSignal for Primitives
const count = useSignal(0) // number
const name = useSignal('') // string
const isActive = useSignal(true) // boolean
const items = useSignal<string[]>([]) // array (replaced entirely)
useStore for Objects
const user = useStore({
id: '',
name: '',
email: '',
preferences: { theme: 'light', language: 'en' },
metadata: { createdAt: null as Date | null },
})
Qwik tracks property-level reads. Mutations like user.preferences.theme = 'dark' trigger targeted re-renders without VDOM diffing.
Derived State
const firstName = useSignal('John')
const lastName = useSignal('Doe')
const fullName = useComputed$(() =>
`${firstName.value} ${lastName.value}`
)
// fullName.value updates automatically when firstName or lastName changes
Context-Based Shared State
// Define context type
interface AuthState { user: User | null; login: (email: string, password: string) => Promise<void> }
// Create context ID
export const AuthContext = createContextId<AuthState>('auth')
// Provider in root layout
export const Root = component$(() => {
const authState = useStore<AuthState>({
user: null,
login: $(async (email, password) => {
const user = await loginUser(email, password)
authState.user = user
}),
})
useContextProvider(AuthContext, authState)
return <Slot />
})
// Consumer in any child
export const Profile = component$(() => {
const auth = useContext(AuthContext)
return <div>{auth.user ? `Welcome ${auth.user.name}` : 'Not logged in'}</div>
})
Server State Synchronization
Use routeLoader$ + routeAction$ for server state. The client can invalidate loaders after an action:
export const usePosts = routeLoader$(async () => {
return await db.post.findMany() as Post[]
})
export const useCreatePost = routeAction$(async (form) => {
await db.post.create({ data: { title: form.get('title') } })
// Loader automatically re-runs on next request
})
export default component$(() => {
const posts = usePosts()
const createPost = useCreatePost()
return <Form action={createPost}>...</Form>
})
URL as State Source
export default component$(() => {
const loc = useLocation()
const nav = useNavigate()
// Read state from URL
const currentPage = Number(loc.params.page) || 1
const sortBy = loc.url.searchParams.get('sort') || 'date'
// Update URL
return <button onClick$={() => nav(`/list?sort=${sortBy}&page=${currentPage + 1}`)}>Next</button>
})
Performance Optimization
- Initial load: ~10KB JS baseline, zero hydration cost, instant TTI.
- Lazy chunks: Each $() is ~200-500 bytes, loaded on interaction only.
- Serialized state: HTML size increases by ~2-5KB per page with serialized signals.
- Prefetch: PrefetchServiceWorker uses idle time to download route chunks.
- Bundle splitting: Automatic at the $ boundary — no manual code splitting.
- Memory: Fine-grained signals use less memory than virtual DOM tree.
- Time to interactive: Near zero because there is no hydration to wait for.
Chunk Size Budgets
- Each $ boundary: 200-500 bytes target
- Route loader data: < 10KB serialized per page
- Total initial JS: < 15KB
- Total per-interaction JS: < 5KB
Measuring Performance
npx qwik build --analyze # opens bundle analyzer
npm run qwik build && node dist/server/entry.mjs # test SSR perf
Build & Bundle Considerations
Entry Strategies
| Strategy | Chunks | Use Case |
|---|---|---|
smart |
Many small chunks (default) | Most applications — optimal lazy loading |
hoist |
Fewer, larger chunks | Apps with many shared dependencies |
single |
One bundle | Small apps, no lazy benefit needed |
Configure in vite.config.ts:
qwikVite({ entryStrategy: { type: 'hoist' } })
Tree Shaking
Qwik's optimizer automatically tree-shakes unused exports from $() boundaries. Only code reachable from a $() boundary is included in the client bundle.
CSS Bundling
- CSS imported in components is automatically extracted and split
- Global CSS in
src/global.cssis inlined in the initial HTML - Use CSS Modules for component-scoped styles:
import styles from './component.module.css'
Adapter-Specific Builds
// Cloudflare Pages
import cloudflarePages from '@builder.io/qwik-city/adapters/cloudflare-pages/vite'
// Node.js server
import nodeServer from '@builder.io/qwik-city/adapters/node-server/vite'
// Vercel Edge
import vercelEdge from '@builder.io/qwik-city/adapters/vercel-edge/vite'
Each adapter changes the SSR entry point and output format.
Testing Strategies
Component Unit Tests
// __tests__/counter.test.tsx
import { createDOM } from '@builder.io/qwik/testing'
import { test, expect } from 'vitest'
import Counter from '../src/components/counter'
test('should increment', async () => {
const { screen, render, userEvent } = await createDOM()
await render(<Counter />)
expect(screen.querySelector('button')?.innerHTML).toBe('0')
await userEvent('button', 'click')
expect(screen.querySelector('button')?.innerHTML).toBe('1')
})
Signal/Store Logic Tests
// __tests__/state.test.ts
import { test, expect } from 'vitest'
import { useSignal, useStore } from '@builder.io/qwik'
test('signal updates reactively', () => {
const count = useSignal(0)
expect(count.value).toBe(0)
count.value = 5
expect(count.value).toBe(5)
})
test('store tracks nested mutations', () => {
const state = useStore({ user: { name: 'John' } })
expect(state.user.name).toBe('John')
state.user.name = 'Jane'
expect(state.user.name).toBe('Jane')
})
Route Loader Tests
// __tests__/loaders.test.ts
import { test, expect } from 'vitest'
import { useProductData } from '../src/routes/product/[id]'
test('loader fetches product by id', async () => {
const mockContext = {
params: { id: '1' },
request: new Request('http://localhost/product/1'),
// ... other context properties
}
const result = await useProductData(mockContext)
expect(result).toHaveProperty('name')
expect(result).toHaveProperty('price')
})
E2E Testing
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test'
test('navigates without full page reload', async ({ page }) => {
await page.goto('/')
await page.click('a[href="/about"]')
await expect(page.locator('h1')).toHaveText('About')
// Verify client-side navigation (no full page reload)
})
test('form submission with validation', async ({ page }) => {
await page.goto('/dashboard')
await page.fill('input[name="name"]', 'A')
await page.click('button[type="submit"]')
await expect(page.locator('.error')).toContainText('too short')
})
Migration Patterns
React to Qwik Migration Plan
Phase 1 — Audit existing components:
- Identify which components need client interactivity
- Map React hooks to Qwik equivalents
- Identify server-side data dependencies
Phase 2 — Rewrite leaf components:
// React
function Toggle() {
const [on, setOn] = useState(false)
return <button => setOn(!on)}>{on ? 'ON' : 'OFF'}</button>
}
// Qwik
export default component$(() => {
const on = useSignal(false)
return <button onClick$={() => on.value = !on.value}>{on.value ? 'ON' : 'OFF'}</button>
})
Phase 3 — Migrate routing and data:
- Replace React Router with Qwik City file-based routing
- Replace React Query/useEffect fetches with routeLoader$
- Replace API routes with resource routes or server$
Phase 4 — Enable prefetch and optimize:
- Add PrefetchServiceWorker
- Verify $ boundary coverage with Qwik DevTools
- Audit chunk sizes with
qwik build --analyze
Next.js to Qwik City
| Next.js | Qwik City |
|---|---|
pages/[slug].tsx |
src/routes/[slug]/index.tsx |
getServerSideProps |
routeLoader$ |
getStaticProps |
routeLoader$ with SSG |
| Server Actions | routeAction$ |
middleware.ts |
plugin@name.ts |
layout.tsx |
layout.tsx per segment |
next/image |
qwik-image or <img> with lazy loading |
Incremental Strategy
- Route-level — Migrate one route at a time, keeping old and new coexisting
- Component-level — Embed Qwik widgets in existing pages via web components
- Feature-level — Build new features in Qwik, leave legacy features in old framework
Anti-Patterns
Missing $ Suffix
// Anti-pattern: onClick without $ — handler is eager, not lazy
<button => doSomething()}>Click</button>
// Correct
<button onClick$={() => doSomething()}>Click</button>
State Outside Qwik's Reactivity System
// Anti-pattern: plain variable — changes won't trigger re-render
let count = 0
// Correct: useSignal
const count = useSignal(0)
Data Fetching in useVisibleTask$
// Anti-pattern: useVisibleTask$ for data
useVisibleTask$(async () => {
const data = await fetch('/api/data').then(r => r.json())
state.value = data
})
// Correct: routeLoader$
export const useData = routeLoader$(async () => {
return await fetch('/api/data').then(r => r.json())
})
Large Serialized State
// Anti-pattern: serializing entire data set
const allProducts = useSignal(await fetch('/api/products/10000').then(r => r.json()))
// Correct: lazy load, paginate
export const useProducts = routeLoader$(async ({ query }) => {
const page = Number(query.get('page')) || 1
return await db.product.findMany({ skip: (page-1)*20, take: 20 })
})
Mixing React and Qwik in Same Component
Don't import or use React hooks inside a Qwik component. The reactivity systems are incompatible.
Over-Serialization
// Anti-pattern: storing non-serializable values in signals
const chartInstance = useSignal<Chart | null>(null) // Chart is a class instance
// Correct: use NoSerialize wrapper
import { NoSerialize } from '@builder.io/qwik'
const chartInstance = useSignal<NoSerialize<Chart>>()
Common Pitfalls
- Forgetting $ on event handlers:
onClickinstead ofonClick$— event never gets lazy loaded. - Using useState from React: Qwik uses useSignal and useStore, not hooks from other frameworks.
- Heavy useVisibleTask$ usage: Every useVisibleTask$ blocks resumability. Keep them minimal.
- Dynamic $() calls:
$(someCondition ? fn1 : fn2)breaks the optimizer. Use static boundaries. - Missing PrefetchServiceWorker: Without it, lazy navigation produces visible network waterfalls.
- Mutable state reassignment:
state = newValueinstead ofstate.value = newValuefor signals. - Nested reactivity with useStore: Objects inside useStore are deeply reactive — use for form state.
- Not using typed loaders: routeLoader$ returns
unknownwithout explicit typing.
Best Practices
- Every
$()should be statically analyzable — no dynamic code inside dollar boundaries. useSignalfor primitives,useStorefor objects. Never plainletorconst.- Colocate routeLoader$ and routeAction$ in the route file that uses them.
- Use
useComputed$for derived values — avoids manual synchronization. - Keep useVisibleTask$ focused on a single concern — one observer per task.
- Lazy-load heavy libraries inside
$()callbacks, not at module level. - Use
NoSerializewrapper for non-serializable values (class instances, DOM refs). - Prefetch critical routes after initial render with PrefetchServiceWorker.
Compared With
| Aspect | Qwik | React (Client) | Solid |
|---|---|---|---|
| Rendering | Resumable | Hydrating | Hydrating |
| Lazy loading | Per-event ($) | Per-component (lazy()) | Per-component (lazy()) |
| Reactivity | Signal-based, fine-grained | Virtual DOM diff | Signal-based |
| Serialization | Automatic via QRL | Manual (JSON) | Manual |
| Server functions | Built-in (server$) | Via Server Actions | Via SolidStart |
| Bundle size | ~10KB (initial) | ~40KB+ (React runtime) | ~8KB (initial) |
Tooling
npm run qwik build— production build with Qwik optimizer.npm run qwik dev— dev server with HMR and the Qwik DevTools panel.npm run qwik lint— ESLint with Qwik-specific rules (ensures $ boundaries).npm run qwik qwik add— CLI to add integrations (auth, database, UI).- Qwik DevTools browser extension — inspect lazy boundaries and QRL chunks.
@builder.io/qwik-labs— experimental features and utilities.qwik-speak— internationalization library for Qwik.qwik-image— optimized images with lazy loading.
Rules
- No eager code — every component, event, and closure is lazy by default via
$. useSignalfor primitives,useStorefor objects. Never use plainletorconstfor reactive state.routeLoader$for data fetching,routeAction$with<Form>for mutations.useVisibleTask$only for client-only effects (intersection observers, analytics) — avoid for data fetching.- PrefetchServiceWorker is required for instant navigation — without it, lazy loading becomes waterfall.
- Qwik optimizer must be able to see
$()boundaries — no dynamic$calls. - Serialized state stays in HTML — never rely on sessionStorage for critical app state.
- Dollar boundaries must be statically analyzable — no dynamic expressions inside $().
References
- references/qwik-city-patterns.md — Qwik City Patterns
- references/qwik-component-patterns.md — Qwik Component Patterns
- references/qwik-data.md — Qwik Data Patterns
- references/qwik-routing.md — Qwik Routing Patterns
- references/qwik-state-management.md — Qwik State Management
- references/resumable-patterns.md — Qwik Resumable Patterns
- references/qwik-component-composition.md — Qwik Component Composition Reference
- references/qwik-form-validation.md — Qwik Form Validation Reference
Handoff
No artifact produced. Next skill: frontend-universal-testing for Qwik component tests. Or frontend-universal-performance. Carry forward: resumability boundaries, dollar-sign conventions, prefetch strategy.