Qwik Complete Guide | Resumable JavaScript Framework
이 글의 핵심
Qwik is a new framework that delivers instant-loading web apps through resumability. Unlike React/Vue, Qwik serializes app state on the server and resumes on the client without hydration.
Introduction
Qwik is a new kind of web framework that solves the hydration problem. Instead of shipping JavaScript to re-create the app state on the client, Qwik serializes the state on the server and resumes execution on the client.
The Hydration Problem
Traditional SSR (React/Next.js):
- Server renders HTML
- Browser displays HTML (fast!)
- JavaScript downloads
- Hydration: React re-runs all components to attach listeners
- App becomes interactive (slow!)
Qwik’s Resumability:
- Server renders HTML + serialized state
- Browser displays HTML (fast!)
- App is immediately interactive
- JavaScript loads only when needed
Bundle Size Comparison
For a simple counter:
- React SSR: 40KB JavaScript (entire React runtime)
- Qwik: 1KB JavaScript (only event handler)
1. Installation
npm create qwik@latest
cd my-qwik-app
npm install
npm run dev
2. Basic Concepts
Components
import { component$ } from '@builder.io/qwik';
export const Counter = component$(() => {
return <div>Hello, Qwik!</div>;
});
Note the $ suffix: It tells Qwik optimizer to lazy-load this code.
State with useSignal
import { component$, useSignal } from '@builder.io/qwik';
export const Counter = component$(() => {
const count = useSignal(0);
return (
<div>
<p>Count: {count.value}</p>
<button onClick$={() => count.value++}>Increment</button>
</div>
);
});
Key differences from React:
useSignalinstead ofuseState- Access with
.value onClick$instead ofonClick(lazy-loaded!)
3. Event Handling
onClick$
export const Button = component$(() => {
const handleClick$ = $(() => {
console.log('Clicked!');
});
return <button onClick$={handleClick$}>Click me</button>;
});
Inline Handlers
export const Form = component$(() => {
const name = useSignal('');
return (
<input
value={name.value}
onInput$={(e) => name.value = e.target.value}
/>
);
});
4. useStore (Objects)
import { component$, useStore } from '@builder.io/qwik';
export const UserProfile = component$(() => {
const user = useStore({
name: 'John',
age: 30,
email: 'john@example.com',
});
return (
<div>
<input
value={user.name}
onInput$={(e) => user.name = e.target.value}
/>
<p>Age: {user.age}</p>
</div>
);
});
5. Async Data with routeLoader$
// routes/users/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
export const useUsers = routeLoader$(async () => {
const res = await fetch('https://api.example.com/users');
return res.json();
});
export default component$(() => {
const users = useUsers();
return (
<ul>
{users.value.map((user: any) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
});
Key points:
routeLoader$runs on the server- Data is serialized and sent to client
- No hydration needed!
6. Forms with routeAction$
import { component$ } from '@builder.io/qwik';
import { routeAction$, Form } from '@builder.io/qwik-city';
export const useAddUser = routeAction$(async (data) => {
// Runs on server
const res = await fetch('https://api.example.com/users', {
method: 'POST',
body: JSON.stringify(data),
});
return await res.json();
});
export default component$(() => {
const action = useAddUser();
return (
<Form action={action}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Add User</button>
{action.value?.success && <p>User added!</p>}
</Form>
);
});
7. Routing with Qwik City
File-based Routing
src/routes/
├── index.tsx → /
├── about.tsx → /about
├── blog/
│ ├── index.tsx → /blog
│ └── [slug].tsx → /blog/:slug
└── users/
└── [id]/
└── index.tsx → /users/:id
Dynamic Routes
// routes/blog/[slug].tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
export const usePost = routeLoader$(async ({ params }) => {
const res = await fetch(`/api/posts/${params.slug}`);
return res.json();
});
export default component$(() => {
const post = usePost();
return (
<article>
<h1>{post.value.title}</h1>
<div>{post.value.content}</div>
</article>
);
});
Navigation
import { component$ } from '@builder.io/qwik';
import { Link } from '@builder.io/qwik-city';
export const Nav = component$(() => {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/blog">Blog</Link>
</nav>
);
});
8. Lifecycle Hooks
useVisibleTask$
import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik';
export const Chart = component$(() => {
const chartRef = useSignal<HTMLDivElement>();
useVisibleTask$(({ track }) => {
track(() => chartRef.value);
// Runs when component becomes visible
if (chartRef.value) {
// Initialize chart library
initChart(chartRef.value);
}
});
return <div ref={chartRef}></div>;
});
useTask$
import { component$, useSignal, useTask$ } from '@builder.io/qwik';
export const AutoSave = component$(() => {
const text = useSignal('');
useTask$(({ track }) => {
track(() => text.value);
// Runs on server AND client when text changes
console.log('Text changed:', text.value);
});
return <input value={text.value} onInput$={(e) => text.value = e.target.value} />;
});
9. Context API
import { component$, createContextId, useContextProvider, useContext } from '@builder.io/qwik';
// Create context
export const UserContext = createContextId<{ name: string }>('user');
// Provider
export const App = component$(() => {
const user = useStore({ name: 'John' });
useContextProvider(UserContext, user);
return <UserProfile />;
});
// Consumer
export const UserProfile = component$(() => {
const user = useContext(UserContext);
return <div>{user.name}</div>;
});
10. Real-World Example: Todo App
import { component$, useStore } from '@builder.io/qwik';
import { routeLoader$, routeAction$, Form } from '@builder.io/qwik-city';
export const useTodos = routeLoader$(async () => {
// Load from database
return [
{ id: 1, text: 'Learn Qwik', done: false },
{ id: 2, text: 'Build app', done: false },
];
});
export const useAddTodo = routeAction$(async (data) => {
// Save to database
return { success: true };
});
export const useToggleTodo = routeAction$(async (data) => {
// Update in database
return { success: true };
});
export default component$(() => {
const todos = useTodos();
const addAction = useAddTodo();
const toggleAction = useToggleTodo();
return (
<div>
<h1>Todo App</h1>
<Form action={addAction}>
<input name="text" required />
<button type="submit">Add</button>
</Form>
<ul>
{todos.value.map((todo) => (
<li key={todo.id}>
<Form action={toggleAction}>
<input type="hidden" name="id" value={todo.id} />
<input
type="checkbox"
checked={todo.done}
onChange$={() => {
// Submit form on change
}}
/>
<span>{todo.text}</span>
</Form>
</li>
))}
</ul>
</div>
);
});
11. Performance Benefits
Zero JavaScript by Default
// This page ships ZERO JavaScript
export default component$(() => {
return (
<div>
<h1>Hello, World!</h1>
<p>Static content needs no JS!</p>
</div>
);
});
Fine-grained Lazy Loading
// Only this button's handler is loaded when clicked
export default component$(() => {
return (
<div>
<h1>Page content (no JS)</h1>
<button onClick$={() => console.log('Clicked')}>
Click me (1KB JS loaded on click)
</button>
</div>
);
});
12. Deployment
Cloudflare Pages
npm run build
wrangler pages publish dist
Vercel
npm run build
vercel deploy
Node.js
npm run build
npm run serve
13. Best Practices
1. Use $ for Lazy Loading
// Good: Lazy-loaded
const handleClick$ = $(() => console.log('Clicked'));
// Bad: Eagerly loaded
const handleClick = () => console.log('Clicked');
2. Use useSignal for Primitives
// Good
const count = useSignal(0);
// Unnecessary for primitives
const state = useStore({ count: 0 });
3. Prefer routeLoader$ over useTask$
// Good: Runs once on server
export const useData = routeLoader$(async () => {
return await fetchData();
});
// Bad: Runs on server AND client
useTask$(async () => {
const data = await fetchData();
});
Summary
Qwik delivers instant-loading apps through resumability:
- Zero hydration - app resumes, doesn’t restart
- Fine-grained lazy loading - load code only when needed
- Zero JavaScript by default - only ship what’s needed
- Instant interactivity - no waiting for hydration
- Optimized automatically -
$syntax enables magic
Key Takeaways:
- Use
$suffix for lazy-loading useSignalfor state,.valueto accessrouteLoader$for server datarouteAction$for forms- Zero JavaScript for static content
Next Steps:
- Compare with Next.js 15
- Learn Astro
- Try Solid.js
Resources: