Skip to content

SaaS Tutorial: Forms ​

Creating and editing tasks is the core interaction in TaskFlow. In this chapter, you'll use @ereo/forms to build a rich task form with client-side validation, assignee selection, due dates, and subtask management with useFieldArray. You'll also wire up server actions to persist the data.

Understanding the Two Form Approaches ​

Ereo has two ways to handle forms:

ApproachWhen to Use
<Form> from @ereo/clientSimple forms (login, search, settings). Server action handles everything.
useForm from @ereo/formsComplex forms (validation, arrays, dependent fields, multi-step). Client-side control.

We used @ereo/client's Form for login and registration. Now we'll use @ereo/forms for task management because we need per-field validation, a dynamic subtask list, and responsive error feedback without round-trips.

Task Creation Form ​

Create the task creation route. This is an island — the form needs client-side interactivity for validation and field arrays.

First, build the form island:

tsx
// app/components/TaskForm.tsx
'use client'
import { useForm, useField, useFieldArray, useFormStatus } from '@ereo/forms'
import { required, minLength, maxLength, oneOf } from '@ereo/forms'
import { createIsland } from '@ereo/client'

interface TaskFormProps {
  projectId: string
  members: Array<{ userId: string; name: string }>
  onSuccess?: () => void
}

const STATUSES = ['todo', 'in_progress', 'done'] as const
const PRIORITIES = ['low', 'medium', 'high', 'urgent'] as const

function TaskFormComponent({ projectId, members, onSuccess }: TaskFormProps) {
  const form = useForm({
    defaultValues: {
      title: '',
      description: '',
      status: 'todo' as string,
      priority: 'medium' as string,
      assigneeId: '' as string,
      dueDate: '' as string,
      subtasks: [] as Array<{ title: string }>,
    },
    validators: {
      title: [required('Title is required'), minLength(3, 'Title must be at least 3 characters'), maxLength(100)],
      status: [oneOf([...STATUSES], 'Pick a valid status')],
      priority: [oneOf([...PRIORITIES], 'Pick a valid priority')],
    },
    onSubmit: async (values) => {
      const response = await fetch(`/api/tasks`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ...values, projectId }),
      })
      const result = await response.json()
      if (!result.success) {
        return { success: false, errors: result.errors }
      }
      onSuccess?.()
      return { success: true }
    },
  })

  const title = useField(form, 'title')
  const description = useField(form, 'description')
  const status = useField(form, 'status')
  const priority = useField(form, 'priority')
  const assigneeId = useField(form, 'assigneeId')
  const dueDate = useField(form, 'dueDate')
  const subtasks = useFieldArray(form, 'subtasks')
  const { isSubmitting } = useFormStatus(form)

  return (
    <form onSubmit={form.handleSubmit} className="space-y-5">
      {/* Title */}
      <div>
        <label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
          Title
        </label>
        <input
          id="title"
          type="text"
          {...title.inputProps}
          className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          placeholder="What needs to be done?"
        />
        {title.error && <p className="text-sm text-red-600 mt-1">{title.error}</p>}
      </div>

      {/* Description */}
      <div>
        <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
          Description
        </label>
        <textarea
          id="description"
          {...description.inputProps}
          rows={3}
          className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
          placeholder="Add details..."
        />
      </div>

      {/* Status + Priority row */}
      <div className="grid grid-cols-2 gap-4">
        <div>
          <label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
            Status
          </label>
          <select
            id="status"
            {...status.inputProps}
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          >
            <option value="todo">To Do</option>
            <option value="in_progress">In Progress</option>
            <option value="done">Done</option>
          </select>
        </div>
        <div>
          <label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-1">
            Priority
          </label>
          <select
            id="priority"
            {...priority.inputProps}
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          >
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High</option>
            <option value="urgent">Urgent</option>
          </select>
        </div>
      </div>

      {/* Assignee + Due Date row */}
      <div className="grid grid-cols-2 gap-4">
        <div>
          <label htmlFor="assigneeId" className="block text-sm font-medium text-gray-700 mb-1">
            Assignee
          </label>
          <select
            id="assigneeId"
            {...assigneeId.inputProps}
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          >
            <option value="">Unassigned</option>
            {members.map((m) => (
              <option key={m.userId} value={m.userId}>{m.name}</option>
            ))}
          </select>
        </div>
        <div>
          <label htmlFor="dueDate" className="block text-sm font-medium text-gray-700 mb-1">
            Due date
          </label>
          <input
            id="dueDate"
            type="date"
            {...dueDate.inputProps}
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          />
        </div>
      </div>

      {/* Subtasks (field array) */}
      <div>
        <div className="flex items-center justify-between mb-2">
          <label className="block text-sm font-medium text-gray-700">Subtasks</label>
          <button
            type="button"
            onClick={() => subtasks.append({ title: '' })}
            className="text-sm text-blue-600 hover:text-blue-700"
          >
            + Add subtask
          </button>
        </div>
        {subtasks.fields.length === 0 && (
          <p className="text-sm text-gray-400">No subtasks yet.</p>
        )}
        <div className="space-y-2">
          {subtasks.fields.map((field, index) => (
            <div key={field.id} className="flex gap-2">
              <SubtaskInput form={form} index={index} />
              <button
                type="button"
                onClick={() => subtasks.remove(index)}
                className="px-2 text-gray-400 hover:text-red-500"
                aria-label="Remove subtask"
              >
                &times;
              </button>
            </div>
          ))}
        </div>
      </div>

      {/* Submit */}
      <div className="flex gap-3 pt-2">
        <button
          type="submit"
          disabled={isSubmitting}
          className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
        >
          {isSubmitting ? 'Creating...' : 'Create Task'}
        </button>
      </div>
    </form>
  )
}

