Qwik Complete Guide | Resumable JavaScript Framework

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):

  1. Server renders HTML
  2. Browser displays HTML (fast!)
  3. JavaScript downloads
  4. Hydration: React re-runs all components to attach listeners
  5. App becomes interactive (slow!)

Qwik’s Resumability:

  1. Server renders HTML + serialized state
  2. Browser displays HTML (fast!)
  3. App is immediately interactive
  4. 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:

  • useSignal instead of useState
  • Access with .value
  • onClick$ instead of onClick (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: '[email protected]',
  });
  
  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>
  );
});
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:

  1. Use $ suffix for lazy-loading
  2. useSignal for state, .value to access
  3. routeLoader$ for server data
  4. routeAction$ for forms
  5. Zero JavaScript for static content

Next Steps:

Resources: