Qwik, framework bez hydratacji z wznawianiem stanu
Qwik to framework webowy zbudowany wokół jednego pomysłu: przeglądarka nie powinna wykonywać kodu, którego użytkownik nie potrzebuje. Zamiast odtwarzać stan aplikacji po stronie klienta, wznawia go z informacji zapisanych w dokumencie i pobiera kod dopiero w momencie interakcji. Projekt jest na licencji MIT, a repozytorium ma ponad dwadzieścia dwa tysiące gwiazdek.
Jak to działa i dlaczego to nietypowe
Żeby zrozumieć sens tego rozwiązania, trzeba nazwać problem, który rozwiązuje, bo jest on niewidoczny dopóki się o nim nie wie.
Klasyczny framework renderuje stronę na serwerze i wysyła gotowy HTML, co daje szybkie pierwsze wyświetlenie. Potem jednak przeglądarka musi pobrać cały kod aplikacji, wykonać go i odtworzyć stan, żeby strona zaczęła reagować na kliknięcia. Ten krok nazywa się hydratacją i jest wykonywany zawsze, niezależnie od tego, czy użytkownik cokolwiek kliknie. Płacisz za niego czasem procesora na urządzeniu, którego nie kontrolujesz.
Wznawianie odwraca ten układ. Serwer zapisuje w dokumencie informacje o tym, gdzie leży stan i który kod obsługuje które zdarzenie, a przeglądarka nie wykonuje na starcie niczego. Dopiero kliknięcie powoduje pobranie tego jednego fragmentu kodu, który obsługuje to konkretne zdarzenie.
Konsekwencja jest mierzalna: ilość JavaScriptu wykonywanego przy wejściu na stronę praktycznie nie zależy od rozmiaru aplikacji. Strona z jednym przyciskiem i strona z rozbudowanym panelem startują podobnie, bo w obu przypadkach na starcie nie wykonuje się nic.
Jest to zarazem powód, dla którego składnia wygląda inaczej. Znak dolara na końcu nazw funkcji nie jest ozdobnikiem, tylko instrukcją dla narzędzia budującego: w tym miejscu przetnij kod na osobny fragment, który da się pobrać niezależnie. Bez tych granic nie dałoby się pobierać kodu kawałkami.
Ta konstrukcja niesie ograniczenie, które zaskakuje osoby przychodzące z innych frameworków. Funkcja oznaczona dolarem trafia do osobnego pliku, więc nie może swobodnie sięgać po zmienne z otoczenia, w którym została napisana. Wszystko, czego potrzebuje, musi dać się zapisać i odtworzyć, a to wyklucza między innymi przekazywanie do niej funkcji utworzonych w locie albo obiektów z odwołaniami do elementów przeglądarki. Komunikat błędu w takiej sytuacji mówi wprost o problemie z serializacją i jest to najczęstsza rzecz, o którą potykają się osoby uczące się tego frameworka.
W praktyce oznacza to inny sposób myślenia o granicach kodu. Zamiast pisać komponent jako całość i dopiero potem szukać, co da się wydzielić, od początku układasz go tak, żeby fragmenty reagujące na zdarzenia były samowystarczalne. Po kilku dniach staje się to odruchem, ale pierwszy tydzień bywa frustrujący właśnie z tego powodu.
Wersja 2, czyli co się zmieniło i pod jaką nazwą
To najważniejsza rzecz przy czytaniu starszych materiałów, bo dotyczy nawet nazwy pakietu.
Wersja stabilna to 1.20.0 w pakiecie @builder.io/qwik. Dwójka jest w becie od dłuższego czasu, w chwili pisania w wydaniu 2.0.0-beta.38 z 16 lipca 2026 roku, i najważniejszą zmianą jest przeniesienie do zupełnie nowej przestrzeni nazw. Pakiety nazywają się teraz @qwik.dev/core i @qwik.dev/router, więc każdy import w projekcie wymaga poprawki. Zapowiedziano narzędzie automatyzujące tę migrację.
Poza nazwą doszły rzeczy, które mają znaczenie w praktyce. Wysyłany HTML jest lżejszy, bo zniknęły węzły komentarzy używane wcześniej do oznaczania granic. Planowanie zadań przyspieszyło, a wewnętrzna logika została uproszczona. Z nowych możliwości warto znać sygnał wyliczany asynchronicznie oraz mechanizm pozwalający bibliotekom zewnętrznym decydować, jak ich dane mają być przekazane z serwera do przeglądarki.
Praktyczny wniosek przy planowaniu: nowy projekt zaczynaj świadomie, wiedząc, że wersja stabilna i rozwijana to dziś dwa różne pakiety. Migracja z jedynki na dwójkę nie jest aktualizacją numeru, tylko zmianą wszystkich importów, więc warto poczekać na narzędzie migracyjne albo zaplanować to jako osobne zadanie.
Kiedy to jest właściwy wybór
Decyzja o niszowym frameworku wymaga uzasadnienia mocniejszego niż ciekawość, więc warto nazwać sytuacje, w których to uzasadnienie faktycznie istnieje.
Pierwsza to strona, na którą ludzie trafiają z wyszukiwarki i zwykle nie wracają. Sklep, serwis z treścią, katalog produktów. Tam liczy się, jak szybko strona staje się użyteczna dla kogoś, kto widzi ją pierwszy raz, i tam różnica jest największa, bo klasyczne podejście płaci pełny koszt startu przy każdej wizycie.
Druga to ruch z urządzeń o słabych procesorach. Wykonanie kodu na telefonie średniej klasy trwa kilkakrotnie dłużej niż na laptopie programisty, więc oszczędność na starcie przekłada się na realną różnicę w odbiorze, a nie na lepszy wynik w narzędziu pomiarowym.
Trzecia to sytuacja, w której zmierzyłeś problem i wiesz, że leży właśnie w koszcie startu. To jest warunek, o którym najłatwiej zapomnieć: jeśli Twoja strona jest wolna z powodu nieoptymalnych obrazów albo zapytań do bazy, zmiana frameworka nic nie da, a doda ryzyko.
Odwrotnie, są sytuacje, w których wybór jest trudny do obrony. Aplikacja za logowaniem, gdzie użytkownicy pracują godzinami, panel wewnętrzny, narzędzie dla zespołu. Tam koszt pierwszego wejścia rozkłada się na długą sesję i przestaje mieć znaczenie, a zostaje wąski ekosystem i mniejsza pula osób, które ten kod utrzymają. W takim wypadku sensowniej sięgnąć po Next.js albo inny framework z dużym zapleczem.
Czym jest Qwik?
Qwik to rewolucyjny framework JavaScript stworzony przez Miško Hevery (twórcę Angulara) i zespół Builder.io. Wprowadza całkowicie nowe podejście do renderowania aplikacji webowych poprzez koncepcję "resumability" - zamiast tradycyjnej hydration, aplikacja Qwik jest interaktywna natychmiast po załadowaniu HTML, bez konieczności ładowania i wykonywania całego JavaScript z góry.
Framework rozwiązuje fundamentalny problem współczesnych aplikacji SPA: nawet przy SSR, użytkownik musi czekać na pobranie i wykonanie całego JS bundle, zanim strona stanie się interaktywna. Qwik eliminuje ten problem poprzez serializację stanu aplikacji bezpośrednio w HTML i lazy-loading JavaScript na poziomie pojedynczych event handlerów.
Problem: Hydration Tax
Jak działa tradycyjne SSR
W tradycyjnych frameworkach (React, Vue, Svelte) Server-Side Rendering przebiega następująco:
1. Serwer → Renderuje HTML
2. Przeglądarka → Pobiera HTML, wyświetla statyczną stronę
3. Przeglądarka → Pobiera CAŁY JavaScript bundle (50-500KB+)
4. Przeglądarka → Wykonuje JavaScript
5. Framework → "Hydratuje" stronę (re-renderuje wszystko w pamięci)
6. Strona → Staje się interaktywnaProblem: Kroki 3-5 to "hydration tax" - czas, który użytkownik musi czekać na interaktywność. Na wolnych urządzeniach mobilnych może to trwać kilka sekund.
Jak działa Qwik Resumability
1. Serwer → Renderuje HTML + serializuje stan w atrybutach
2. Przeglądarka → Pobiera HTML, wyświetla stronę
3. Strona → Jest NATYCHMIAST interaktywna
4. Przeglądarka → Pobiera JS tylko dla klikniętych elementów (lazy)Korzyść: Brak hydration = natychmiastowa interaktywność.
Qwik vs React/Vue - porównanie wydajności
| Metryka | Qwik | React | Vue | Angular |
|---|---|---|---|---|
| Initial JS | ~1KB | 50-100KB | 40-80KB | 80-150KB |
| Time to Interactive | Instant | 1-5s | 1-4s | 2-6s |
| Hydration | Brak (resume) | Tak | Tak | Tak |
| Lazy loading | Per-listener | Per-route | Per-route | Per-route |
| Bundle growth | Liniowy | Wykładniczy | Wykładniczy | Wykładniczy |
| SSR overhead | Minimalny | Wysoki | Średni | Wysoki |
Instalacja i konfiguracja
Tworzenie nowego projektu
# Inicjalizacja projektu z Qwik CLI
npm create qwik@latest
# Lub z pnpm
pnpm create qwik@latest
# Interaktywny wizard zapyta o:
# - Nazwę projektu
# - Starter template (podstawowy, z integrami)
# - Czy dodać Qwik City (routing/meta-framework)Struktura projektu Qwik City
my-qwik-app/
├── src/
│ ├── components/ # Komponenty Qwik
│ │ ├── header/
│ │ │ └── header.tsx
│ │ └── footer/
│ │ └── footer.tsx
│ ├── routes/ # File-based routing
│ │ ├── index.tsx # / (home page)
│ │ ├── about/
│ │ │ └── index.tsx # /about
│ │ ├── blog/
│ │ │ ├── index.tsx # /blog
│ │ │ └── [slug]/
│ │ │ └── index.tsx # /blog/:slug
│ │ └── layout.tsx # Shared layout
│ ├── entry.ssr.tsx # SSR entry point
│ └── root.tsx # Root component
├── public/ # Static assets
├── vite.config.ts # Vite configuration
├── qwik.config.ts # Qwik configuration
└── package.jsonPodstawowa konfiguracja
// vite.config.ts
import { defineConfig } from 'vite'
import { qwikVite } from '@builder.io/qwik/optimizer'
import { qwikCity } from '@builder.io/qwik-city/vite'
export default defineConfig(() => {
return {
plugins: [qwikCity(), qwikVite()],
server: {
port: 5173,
},
preview: {
port: 4173,
},
}
})Podstawy Qwik - składnia
Komponenty z component$
// components/counter.tsx
import { component$, useSignal } from '@builder.io/qwik'
// $ suffix oznacza, że funkcja jest lazy-loaded
export const Counter = component$(() => {
// useSignal - reaktywny stan (fine-grained reactivity)
const count = useSignal(0)
return (
<div class="counter">
<p>Count: {count.value}</p>
{/* onClick$ - handler lazy-loaded przy pierwszym kliknięciu */}
<button onClick$={() => count.value++}>
Increment
</button>
<button onClick$={() => count.value--}>
Decrement
</button>
</div>
)
})Znaczenie symbolu $ (Dollar Sign)
Symbol $ w Qwik oznacza granicę lazy-loadingu. Wszystko po $ jest serializowane i ładowane tylko gdy potrzebne:
import { component$, $, useSignal } from '@builder.io/qwik'
export const Example = component$(() => {
const message = useSignal('')
// $ tworzy lazy-loaded funkcję
const handleClick = $(() => {
// Ten kod jest w osobnym chunk i ładowany przy kliknięciu
message.value = 'Button clicked!'
console.log('This code was lazy-loaded')
})
// onClick$ automatycznie opakowuje w $
return (
<div>
<button onClick$={handleClick}>Click me</button>
<p>{message.value}</p>
</div>
)
})useSignal - reaktywny stan
import { component$, useSignal } from '@builder.io/qwik'
export const SignalExample = component$(() => {
// Prymitywne wartości
const count = useSignal(0)
const name = useSignal('John')
const isActive = useSignal(true)
// Zmiana wartości
const increment = $(() => {
count.value++
})
const updateName = $((newName: string) => {
name.value = newName
})
return (
<div>
<p>Count: {count.value}</p>
<p>Name: {name.value}</p>
<p>Active: {isActive.value ? 'Yes' : 'No'}</p>
<button onClick$={increment}>+1</button>
<input
value={name.value}
onInput$={(e) => name.value = (e.target as HTMLInputElement).value}
/>
<button onClick$={() => isActive.value = !isActive.value}>
Toggle
</button>
</div>
)
})useStore - reaktywne obiekty
import { component$, useStore } from '@builder.io/qwik'
interface TodoItem {
id: number
text: string
completed: boolean
}
interface State {
todos: TodoItem[]
filter: 'all' | 'active' | 'completed'
newTodo: string
}
export const TodoApp = component$(() => {
// useStore dla złożonych obiektów - głęboka reaktywność
const state = useStore<State>({
todos: [],
filter: 'all',
newTodo: '',
})
const addTodo = $(() => {
if (state.newTodo.trim()) {
// Bezpośrednia mutacja - reaktywna!
state.todos.push({
id: Date.now(),
text: state.newTodo,
completed: false,
})
state.newTodo = ''
}
})
const toggleTodo = $((id: number) => {
const todo = state.todos.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
})
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed
if (state.filter === 'completed') return todo.completed
return true
})
return (
<div class="todo-app">
<input
value={state.newTodo}
onInput$={(e) => state.newTodo = (e.target as HTMLInputElement).value}
onKeyDown$={(e) => e.key === 'Enter' && addTodo()}
placeholder="Add todo..."
/>
<button onClick$={addTodo}>Add</button>
<div class="filters">
{(['all', 'active', 'completed'] as const).map(filter => (
<button
key={filter}
class={{ active: state.filter === filter }}
onClick$={() => state.filter = filter}
>
{filter}
</button>
))}
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange$={() => toggleTodo(todo.id)}
/>
<span class={{ completed: todo.completed }}>{todo.text}</span>
</li>
))}
</ul>
</div>
)
})useComputed$ - computed values
import { component$, useSignal, useComputed$ } from '@builder.io/qwik'
export const ComputedExample = component$(() => {
const firstName = useSignal('John')
const lastName = useSignal('Doe')
const items = useSignal([10, 20, 30, 40, 50])
// Computed - automatycznie przeliczane przy zmianie dependencies
const fullName = useComputed$(() => {
return `${firstName.value} ${lastName.value}`
})
const total = useComputed$(() => {
return items.value.reduce((sum, item) => sum + item, 0)
})
const average = useComputed$(() => {
const sum = items.value.reduce((s, i) => s + i, 0)
return items.value.length > 0 ? sum / items.value.length : 0
})
return (
<div>
<p>Full Name: {fullName.value}</p>
<p>Total: {total.value}</p>
<p>Average: {average.value.toFixed(2)}</p>
</div>
)
})useTask$ - side effects
import { component$, useSignal, useTask$ } from '@builder.io/qwik'
export const TaskExample = component$(() => {
const searchQuery = useSignal('')
const results = useSignal<string[]>([])
const isLoading = useSignal(false)
// useTask$ - wykonuje się przy zmianie tracked signals
useTask$(async ({ track, cleanup }) => {
// track() rejestruje dependency
const query = track(() => searchQuery.value)
if (!query || query.length < 3) {
results.value = []
return
}
isLoading.value = true
// Debounce
const timeoutId = setTimeout(async () => {
try {
const response = await fetch(`/api/search?q=${query}`)
results.value = await response.json()
} finally {
isLoading.value = false
}
}, 300)
// cleanup - wywoływane przed następnym wykonaniem
cleanup(() => clearTimeout(timeoutId))
})
return (
<div>
<input
value={searchQuery.value}
onInput$={(e) => searchQuery.value = (e.target as HTMLInputElement).value}
placeholder="Search..."
/>
{isLoading.value && <p>Searching...</p>}
<ul>
{results.value.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
</div>
)
})useVisibleTask$ - client-only effects
import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik'
export const ClientOnlyExample = component$(() => {
const windowWidth = useSignal(0)
const mousePosition = useSignal({ x: 0, y: 0 })
// useVisibleTask$ - wykonuje się TYLKO na kliencie
// Używaj gdy potrzebujesz dostępu do DOM/Browser APIs
useVisibleTask$(() => {
// Ten kod nigdy nie wykona się na serwerze
windowWidth.value = window.innerWidth
const handleResize = () => {
windowWidth.value = window.innerWidth
}
const handleMouseMove = (e: MouseEvent) => {
mousePosition.value = { x: e.clientX, y: e.clientY }
}
window.addEventListener('resize', handleResize)
window.addEventListener('mousemove', handleMouseMove)
// Cleanup
return () => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('mousemove', handleMouseMove)
}
})
return (
<div>
<p>Window width: {windowWidth.value}px</p>
<p>Mouse: ({mousePosition.value.x}, {mousePosition.value.y})</p>
</div>
)
})Qwik City - Meta-framework
Trasowanie oparte na plikach
src/routes/
├── index.tsx # → /
├── about/
│ └── index.tsx # → /about
├── blog/
│ ├── index.tsx # → /blog
│ └── [slug]/
│ └── index.tsx # → /blog/:slug
├── api/
│ └── users/
│ └── index.ts # → /api/users (server endpoint)
├── (auth)/ # Route group (nie dodaje do URL)
│ ├── login/
│ │ └── index.tsx # → /login
│ └── register/
│ └── index.tsx # → /register
└── layout.tsx # Shared layout dla wszystkich routesStrona z routeLoader$
// src/routes/blog/[slug]/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeLoader$, DocumentHead } from '@builder.io/qwik-city'
// routeLoader$ - data fetching na serwerze
export const usePost = routeLoader$(async ({ params, status }) => {
const response = await fetch(`https://api.example.com/posts/${params.slug}`)
if (!response.ok) {
status(404)
return null
}
return response.json() as Promise<{
title: string
content: string
author: string
date: string
}>
})
export default component$(() => {
// Dane są już załadowane na serwerze
const post = usePost()
if (!post.value) {
return <div>Post not found</div>
}
return (
<article class="blog-post">
<h1>{post.value.title}</h1>
<p class="meta">
By {post.value.author} on {new Date(post.value.date).toLocaleDateString()}
</p>
<div class="content" dangerouslySetInnerHTML={post.value.content} />
</article>
)
})
// Dynamic head/meta
export const head: DocumentHead = ({ resolveValue }) => {
const post = resolveValue(usePost)
return {
title: post?.title || 'Blog Post',
meta: [
{ name: 'description', content: post?.content?.slice(0, 160) || '' },
{ property: 'og:title', content: post?.title || 'Blog' },
],
}
}routeAction$ - akcje serwerowe
// src/routes/contact/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city'
// Walidacja z Zod
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
})
// routeAction$ - server mutation
export const useContactForm = routeAction$(
async (data, { fail }) => {
try {
// Wyślij do API
const response = await fetch('https://api.example.com/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
return fail(500, { message: 'Failed to send message' })
}
return { success: true, message: 'Message sent successfully!' }
} catch (error) {
return fail(500, { message: 'Server error' })
}
},
zod$(contactSchema)
)
export default component$(() => {
const action = useContactForm()
return (
<div class="contact-form">
<h1>Contact Us</h1>
{/* Form - progressive enhancement, działa bez JS */}
<Form action={action}>
<div class="field">
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
{action.value?.fieldErrors?.name && (
<span class="error">{action.value.fieldErrors.name}</span>
)}
</div>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" required />
{action.value?.fieldErrors?.email && (
<span class="error">{action.value.fieldErrors.email}</span>
)}
</div>
<div class="field">
<label for="message">Message</label>
<textarea id="message" name="message" rows={5} required />
{action.value?.fieldErrors?.message && (
<span class="error">{action.value.fieldErrors.message}</span>
)}
</div>
<button type="submit" disabled={action.isRunning}>
{action.isRunning ? 'Sending...' : 'Send Message'}
</button>
{action.value?.success && (
<p class="success">{action.value.message}</p>
)}
{action.value?.failed && (
<p class="error">{action.value.message}</p>
)}
</Form>
</div>
)
})server$ - Server Functions
import { component$, useSignal } from '@builder.io/qwik'
import { server$ } from '@builder.io/qwik-city'
// server$ - funkcja wykonywana na serwerze, wywoływana z klienta
const serverGreet = server$(async function (name: string) {
// Ten kod ZAWSZE działa na serwerze
// Masz dostęp do env, bazy danych, secrets
const apiKey = this.env.get('API_KEY')
console.log('Server log:', name, apiKey)
// Symulacja operacji serwerowej
await new Promise(resolve => setTimeout(resolve, 100))
return {
greeting: `Hello ${name} from server!`,
timestamp: new Date().toISOString(),
}
})
const fetchUserFromDB = server$(async function (userId: string) {
// Bezpieczne operacje na bazie danych
const db = await connectToDatabase()
const user = await db.users.findById(userId)
return user
})
export const ServerFunctionExample = component$(() => {
const result = useSignal<{ greeting: string; timestamp: string } | null>(null)
const isLoading = useSignal(false)
const callServer = $(async () => {
isLoading.value = true
try {
// Wywołanie server function z klienta
result.value = await serverGreet('World')
} finally {
isLoading.value = false
}
})
return (
<div>
<button onClick$={callServer} disabled={isLoading.value}>
{isLoading.value ? 'Loading...' : 'Call Server'}
</button>
{result.value && (
<div>
<p>{result.value.greeting}</p>
<small>At: {result.value.timestamp}</small>
</div>
)}
</div>
)
})Layout i nested routing
// src/routes/layout.tsx
import { component$, Slot } from '@builder.io/qwik'
import { routeLoader$ } from '@builder.io/qwik-city'
// Global data loader
export const useCurrentUser = routeLoader$(async ({ cookie }) => {
const token = cookie.get('auth-token')?.value
if (!token) return null
const response = await fetch('https://api.example.com/me', {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) return null
return response.json()
})
export default component$(() => {
const user = useCurrentUser()
return (
<div class="app">
<header>
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
<a href="/about">About</a>
{user.value ? (
<span>Welcome, {user.value.name}</span>
) : (
<a href="/login">Login</a>
)}
</nav>
</header>
<main>
{/* Slot renderuje child routes */}
<Slot />
</main>
<footer>
<p>© 2024 My App</p>
</footer>
</div>
)
})// src/routes/dashboard/layout.tsx - Nested layout
import { component$, Slot } from '@builder.io/qwik'
import { routeLoader$, useLocation } from '@builder.io/qwik-city'
// Middleware - redirect if not authenticated
export const onRequest: RequestHandler = async ({ redirect, cookie }) => {
const token = cookie.get('auth-token')?.value
if (!token) {
throw redirect(302, '/login')
}
}
export default component$(() => {
const location = useLocation()
const navItems = [
{ href: '/dashboard', label: 'Overview' },
{ href: '/dashboard/projects', label: 'Projects' },
{ href: '/dashboard/settings', label: 'Settings' },
]
return (
<div class="dashboard-layout">
<aside class="sidebar">
<nav>
{navItems.map(item => (
<a
key={item.href}
href={item.href}
class={{ active: location.url.pathname === item.href }}
>
{item.label}
</a>
))}
</nav>
</aside>
<div class="dashboard-content">
<Slot />
</div>
</div>
)
})API Routes
// src/routes/api/users/index.ts
import type { RequestHandler } from '@builder.io/qwik-city'
export const onGet: RequestHandler = async ({ json, query }) => {
const page = parseInt(query.get('page') || '1')
const limit = parseInt(query.get('limit') || '10')
const users = await db.users.findMany({
skip: (page - 1) * limit,
take: limit,
})
json(200, {
users,
pagination: { page, limit },
})
}
export const onPost: RequestHandler = async ({ json, parseBody, status }) => {
const body = await parseBody()
if (!body?.email || !body?.name) {
status(400)
return json(400, { error: 'Missing required fields' })
}
const user = await db.users.create({
data: { email: body.email, name: body.name },
})
json(201, user)
}// src/routes/api/users/[id]/index.ts
import type { RequestHandler } from '@builder.io/qwik-city'
export const onGet: RequestHandler = async ({ params, json, status }) => {
const user = await db.users.findById(params.id)
if (!user) {
status(404)
return json(404, { error: 'User not found' })
}
json(200, user)
}
export const onPut: RequestHandler = async ({ params, parseBody, json, status }) => {
const body = await parseBody()
const user = await db.users.update({
where: { id: params.id },
data: body,
})
if (!user) {
status(404)
return json(404, { error: 'User not found' })
}
json(200, user)
}
export const onDelete: RequestHandler = async ({ params, json, status }) => {
try {
await db.users.delete({ where: { id: params.id } })
json(200, { success: true })
} catch {
status(404)
json(404, { error: 'User not found' })
}
}Integracje
Tailwind CSS
npm run qwik add tailwind// Używanie Tailwind z Qwik
export const Button = component$<{
variant?: 'primary' | 'secondary'
}>((props) => {
const baseClasses = 'px-4 py-2 rounded-lg font-medium transition-colors'
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
}
return (
<button class={`${baseClasses} ${variants[props.variant || 'primary']}`}>
<Slot />
</button>
)
})Prisma
npm install prisma @prisma/client
npx prisma init// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client'
declare global {
var prisma: PrismaClient | undefined
}
export const prisma = globalThis.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma
}// src/routes/users/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeLoader$ } from '@builder.io/qwik-city'
import { prisma } from '~/lib/prisma'
export const useUsers = routeLoader$(async () => {
return prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
take: 20,
})
})
export default component$(() => {
const users = useUsers()
return (
<div>
<h1>Users</h1>
<ul>
{users.value.map(user => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
</div>
)
})Auth (z Lucia)
// src/lib/auth.ts
import { Lucia } from 'lucia'
import { PrismaAdapter } from '@lucia-auth/adapter-prisma'
import { prisma } from './prisma'
const adapter = new PrismaAdapter(prisma.session, prisma.user)
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === 'production',
},
},
getUserAttributes: (attributes) => ({
email: attributes.email,
name: attributes.name,
}),
})// src/routes/login/index.tsx
import { component$ } from '@builder.io/qwik'
import { routeAction$, Form, zod$, z } from '@builder.io/qwik-city'
import { lucia } from '~/lib/auth'
import { prisma } from '~/lib/prisma'
import { verifyPassword } from '~/lib/password'
export const useLogin = routeAction$(
async (data, { cookie, redirect, fail }) => {
const user = await prisma.user.findUnique({
where: { email: data.email },
})
if (!user || !await verifyPassword(data.password, user.passwordHash)) {
return fail(401, { message: 'Invalid credentials' })
}
const session = await lucia.createSession(user.id, {})
const sessionCookie = lucia.createSessionCookie(session.id)
cookie.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes)
throw redirect(302, '/dashboard')
},
zod$(z.object({
email: z.string().email(),
password: z.string().min(8),
}))
)
export default component$(() => {
const login = useLogin()
return (
<Form action={login}>
<input type="email" name="email" placeholder="Email" required />
<input type="password" name="password" placeholder="Password" required />
<button type="submit">Login</button>
{login.value?.failed && <p class="error">{login.value.message}</p>}
</Form>
)
})Zaawansowane wzorce
Context
import { component$, createContextId, useContextProvider, useContext, Slot } from '@builder.io/qwik'
// Definicja context
interface ThemeContext {
theme: 'light' | 'dark'
toggle: () => void
}
export const ThemeContextId = createContextId<ThemeContext>('theme')
// Provider
export const ThemeProvider = component$(() => {
const themeStore = useStore<ThemeContext>({
theme: 'light',
toggle: $(() => {
themeStore.theme = themeStore.theme === 'light' ? 'dark' : 'light'
}),
})
useContextProvider(ThemeContextId, themeStore)
return <Slot />
})
// Consumer
export const ThemeToggle = component$(() => {
const theme = useContext(ThemeContextId)
return (
<button onClick$={theme.toggle}>
Current: {theme.theme}
</button>
)
})Resource i Suspense
import { component$, useResource$, Resource } from '@builder.io/qwik'
export const AsyncDataExample = component$(() => {
const userId = useSignal('1')
// useResource$ - asynchroniczne dane z automatycznym SSR
const userResource = useResource$(async ({ track, cleanup }) => {
const id = track(() => userId.value)
const controller = new AbortController()
cleanup(() => controller.abort())
const response = await fetch(`/api/users/${id}`, {
signal: controller.signal,
})
return response.json()
})
return (
<div>
<select
value={userId.value}
onChange$={(e) => userId.value = (e.target as HTMLSelectElement).value}
>
<option value="1">User 1</option>
<option value="2">User 2</option>
<option value="3">User 3</option>
</select>
{/* Resource automatycznie obsługuje loading/error/success */}
<Resource
value={userResource}
onPending={() => <p>Loading user...</p>}
onRejected={(error) => <p>Error: {error.message}</p>}
onResolved={(user) => (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)}
/>
</div>
)
})Wdrożenie
Vercel
npm run qwik add vercel-edge
# lub
npm run qwik add vercel-serverlessCloudflare Pages
npm run qwik add cloudflare-pagesNode.js Server
npm run qwik add express
# lub
npm run qwik add fastifyStatic Site Generation
npm run qwik add static
npm run build.client
npm run build.server
npm run ssgPorównanie wydajności
| Aplikacja | Qwik | Next.js | SvelteKit |
|---|---|---|---|
| E-commerce (50 produktów) | 3KB JS | 180KB JS | 45KB JS |
| Dashboard (10 widgets) | 2KB JS | 250KB JS | 60KB JS |
| Blog (10 postów) | 1.5KB JS | 120KB JS | 35KB JS |
| TTI (3G mobile) | 0.3s | 4.2s | 1.8s |
| LCP | 0.8s | 1.5s | 1.1s |
| CLS | 0 | 0.05 | 0.02 |
Cennik
- Sam framework jest darmowy - licencja MIT, bez wariantu płatnego i bez zakładania konta gdziekolwiek
- Builder.io, firma, w której Qwik powstał, sprzedaje dziś dwa osobne produkty i żaden z nich nie jest do pracy z frameworkiem potrzebny:
- Fusion (wizualne środowisko pracy): plan darmowy do pięciu osób z sześćdziesięcioma kredytami miesięcznie i limitem piętnastu dziennie, Pro 30 USD za osobę miesięcznie, Team 50 USD, Enterprise po wycenie
- Publish (wizualny CMS, opisywany w starszych materiałach jako Visual CMS): wyłącznie po wycenie u dostawcy, bez planu darmowego i bez abonamentu samoobsługowego
- Uwaga na dwie stawki w Fusion: 30 i 50 USD to ceny przy rozliczeniu miesięcznym, przy rocznym schodzą do 24 i 40 USD za osobę
FAQ - Często zadawane pytania
Czy Qwik jest gotowy do produkcji?
Tak. Qwik jest w wersji stabilnej (v1.0+) i jest używany przez Builder.io oraz inne firmy w produkcji. Ma rosnącą społeczność i aktywny rozwój.
Jak Qwik się skaluje przy dużych aplikacjach?
Qwik skaluje się lepiej niż tradycyjne frameworki, ponieważ initial JS pozostaje stały (~1KB) niezależnie od rozmiaru aplikacji. JavaScript jest ładowany leniwie w miarę interakcji użytkownika.
Czy mogę używać bibliotek React z Qwik?
Qwik ma @builder.io/qwik-react który pozwala używać komponentów React w aplikacjach Qwik, ale tracisz wtedy korzyści resumability dla tych komponentów.
Jaka jest krzywa uczenia się Qwik?
Jeśli znasz React lub inne nowoczesne frameworki, Qwik jest łatwy do nauki. Składnia JSX jest znajoma. Główna różnica to zrozumienie koncepcji $ (lazy loading boundaries) i resumability.
Dlaczego symbol dolara przy funkcjach?
Oznacza granicę leniwego ładowania. Narzędzie budujące wycina taką funkcję do osobnego fragmentu, pobieranego dopiero wtedy, gdy jest potrzebny. Bez tych granic wznawianie nie miałoby jak działać, bo nie dałoby się pobierać kodu kawałkami.
Czym różni się wersja 2 od wersji 1?
Przede wszystkim nazwą pakietu. Dwójka mieszka w przestrzeni @qwik.dev, a nie @builder.io, więc migracja wymaga poprawienia wszystkich importów. Poza tym doszła lżejsza postać wysyłanego HTML i szybsze planowanie zadań.
Czego to nie rozwiązuje
Ta sekcja jest ważniejsza niż lista zalet, bo decyduje o tym, czy warto zapłacić cenę wejścia w niszowy ekosystem.
Wznawianie rozwiązuje koszt startu, a nie koszt działania. Jeśli Twoja aplikacja jest wolna, bo wykonuje ciężkie obliczenia albo renderuje tysiąc wierszy naraz, ten framework tego nie naprawi. Poprawia moment, w którym strona staje się interaktywna, a nie to, co dzieje się później.
Nie pomaga też tam, gdzie użytkownik i tak zaraz kliknie. Panel administracyjny, w którym pierwsza czynność następuje sekundę po wejściu, pobierze ten kod natychmiast, więc oszczędność będzie iluzoryczna. Największą wartość ma tam, gdzie większość odwiedzających czyta i wychodzi: strony treściowe, sklepy, strony sprzedażowe.
Trzecia sprawa to opóźnienie przy pierwszej interakcji. Skoro kod pobiera się dopiero po kliknięciu, to pierwsze kliknięcie czeka na sieć. Mechanizm wstępnego pobierania w tle to łagodzi, ale przy słabym połączeniu różnica bywa odczuwalna i jest odwrotnością tego, czym framework się reklamuje.
Czwarta to ekosystem. Bibliotek napisanych pod ten framework jest niewiele, a integracja z komponentami Reacta działa kosztem utraty wznawiania właśnie dla nich. Dołożenie jednej biblioteki komponentów potrafi zniweczyć główną przewagę, dla której framework wybrano.
Piąta to dostępność osób. Zatrudniając programistę, znajdziesz dziesiątki kandydatów znających popularne frameworki i pojedynczych znających ten. Składnia jest zbliżona do znanej z Reacta, więc nauka podstaw idzie szybko, ale zrozumienie, dlaczego kod trzeba pisać z granicami leniwego ładowania, zajmuje więcej niż tydzień.
Szósta, o której warto wiedzieć przy dłuższej perspektywie, to trwałość samego podejścia. Wznawianie jest pomysłem oryginalnym i technicznie ciekawym, ale nie przyjęło się szeroko: pozostałe frameworki poszły w stronę komponentów serwerowych i ograniczania kodu wysyłanego do przeglądarki innymi środkami. Ten sam problem rozwiązano więc inaczej i z mniejszym kosztem dla piszącego. Nie znaczy to, że tutejsze podejście jest gorsze, ale oznacza, że stawiasz na rozwiązanie, którego przyszłość zależy od jednego, stosunkowo niewielkiego zespołu, a nie od rozpędu całej branży.
Kod źródłowy i wydania znajdziesz w repozytorium projektu, a dokumentację na stronie qwik.dev.