function SubtaskInput({ form, index }: { form: any; index: number }) {
  const field = useField(form, `subtasks.${index}.title`)
  return (
    <input
      type="text"
      {...field.inputProps}
      placeholder={`Subtask ${index + 1}`}
      className="flex-1 px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
    />
  )
}

export default createIsland(TaskFormComponent, 'TaskForm')

Task API Route ​

Create the API endpoint that the form submits to:

ts
// app/routes/api/tasks.ts
import { createAction, json } from '@ereo/data'
import { createTask, logActivity } from '~/lib/queries'
import { getUser } from '@ereo/auth'
import { db } from '~/lib/db'
import { teamMembers } from '~/lib/schema'
import { eq } from 'drizzle-orm'

export const action = createAction(async ({ request, context }) => {
  if (request.method !== 'POST') {
    return json({ success: false, errors: { _form: ['Method not allowed'] } }, { status: 405 })
  }

  const user = getUser(context)
  if (!user) return json({ success: false, errors: { _form: ['Unauthorized'] } }, { status: 401 })

  const body = await request.json()
  const { projectId, title, description, status, priority, assigneeId, dueDate, subtasks } = body

  // Server-side validation
  const errors: Record<string, string[]> = {}
  if (!title || title.length < 3) errors.title = ['Title must be at least 3 characters.']
  if (!projectId) errors.projectId = ['Project is required.']

  if (Object.keys(errors).length > 0) {
    return json({ success: false, errors })
  }

  const membership = db.select().from(teamMembers).where(eq(teamMembers.userId, user.id)).get()!

  const taskId = createTask({
    projectId,
    title,
    description: description || undefined,
    status: status || 'todo',
    priority: priority || 'medium',
    assigneeId: assigneeId || undefined,
    dueDate: dueDate ? new Date(dueDate) : undefined,
    createdById: user.id,
  })

  logActivity({
    teamId: membership.teamId,
    userId: user.id,
    action: 'created',
    targetType: 'task',
    targetId: taskId,
    metadata: { title, projectId },
  })

  return json({ success: true, taskId })
})

New Task Route ​

Wire the island into a route:

tsx
// app/routes/dashboard/projects/[id]/tasks/new.tsx
import { createLoader } from '@ereo/data'
import { Link } from '@ereo/client'
import { getProject, getTeamMembers } from '~/lib/queries'
import { getUser } from '@ereo/auth'
import { db } from '~/lib/db'
import { teamMembers } from '~/lib/schema'
import { eq } from 'drizzle-orm'
import type { RouteComponentProps } from '@ereo/core'
import TaskForm from '~/components/TaskForm'

export const loader = createLoader(async ({ params, context }) => {
  const user = getUser(context)!
  const membership = db.select().from(teamMembers).where(eq(teamMembers.userId, user.id)).get()!
  const project = getProject(params.id, membership.teamId)
  if (!project) throw new Response('Not found', { status: 404 })

  const members = getTeamMembers(membership.teamId)
  return { project, members }
})

export default function NewTask({ loaderData }: RouteComponentProps) {
  const { project, members } = loaderData

  return (
    <div className="p-8 max-w-2xl">
      <div className="mb-6">
        <Link href={`/dashboard/projects/${project.id}`} className="text-sm text-gray-500 hover:text-gray-700">
          &larr; Back to {project.name}
        </Link>
        <h1 className="text-2xl font-bold mt-2">New Task</h1>
      </div>

      <TaskForm client:load projectId={project.id} members={members} />
    </div>
  )
}

Task Edit Form ​

Reuse the same form island for editing. The pattern is: load existing data in the route loader, pass it as defaultValues to the form.

tsx
// app/components/TaskEditForm.tsx
'use client'
import { useForm, useField, useFormStatus } from '@ereo/forms'
import { required, minLength, maxLength, oneOf } from '@ereo/forms'
import { createIsland } from '@ereo/client'

interface TaskEditFormProps {
  taskId: string
  projectId: string
  members: Array<{ userId: string; name: string }>
  defaultValues: {
    title: string
    description: string
    status: string
    priority: string
    assigneeId: string
    dueDate: string
  }
}

