Skip to content
All essays
WebMarch 27, 202612 min

Qwik City Actions and Form Handling: Building Interactive Applications

Master Qwik City actions, form handling, validation, and server-side mutations for building interactive web applications.

Ü
Ümit Uz
Mobile & Full Stack Developer

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

typescript
// 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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
// 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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

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

  1. 1Actions are server-side functions that handle mutations
  2. 2Zod provides powerful validation
  3. 3Forms work without JavaScript
  4. 4Automatic state management for loading, success, and errors
  5. 5Built-in security features
  6. 6Type-safe data handling
  7. 7Excellent progressive enhancement

Master these concepts to build robust, interactive forms that work flawlessly in Qwik City applications.

Enjoyed this essay? Share it.

Related essays

Next essay
Qwik Fundamentals: Building Resumable Web Applications