Qwik City provides a powerful action system that makes handling forms and mutations incredibly efficient while maintaining resumability. This comprehensive guide covers everything you need to know about actions and form handling in Qwik City.
Understanding Qwik City Actions
Actions in Qwik City are server-side functions that handle mutations and form submissions. They're automatically serialized and can be called from the client without manual API endpoint creation.
Action Basics
// src/routes/index.tsx
import { action$, zod$, z } from '@builder.io/qwik-city';
import { component$ } from '@builder.io/qwik';
export const useAddUser = action$(
async (data, event) => {
// Handle form submission
const user = await db.user.create({
data: {
name: data.name,
email: data.email,
},
});
return { success: true, userId: user.id };
},
zod$({
name: z.string().min(2),
email: z.string().email(),
})
);
export default component$(() => {
const action = useAddUser();
return (
<form action={action}>
<input type="text" name="name" required />
<input type="email" name="email" required />
<button type="submit">Add User</button>
</form>
);
});Form Validation
Zod integration provides powerful validation capabilities.
Schema Validation
import { zod$, z } from '@builder.io/qwik-city';
const userSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters'),
email: z.string()
.email('Invalid email address')
.refine(async (email) => {
// Custom async validation
const exists = await db.user.findUnique({ where: { email } });
return !exists;
}, 'Email already exists'),
age: z.number()
.min(18, 'Must be 18 or older')
.max(120, 'Invalid age'),
});
export const useRegister = action$(
async (data) => {
// Data is validated and typed
const user = await db.user.create({ data });
return { userId: user.id };
},
zod$(userSchema)
);Client-Side Validation
export default component$(() => {
const action = useRegister();
return (
<form action={action}>
<input type="text" name="name" required minlength="2" />
{action.value?.fieldErrors?.name && (
<span class="error">{action.value.fieldErrors.name}</span>
)}
<input type="email" name="email" required />
{action.value?.fieldErrors?.email && (
<span class="error">{action.value.fieldErrors.email}</span>
)}
<button type="submit">Register</button>
</form>
);
});Form State Management
Qwik City actions provide excellent state management for forms.
Loading States
export default component$(() => {
const action = useAddUser();
return (
<form action={action}>
<input type="text" name="name" disabled={action.isRunning} />
<input type="email" name="email" disabled={action.isRunning} />
<button type="submit" disabled={action.isRunning}>
{action.isRunning ? 'Adding...' : 'Add User'}
</button>
{action.isRunning && <div class="spinner">Processing...</div>}
</form>
);
});Success and Error Handling
export default component$(() => {
const action = useAddUser();
return (
<form action={action}>
<input type="text" name="name" />
<input type="email" name="email" />
<button type="submit">Add User</button>
{action.value?.success && (
<div class="success">
User added successfully! ID: {action.value.userId}
</div>
)}
{action.value?.failed && (
<div class="error">
{action.value.error?.message || 'Failed to add user'}
</div>
)}
</form>
);
});File Uploads
Handle file uploads with Qwik City actions.
Single File Upload
export const useUploadAvatar = action$(async (data, event) => {
const file = data.file as File;
const buffer = await file.arrayBuffer();
// Upload to cloud storage
const url = await uploadToS3(buffer, file.name);
return { url };
});
export default component$(() => {
const action = useUploadAvatar();
return (
<form action={action}>
<input type="file" name="file" accept="image/*" />
<button type="upload">Upload</button>
</form>
);
});Multiple File Uploads
export const useUploadFiles = action$(async (data) => {
const files = data.files as File[];
const urls = await Promise.all(
files.map(async (file) => {
const buffer = await file.arrayBuffer();
return uploadToS3(buffer, file.name);
})
);
return { urls, count: urls.length };
});Progressive Enhancement
Qwik City forms work without JavaScript, providing progressive enhancement.
Server-First Forms
export const useSearch = action$(
async (data) => {
const results = await searchProducts(data.query);
return { results };
},
zod$({
query: z.string().min(1),
})
);
export default component$(() => {
const action = useSearch();
return (
<form action={action}>
<input type="search" name="query" placeholder="Search products..." />
<button type="submit">Search</button>
{action.value?.results && (
<ul>
{action.value.results.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)}
</form>
);
});Form Actions and Redirects
Redirect after successful form submission.
Post-Submit Redirect
import { redirect } from '@builder.io/qwik-city';
export const useCreatePost = action$(async (data) => {
const post = await db.post.create({
data: {
title: data.title,
content: data.content,
},
});
throw redirect(302, `/posts/${post.id}`);
});Conditional Redirects
export const useLogin = action$(async (data) => {
const user = await authenticate(data.email, data.password);
if (user.requiresVerification) {
throw redirect(302, '/verify-email');
}
throw redirect(302, '/dashboard');
});Complex Form Workflows
Multi-Step Forms
// Step 1
export const useStep1 = action$(async (data) => {
const session = await saveStep1(data);
return { step1Complete: true, sessionId: session.id };
});
// Step 2
export const useStep2 = action$(async (data) => {
await saveStep2(data);
return { step2Complete: true };
});
// Step 3
export const useStep3 = action$(async (data) => {
await completeRegistration(data);
return { registrationComplete: true };
});Form Drafts
export const useSaveDraft = action$(async (data) => {
const draft = await db.draft.upsert({
where: { userId: data.userId },
create: { userId: data.userId, content: data.content },
update: { content: data.content },
});
return { draftId: draft.id, savedAt: new Date() };
});
export const usePublish = action$(async (data) => {
const draft = await db.draft.findUnique({
where: { id: data.draftId },
});
const post = await db.post.create({
data: {
title: draft.title,
content: draft.content,
publishedAt: new Date(),
},
});
await db.draft.delete({ where: { id: draft.id } });
throw redirect(302, `/posts/${post.id}`);
});Real-Time Form Validation
Implement real-time validation as users type.
Debounced Validation
import { useSignal } from '@builder.io/qwik';
export default component$(() => {
const action = useRegister();
const email = useSignal('');
const isValid$ = $(async (email: string) => {
// Check email availability
const response = await fetch(`/api/check-email?email=${email}`);
return response.ok;
});
return (
<form action={action}>
<input
type="email"
name="email"
onInput$={(e) => email.value = (e.target as HTMLInputElement).value}
/>
<span>{email.value}</span>
</form>
);
});Form Security
CSRF Protection
Qwik City automatically handles CSRF protection for forms.
Rate Limiting
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
});
export const useContact = action$(async (data, event) => {
const ip = event.request.headers.get('x-forwarded-for') ?? 'anonymous';
const { success } = await ratelimit.limit(ip);
if (!success) {
throw new Error('Too many requests. Please try again later.');
}
await sendContactForm(data);
return { sent: true };
});Best Practices
1. Validate Everything
export const useAction = action$(
async (data) => {
// Always validate, even with zod
if (!data.name.trim()) {
throw new Error('Name is required');
}
// Process data
},
zod$({
name: z.string(),
})
);2. Use Type Safety
interface UserData {
name: string;
email: string;
}
export const useUpdateUser = action$(
async (data: UserData) => {
// Type-safe access
console.log(data.name.toUpperCase());
}
);3. Handle Errors Gracefully
export const useAction = action$(async (data) => {
try {
const result = await riskyOperation(data);
return { success: true, result };
} catch (error) {
console.error('Action failed:', error);
return {
failed: true,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
});4. Provide Feedback
export default component$(() => {
const action = useAction();
return (
<form action={action}>
<button type="submit">
{action.isRunning ? 'Processing...' : 'Submit'}
</button>
{action.value?.success && (
<div class="success">Operation successful!</div>
)}
{action.value?.failed && (
<div class="error">Error: {action.value.error}</div>
)}
</form>
);
});Conclusion
Qwik City actions provide a powerful, type-safe way to handle forms and mutations while maintaining resumability and performance. The combination of server-side processing, automatic progressive enhancement, and excellent developer experience makes form handling in Qwik City both secure and user-friendly.
Key takeaways:
- 1Actions are server-side functions that handle mutations
- 2Zod provides powerful validation
- 3Forms work without JavaScript
- 4Automatic state management for loading, success, and errors
- 5Built-in security features
- 6Type-safe data handling
- 7Excellent progressive enhancement
Master these concepts to build robust, interactive forms that work flawlessly in Qwik City applications.