function TaskEditFormComponent({ taskId, projectId, members, defaultValues }: TaskEditFormProps) {
  const form = useForm({
    defaultValues,
    validators: {
      title: [required('Title is required'), minLength(3), maxLength(100)],
      status: [oneOf(['todo', 'in_progress', 'done'])],
      priority: [oneOf(['low', 'medium', 'high', 'urgent'])],
    },
    onSubmit: async (values) => {
      const response = await fetch(`/api/tasks/${taskId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(values),
      })
      const result = await response.json()
      if (!result.success) return { success: false, errors: result.errors }
      return { success: true }
    },
  })

  const title = useField(form, 'title')
  const description = useField(form, 'description')
  const status = useField(form, 'status')
  const priority = useField(form, 'priority')
  const assigneeId = useField(form, 'assigneeId')
  const dueDate = useField(form, 'dueDate')
  const { isSubmitting } = useFormStatus(form)

  return (
    <form onSubmit={form.handleSubmit} className="space-y-5">
      <div>
        <label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">Title</label>
        <input
          id="title"
          type="text"
          {...title.inputProps}
          className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        />
        {title.error && <p className="text-sm text-red-600 mt-1">{title.error}</p>}
      </div>

      <div>
        <label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">Description</label>
        <textarea
          id="description"
          {...description.inputProps}
          rows={3}
          className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
        />
      </div>

      <div className="grid grid-cols-2 gap-4">
        <div>
          <label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">Status</label>
          <select id="status" {...status.inputProps} className="w-full px-3 py-2 border border-gray-300 rounded-lg">
            <option value="todo">To Do</option>
            <option value="in_progress">In Progress</option>
            <option value="done">Done</option>
          </select>
        </div>
        <div>
          <label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
          <select id="priority" {...priority.inputProps} className="w-full px-3 py-2 border border-gray-300 rounded-lg">
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High</option>
            <option value="urgent">Urgent</option>
          </select>
        </div>
      </div>

      <div className="grid grid-cols-2 gap-4">
        <div>
          <label htmlFor="assigneeId" className="block text-sm font-medium text-gray-700 mb-1">Assignee</label>
          <select id="assigneeId" {...assigneeId.inputProps} className="w-full px-3 py-2 border border-gray-300 rounded-lg">
            <option value="">Unassigned</option>
            {members.map((m) => (
              <option key={m.userId} value={m.userId}>{m.name}</option>
            ))}
          </select>
        </div>
        <div>
          <label htmlFor="dueDate" className="block text-sm font-medium text-gray-700 mb-1">Due date</label>
          <input id="dueDate" type="date" {...dueDate.inputProps} className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
        </div>
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
      >
        {isSubmitting ? 'Saving...' : 'Save Changes'}
      </button>
    </form>
  )
}

export default createIsland(TaskEditFormComponent, 'TaskEditForm')

Understanding useForm vs useField ​

Here's how the form architecture works:

useForm(config)
  │
  ├── Creates FormStore (per-field signals)
  │     ├── title signal   → useField(form, 'title')    → { inputProps, error, touched }
  │     ├── status signal  → useField(form, 'status')   → { inputProps, error, touched }
  │     └── subtasks signal→ useFieldArray(form, 'subtasks') → { fields, append, remove }
  │
  ├── Validation engine
  │     ├── Sync validators run on blur/change
  │     ├── Async validators run after sync passes (with debounce)
  │     └── focusOnError: auto-focuses first invalid field on submit
  │
  └── handleSubmit
        ├── Runs all validators
        ├── If valid → calls onSubmit(values)
        └── If invalid → shows errors, focuses first error field

Key points:

  • Each field has its own signal — changing one field doesn't re-render the entire form
  • useField() returns inputProps that you spread onto native inputs (no wrapper components needed)
  • useFieldArray() provides append(), remove(), move(), and swap() for dynamic lists
  • Validation rules run automatically — required() triggers on blur, async() triggers on change with debounce

Try It Out ​

  1. Navigate to a project and click "Add Task"
  2. Try submitting with an empty title — you should see the validation error without a server round-trip
  3. Fill in the form and add a couple of subtasks using the "+ Add subtask" button
  4. Remove a subtask with the × button
  5. Submit — the task should be created and you'll see it in the project detail page

What We've Done ​

  • Built a rich task form with useForm, useField, and useFieldArray
  • Added client-side validation with built-in validators (required, minLength, maxLength, oneOf)
  • Created form islands that hydrate on the client for interactive validation
  • Built both create and edit form variants that share the same patterns
  • Set up a JSON API endpoint with server-side validation as a backstop

Next Step ​

The forms work, but the task board is static — you have to reload the page to see changes. In the next chapter, we'll build interactive islands with @ereo/state signals for a live-updating dashboard.

← Previous: Database | Continue to Chapter 5: Islands & State →

Released under the MIT License.