Reusable Forms and Validation

Reusable Forms and Validation

Welcome to your first lesson in the course Forms, Validation & Creating Tasks.

In this lesson, you’ll move from displaying data to collecting it through forms. Forms are the bridge between users and your app — they let users create, edit, or share information.

But forms can get messy if you repeat code for every field or skip validation. That’s why we’ll learn two important patterns:

  • Building a reusable input component, so all your text fields look and behave consistently.
  • Using validation, so users can’t submit incomplete or incorrect information.

By the end of this lesson, you’ll have a clean, reusable Input component and a TaskForm that validates data before submitting it.

Quick Recap: Where We Are

So far in your project, you’ve built a functional frontend structure.

You already have:

  • A dashboard layout with a header and sidebar navigation (in src/app/(dashboard)/layout.tsx).
  • A dashboard page that shows task statistics (total, completed, incomplete).
  • A tasks list page that displays existing tasks in a clean, read-only list.
  • Reusable UI pieces like the Button and TaskRow components.

Up until now, the user experience has been read-only — users can see their tasks but not create new ones.
In this lesson, we’ll change that by building a reusable form for creating tasks.

Building a Reusable Input Component

Most forms include several input fields (text, date, email, etc.), and each one needs labels, styling, and error handling. Instead of rewriting that for every field, you can build a single reusable Input component.

Here’s the code:

TSX
import { InputHTMLAttributes, forwardRef } from 'react';

type Props = InputHTMLAttributes<HTMLInputElement> & { label?: string; error?: string };

export const Input = forwardRef<HTMLInputElement, Props>(function Input(
  { label, id, error, className = '', ...props },
  ref
) {
  const base = 'w-full rounded-md border bg-white px-3 py-2 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2';
  const normal = 'border-gray-300 focus:border-blue-500 focus:ring-blue-500/30';
  const danger = 'border-red-300 focus:border-red-500 focus:ring-red-500/30';

  return (
    <div className="block text-sm">
      {label && (
        <label htmlFor={id} className="mb-1 block text-gray-700">
          {label}
        </label>
      )}
      <input
        ref={ref}
        id={id}
        className={`${base} ${error ? danger : normal} ${className}`}
        {...props}
      />
      {error && <p className="mt-1 text-sm text-red-600">{error}</p>}
    </div>
  );
});

Understanding the Input Component Step by Step

Props

  • The component accepts all normal HTML input attributes (like type, placeholder, onChange, etc.) thanks to InputHTMLAttributes<HTMLInputElement>.
  • It also adds two optional custom props:
    • label — the text displayed above the input.
    • error — the validation message displayed below the input.

This makes the component flexible and reusable for any kind of text input.

forwardRef

We wrap the component in forwardRef so we can pass a ref to the <input> element.
This is essential for libraries like React Hook Form, which use refs to control the field’s value and track its state.
Without forwardRef, our input wouldn’t connect properly to form management tools.

Base Styles

The base variable defines styles that every input should share — full width, padding, rounded corners, and focus outlines.

TypeScript
const base = 'w-full rounded-md border bg-white px-3 py-2 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2';

Normal and Danger States

The normal and danger constants define how the input should look depending on whether there’s a validation error.

  • The normal style uses a gray border and blue focus ring.
  • The danger style switches to red when an error exists.

The component picks which one to use dynamically:

TypeScript
className={`${base} ${error ? danger : normal} ${className}`}

If error is truthy, the danger style applies.

Label and Error Messages

  • If a label prop is provided, the component renders a <label> element with htmlFor={id} to connect it to the input for accessibility.
  • If an error message exists, it shows below the field in red text.

Example Usage: <Input label="Title" id="title" placeholder="Task title" error="Title is required" />

This creates:

  • A labeled input with “Title” above it.
  • A red border and message “Title is required” below it.

Building the TaskForm Component

Now that you have a reusable input, let’s build the form that will use it.

Here’s the code:

TSX
'use client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';

const schema = z.object({
  title: z.string().trim().min(1, 'Title is required'),
  content: z.string().trim().min(1, 'Content is required'),
  dueDate: z.string().optional(),
});

export type TaskFormValues = z.infer<typeof schema>;

This defines the validation schema with Zod:

  • title and content are required fields.
  • dueDate is optional.

By using zodResolver, React Hook Form will automatically check these rules every time the user submits.

Setting Up React Hook Form

TSX
const {
  register,
  handleSubmit,
  formState: { errors, isSubmitting },
} = useForm<TaskFormValues>({
  resolver: zodResolver(schema),
  defaultValues: { title: '', content: '', dueDate: '', ...initialValues },
});

Here’s what each part does:

  • useForm() initializes form state.
  • register() connects input fields to the form’s tracking system.
  • handleSubmit() handles validation and calls your submit function only if validation passes.
  • errors stores any validation messages generated by Zod.
  • isSubmitting tells us if the form is currently being submitted — useful for disabling buttons.
  • defaultValues sets initial field values, so the form can be reused for both creating and editing tasks. In this unit, you only use the form for creating tasks; initialValues is included now to prepare for a later edit flow.

Using the Input Component and Validation Together

TSX
<Input label="Title" id="title" placeholder="Task title" {...register('title')} />
{errors.title && <p className="mt-1 text-sm text-red-600">{errors.title.message}</p>}

Here’s what’s happening:

  • register('title') links this field to React Hook Form and the Zod schema.
  • If the user leaves it empty, Zod will generate an error (“Title is required”).
  • That message is stored in errors.title.message and displayed below the field.

The same logic applies for content and dueDate.

The form also includes a <textarea> for task content and uses the Input component again for the due date (this time with type="date").

Handling Submission

TSX
<form onSubmit={handleSubmit(async (v) => { await onSubmit(v); })}>
  <Button type="submit" disabled={isSubmitting}>{submitLabel}</Button>
</form>
  • handleSubmit runs validation automatically.
  • If validation passes, it calls your onSubmit function with the form data.
  • While submitting, isSubmitting disables the button to prevent multiple submissions.
  • submitLabel lets you customize the button text (e.g., “Save”, “Create”).

Why Use Zod and React Hook Form Together

  • Zod defines your rules.
  • React Hook Form enforces them, handles state, and manages inputs efficiently.

Zod ensures your form’s data is always valid.
React Hook Form ensures your UI stays performant — it doesn’t re-render the entire form on every keystroke.

The combination keeps your code type-safe, compact, and easy to maintain.

The “New Task” Page

Now that the TaskForm is ready, it’s used in a new page under src/app/(dashboard)/tasks/new/page.tsx:

TSX
"use client";
import { TaskForm, TaskFormValues } from '@/components/tasks/TaskForm';

export default function NewTaskPage() {

  const onSubmit = async (values: TaskFormValues) => {
    alert("No action for now. Will be implemented in later units");
  };

  return (
    <div className="space-y-6">
      <h1 className="text-2xl font-semibold">Create Task</h1>
      <div className="rounded-lg border bg-white p-4">
        <TaskForm onSubmit={onSubmit} submitLabel="Create" />
      </div>
    </div>
  );
}

For now, when you submit, it simply shows an alert.
In future lessons, this will connect to your backend API to actually create a new task.

Summary

In this lesson, you learned how to:

  • Create a reusable Input component that handles labels, styling, and validation states.
  • Build a TaskForm that collects task information and validates it using React Hook Form and Zod.
  • Understand how register, errors, and handleSubmit work together to manage form logic.
  • Set up the structure for your New Task page, ready for backend integration in upcoming units.

By combining reusability with strong validation, you’re laying the foundation for robust, user-friendly forms throughout your Next.js app.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal