Skip to main content

🧱 Part 6 — Forms Like a Pro (React Hook Form + Zod) ✍️✅

Forms are where users talk to your app. In this part we’ll build elegant, accessible, type-safe forms using React Hook Form (RHF) and Zod for schema validation.

🎯 Goals

  • Typed schemas with Zod → auto‑inferred TS types (always in sync)
  • RHF + zodResolver for instant, user‑friendly validation
  • Reusable Input that shows errors + accessibility labels
  • Field Arrays (subtasks), default values, reset, watch
  • Async validation & Axios submission with error handling

We’ll implement an Add/Edit Task form for TaskTimer.


0) Install (if you haven’t already)

pnpm add react-hook-form zod @hookform/resolvers axios

1) Define a Zod schema (single source of truth)

// src/features/forms/schemas.ts
import { z } from 'zod';

// Zod schema describes shape + validation
export const taskSchema = z.object({
  title: z
    .string()
    .min(3, 'Title must be at least 3 characters')
    .max(50, 'Title must be 50 characters or less'),
  notes: z
    .string()
    .max(200, 'Notes must be 200 characters or less')
    .optional()
    // treat empty strings as undefined so optional really feels optional
    .or(z.literal('').transform(() => undefined)),
  dueDate: z
    .string()
    .optional()
    .refine((v) => !v || !Number.isNaN(Date.parse(v)), { message: 'Invalid date' }),
  priority: z.enum(['low', 'medium', 'high']).default('medium'),
  subtasks: z
    .array(
      z.object({
        id: z.string(),
        text: z.string().min(1, 'Subtask cannot be empty'),
        done: z.boolean().default(false),
      }),
    )
    .max(10, 'Too many subtasks')
    .default([]),
});

// Infer the TypeScript type from schema (always in sync)
export type TaskForm = z.infer<typeof taskSchema>;

Why this matters: You write rules once in Zod and get both runtime validation and compile‑time types.


2) RHF setup with zodResolver

// src/features/forms/TaskForm.tsx
import { useForm, FormProvider, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import type { TaskForm } from './schemas';
import { taskSchema } from './schemas';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';

export function TaskFormView({
  onSubmit,
  initial,
}: {
  onSubmit: (data: TaskForm) => Promise<void> | void;
  initial?: Partial<TaskForm>; // allow partial for edit forms
}) {
  // Create the form with resolver + defaults
  const methods = useForm<TaskForm>({
    resolver: zodResolver(taskSchema),
    mode: 'onBlur', // validate when field loses focus
    defaultValues: {
      title: '',
      notes: '',
      dueDate: '',
      priority: 'medium',
      subtasks: [],
      ...initial,
    },
  });

  // Field array for dynamic subtasks
  const { fields, append, remove } = useFieldArray({
    name: 'subtasks',
    control: methods.control,
  });

  // Convenience
  const {
    register, // connects inputs to RHF
    handleSubmit, // wraps your submit handler with validation
    formState: { errors, isSubmitting }, // errors map + submitting flag
    reset,
    watch,
  } = methods;

  const priority = watch('priority'); // example of live watch

  return (
    // FormProvider lets nested components access the form via useFormContext
    <FormProvider {...methods}>
      <form
        onSubmit={handleSubmit(async (data) => {
          await onSubmit(data); // parent handles persistence
          reset(); // clear after success (optional)
        })}
        className="flex flex-col gap-3 max-w-lg"
        noValidate // rely on Zod/RHF messages, not default browser tooltips
      >
        {/* Title */}
        <Input
          label="Title"
          placeholder="e.g., Write Part 6 blog"
          // register wires the input to RHF under the key "title"
          {...register('title')}
          error={errors.title?.message}
        />

        {/* Notes (optional) */}
        <Input
          label="Notes"
          placeholder="Optional notes…"
          {...register('notes')}
          error={errors.notes?.message}
        />

        {/* Due date (string ISO date for simplicity) */}
        <Input
          label="Due Date"
          type="date"
          {...register('dueDate')}
          error={errors.dueDate?.message}
        />

        {/* Priority (radio group example) */}
        <fieldset className="border rounded p-3">
          <legend className="font-medium">Priority</legend>
          <label className="mr-4">
            <input type="radio" value="low" {...register('priority')} /> Low
          </label>
          <label className="mr-4">
            <input type="radio" value="medium" {...register('priority')} /> Medium
          </label>
          <label>
            <input type="radio" value="high" {...register('priority')} /> High
          </label>
          <p className="text-sm text-gray-500 mt-1">Selected: {priority}</p>
          {errors.priority && (
            <span className="text-red-500 text-sm">{errors.priority.message}</span>
          )}
        </fieldset>

        {/* Subtasks (dynamic list) */}
        <div className="border rounded p-3">
          <div className="flex items-center justify-between mb-2">
            <span className="font-medium">Subtasks</span>
            <Button type="button" variant="secondary" onClick={() =>
              append({ id: crypto.randomUUID(), text: '', done: false })
            }>
              + Add Subtask
            </Button>
          </div>

          {fields.length === 0 && (
            <p className="text-sm text-gray-500">No subtasks yet.</p>
          )}

          <ul className="flex flex-col gap-2">
            {fields.map((field, index) => (
              <li key={field.id} className="flex items-center gap-2">
                {/* Keep key as field.id from RHF for stability */}
                <input
                  className="border rounded px-2 py-1 flex-1"
                  placeholder={`Subtask #${index + 1}`}
                  // Register nested field via index path
                  {...register(`subtasks.${index}.text`)}
                />
                <label className="flex items-center gap-1 text-sm">
                  <input type="checkbox" {...register(`subtasks.${index}.done`)} /> done
                </label>
                <Button type="button" variant="danger" onClick={() => remove(index)}>
                  Remove
                </Button>
                {/* Show per‑item error if any */}
                {errors.subtasks?.[index]?.text?.message && (
                  <span className="text-red-500 text-sm">
                    {errors.subtasks?.[index]?.text?.message}
                  </span>
                )}
              </li>) )}
          </ul>

          {/* Array‑level errors (e.g., max length) */}
          {typeof errors.subtasks?.message === 'string' && (
            <span className="text-red-500 text-sm">{errors.subtasks?.message}</span>
          )}
        </div>

        <div className="flex gap-2 justify-end mt-2">
          <Button type="button" variant="secondary" onClick={() => reset()}>
            Reset
          </Button>
          <Button type="submit" isLoading={isSubmitting}>Save Task</Button>
        </div>
      </form>
    </FormProvider>
  );
}

Notes

  • register('field') connects inputs; RHF manages value/blur/change.
  • errors.field?.message shows the Zod message.
  • useFieldArray handles dynamic lists with stable keys.

3) Submitting with Axios (+ API layer)

