Arcwayv0.3.0
Cookbook

Forms & Frontend

Client forms with validation, pagination, error handling, and optimistic updates

Client-side patterns for forms, pagination, and error handling.

Form with Validation

Manage form state with plain React state and submit handlers in page/components.

Paginated List

Use useQuery to keep the current page in the URL query string:

// api/users/index.js
export const GET = {
  handler: async (ctx) => {
    const { db } = ctx;
    const page = Math.max(1, parseInt(ctx.req.query.page) || 1);
    const limit = Math.min(Math.max(1, parseInt(ctx.req.query.limit) || 20), 100);
    const search = ctx.req.query.search || '';

    let query = db('users').select('id', 'name', 'email', 'created_at');
    if (search) {
      query = query.where((qb) => {
        qb.where('name', 'like', `%${search}%`).orWhere('email', 'like', `%${search}%`);
      });
    }

    const [users, [{ count }]] = await Promise.all([
      query.clone().orderBy('created_at', 'desc').limit(limit).offset((page - 1) * limit),
      query.clone().count('* as count'),
    ]);

    return {
      data: {
        users,
        pagination: { page, limit, total: Number(count), totalPages: Math.ceil(Number(count) / limit) },
      },
    };
  },
};
// pages/users.jsx
import { useApi, useQuery } from 'arcway/lib/client';

export default function Users() {
  const { query, setQuery } = useQuery();
  const page = parseInt(query.page) || 1;

  const { data, loading } = useApi(`/users?page=${page}&limit=20`);

  if (loading) return <p>Loading...</p>;

  const { users, pagination } = data;

  return (
    <div>
      <table>
        <thead>
          <tr><th>Name</th><th>Email</th></tr>
        </thead>
        <tbody>
          {users.map((u) => (
            <tr key={u.id}><td>{u.name}</td><td>{u.email}</td></tr>
          ))}
        </tbody>
      </table>

      <div>
        <button disabled={page <= 1} onClick={() => setQuery({ page: page - 1 })}>
          Previous
        </button>
        <span>Page {page} of {pagination.totalPages}</span>
        <button disabled={page >= pagination.totalPages} onClick={() => setQuery({ page: page + 1 })}>
          Next
        </button>
      </div>
    </div>
  );
}

Client Error Handling

Handle typed errors from the API using ApiError:

import { useApi, ApiError } from 'arcway/lib/client';

function UserProfile({ id }) {
  const { data, error, loading } = useApi(`/users/${id}`);

  if (loading) return <p>Loading...</p>;

  if (error) {
    if (error instanceof ApiError && error.status === 404) {
      return <p>User not found</p>;
    }
    return <p>Something went wrong: {error.message}</p>;
  }

  return <h1>{data.name}</h1>;
}

Mutation with Optimistic Update

Update the UI immediately while the server request is in flight:

// pages/todos.jsx
import { useState } from 'react';
import { useApi } from 'arcway/lib/client';

export default function Todos() {
  const { data: todos, loading, mutate } = useApi('/todos');
  const [adding, setAdding] = useState(false);

  const addTodo = async (text) => {
    setAdding(true);

    // Optimistic update — add immediately to local state
    mutate((current) => [...(current || []), { id: 'temp', text, done: false }]);

    try {
      const res = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      });
      const json = await res.json();
      // Replace optimistic entry with the real one from server
      mutate((current) => current.map((t) => (t.id === 'temp' ? json.data : t)));
    } catch {
      // Revert on failure
      mutate((current) => current.filter((t) => t.id !== 'temp'));
    } finally {
      setAdding(false);
    }
  };

  if (loading) return <p>Loading...</p>;

  return (
    <div>
      <ul>{todos.map((t) => <li key={t.id}>{t.text}</li>)}</ul>
      <button onClick={() => addTodo('New task')} disabled={adding}>
        Add Todo
      </button>
    </div>
  );
}

On this page