// src/lib/api/http.ts (from earlier parts)
import axios from 'axios';

export const http = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 10_000,
});

http.interceptors.response.use(
  (r) => r,
  (err) => {
    const status = err.response?.status ?? 0;
    const message = err.response?.data?.message ?? err.message;
    return Promise.reject({ status, message } as { status: number; message: string });
  },
);
// src/lib/api/tasks.ts
import { http } from './http';
import type { TaskForm } from '@/features/forms/schemas';

export async function createTask(data: TaskForm) {
  // Map to server DTO if needed
  const res = await http.post('/tasks', data);
  return res.data as { id: string };
}
// Example usage: wrap TaskFormView and send to API
import { TaskFormView } from '@/features/forms/TaskForm';
import { createTask } from '@/lib/api/tasks';

export default function NewTaskPage() {
  return (
    <section>
      <h2 className="text-xl font-semibold mb-3">New Task</h2>
      <TaskFormView
        onSubmit={async (data) => {
          try {
            await createTask(data);
            alert('✅ Task saved'); // replace with a toast lib in real app
          } catch (e: any) {
            alert(`❌ Failed: ${e.message ?? 'Unknown error'}`);
          }
        }}
      />
    </section>
  );
}

4) Controlled vs Uncontrolled (when to use Controller)

Most HTML inputs work with register. For custom components (like date pickers), use Controller.

import { Controller, useFormContext } from 'react-hook-form';

// Example custom select component integration
function PrioritySelect() {
  const { control } = useFormContext();
  return (
    <Controller
      name="priority"
      control={control}
      render={({ field, fieldState }) => (
        <div>
          <select {...field} className="border rounded px-2 py-1">
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High</option>
          </select>
          {fieldState.error && (
            <span className="text-red-500 text-sm">{fieldState.error.message}</span>
          )}
        </div>
      )}
    />
  );
}

5) Async field validation (e.g., unique title)

// Debounced async validation example
import { useFormContext } from 'react-hook-form';
import { useEffect } from 'react';

async function isTitleTaken(title: string) {
  // pretend API call
  await new Promise((r) => setTimeout(r, 300));
  return title.toLowerCase() === 'react';
}

export function TitleAvailabilityHint() {
  const { watch, setError, clearErrors } = useFormContext<TaskForm>();
  const title = watch('title');

  useEffect(() => {
    let active = true;
    const run = async () => {
      if (title.length < 3) return; // skip until basic rule passes
      const taken = await isTitleTaken(title);
      if (!active) return;
      if (taken) setError('title', { type: 'validate', message: 'Title already taken' });
      else clearErrors('title');
    };
    run();
    return () => { active = false; };
  }, [title, setError, clearErrors]);

  return null; // this component only manages validation side-effects
}

Use it inside the form near Title (optional enhancement).


6) Accessibility & UX checklist

  • Always use a label for inputs (our Input adds it).
  • Connect error text to inputs via aria-describedby (our Input can be extended to set IDs).
  • Prefer onBlur validation to reduce noise; show errors near fields.
  • Disable submit while isSubmitting; show progress (we used isLoading).

7) Common pitfalls & fixes

  • Forgetting defaultValues → fields become uncontrolled. Always set initial values.
  • Mixing controlled & uncontrolled → don’t pass both value and defaultValue unless you intend to control.
  • Not using schema inference → import TaskForm from Zod inference, don’t duplicate types.
  • Dynamic lists without stable keys → use field.id from useFieldArray.

✅ Wrap‑Up

You now have a fully typed form pipeline:

  • Zod schema → single source of truth for validation + types
  • React Hook Form → performant, ergonomic form state
  • Field arrays, async validation, and API submission with Axios

👉 Next: We’ll manage server state with TanStack Query (typed queries/mutations, caching, optimistic updates) and connect it to our Task API for a smooth, real‑world experience. 🚀


Comments

Popular posts from this blog

🌟 Dot net Microservices interview questions

Here is a comprehensive list of 200 .NET microservices coding questions covering all core microservices concepts and cross-cutting concerns relevant for designing, building, deploying, and maintaining .NET-based distributed systems. 🧩 A. Microservices Fundamentals (20) Build a microservice in .NET 8 that exposes a simple CRUD API. Implement communication between two microservices using REST. How would you design microservices for an e-commerce application? Create a microservice that handles user registration and login. How do you isolate domain logic in a microservice? How to apply the "Single Responsibility Principle" in microservices? Design a service registry/discovery mechanism using custom middleware. Implement a service that handles file uploads and metadata separately. Build a stateless microservice and explain its benefits. Implement health check endpoints in .NET 8. Demonstrate versioning in a microservice API. Add Swagger/OpenAPI support to your m...

⚡ Part 1: Introduction to Generics in C#

🌍 Why Do We Need Generics? Imagine you want to create a stack (like a pile of books 📚): You can push items on top You can pop items off the top If we write a stack for integers : public class IntStack { private int[] items = new int[10]; private int index = 0; public void Push(int item) => items[index++] = item; public int Pop() => items[--index]; } 👉 Problem: This only works for int . What if we want a string stack ? Or a Customer stack ? We’d have to duplicate code for every type. 😢 ✅ Solution: Generics Generics let us create type-safe reusable code without duplication. We can say: “I don’t care what type it is yet — I’ll decide later.” 1) Generic Classes Here’s a generic stack : // Generic class "Stack<T>" // The <T> is a placeholder for any type public class Stack<T> { private T[] items = new T[10]; // Array of type T private int index = 0; // Push adds an item of type T public void P...

🚪 Part 9: API Gateway for .NET 8 Microservices (Ocelot & YARP)

Once you have multiple microservices (Products, Orders, Payments…), exposing each one directly to clients gets messy: Different base URLs Duplicated auth logic No unified rate limiting / caching Hard to evolve routes or aggregate data 👉 Enter the API Gateway — your single front door for all microservices. An API Gateway handles: ✅ Routing & path rewriting ✅ Load balancing, retries, circuit breakers ✅ Authentication & Authorization (JWT, OAuth2) ✅ Rate limiting & caching ✅ Aggregation (compose results from multiple services) In this post we’ll implement two strong options: Ocelot → config-driven, mature, DevOps-friendly YARP (Yet Another Reverse Proxy) → Microsoft’s code-first, extensible gateway ⚖️ Ocelot vs YARP — When to Choose Ocelot → JSON config, minimal C#, built-in QoS (rate limit, circuit breaker). Perfect for teams that like DevOps config-as-code. YARP → full C# control, middleware-friendly, can embed into broader apps (e.g. add dashb...