Skip to content

Part 2.3 — Handling Forms, Validation & Submission to REST Endpoints

Site Console Site Console
6 min read Updated Nov 28, 2025 Web Development 0 comments
Form Handling & Validation in React with REST Submission

Forms are where user intent turns into backend data.
They’re also where bugs tend to hide — invalid input, double submissions, missing fields, unclear errors, and edge-case states.
This post shows you how to build robust, validated, and fully typed forms that submit safely to REST endpoints.

We’ll combine:

  • Controlled inputs

  • Validation logic

  • Loading + error states

  • API submissions

  • Clean React patterns

By the end, your forms will feel professional, not fragile.


1. The Core Pattern of Every Good Form

A reliable form handles five concerns:

  1. State — what the user typed

  2. Validation — is the input acceptable?

  3. Submission — send a typed payload to a REST endpoint

  4. UX feedback — loading, success, error

  5. Reset / cleanup — clear form when appropriate

Let’s build this pattern using a CreateUser example.


2. Create a Typed DTO for the Form

This mirrors what your NestJS backend will use.

// src/types/dto/create-user.ts
export interface CreateUserDto {
  name: string;
  email: string;
}

We store DTOs separately because they become shared contracts once the backend is introduced.


3. Basic Controlled Form

We'll build the simplest version first.

// src/components/UserCreateForm.tsx
import { useState } from 'react';
import type { CreateUserDto } from '../types/dto/create-user';

interface Props {
  onSubmit: (dto: CreateUserDto) => Promise<void>;
}

export function UserCreateForm({ onSubmit }: Props) {
  const [form, setForm] = useState<CreateUserDto>({ name: '', email: '' });

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));
  };

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    await onSubmit(form);
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-3 mt-4">
      <input
        name="name"
        value={form.name}
        onChange={handleChange}
        className="border rounded p-2 w-full"
        placeholder="Name"
      />
      <input
        name="email"
        value={form.email}
        onChange={handleChange}
        className="border rounded p-2 w-full"
        placeholder="Email"
      />
      <button className="bg-blue-600 text-white px-4 py-2 rounded">
        Create
      </button>
    </form>
  );
}

This works — but is not safe yet.


4. Add Simple Frontend Validation

Add constraints that mirror NestJS class-validator rules you’ll use later:

const validate = (dto: CreateUserDto) => {
  const errors: string[] = [];

  if (!dto.name.trim()) errors.push('Name is required');
  if (!dto.email.includes('@')) errors.push('Email must be valid');

  return errors;
};

Integrate into the form:

const [errors, setErrors] = useState<string[]>([]);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  const errs = validate(form);
  if (errs.length > 0) {
    setErrors(errs);
    return;
  }
  await onSubmit(form);
  setForm({ name: '', email: '' });
  setErrors([]);
};

Render errors:

{errors.length > 0 && (
  <ul className="text-red-500 text-sm">
    {errors.map(err => <li key={err}>{err}</li>)}
  </ul>
)}

Validation now catches bad input before calling the backend.


5. Add UX States: Loading & Success

Without visual feedback, users double-click buttons or think nothing happened.

const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  const errs = validate(form);
  if (errs.length > 0) {
    setErrors(errs);
    return;
  }

  setLoading(true);
  setSuccess(false);

  try {
    await onSubmit(form);
    setSuccess(true);
    setForm({ name: '', email: '' });
  } catch (err) {
    setErrors([String(err)]);
  } finally {
    setLoading(false);
  }
};

UI feedback:

{loading && <p className="text-gray-500">Saving...</p>}
{success && <p className="text-green-600">User created!</p>}

This is the skeleton for all CRUD submissions.


6. Submit the Form to a REST Endpoint

Connect the form to your API helper:

// src/api/users.ts
import { http } from '../lib/http';
import type { User } from '../types/user';
import type { CreateUserDto } from '../types/dto/create-user';

export function createUser(dto: CreateUserDto) {
  return http<User>('/api/users', {
    method: 'POST',
    body: JSON.stringify(dto),
  });
}

Use it:

<UserCreateForm onSubmit={createUser} />

This fully wires UI → REST → backend.


7. Add Reusable <Input /> Components

Forms get large, fast.
You want reusable building blocks.

// src/components/Input.tsx
interface InputProps {
  label: string;
  name: string;
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

export function Input({ label, name, value, onChange }: InputProps) {
  return (
    <label className="flex flex-col gap-1">
      {label}
      <input
        name={name}
        value={value}
        onChange={onChange}
        className="border rounded p-2"
      />
    </label>
  );
}

Now the form becomes clearer:

<Input label="Name" name="name" value={form.name} onChange={handleChange} />
<Input label="Email" name="email" value={form.email} onChange={handleChange} />

This pattern scales to complex forms later (login, tasks, comments, etc).


8. Handling Backend Validation Errors

Your NestJS API will return meaningful validation messages.
Catch them cleanly:

try {
  await onSubmit(form);
} catch (err) {
  setErrors([err instanceof Error ? err.message : 'Unknown error']);
}

Later, when the backend returns structured validation errors, you’ll map them to the UI.


9. A Complete, Polished Form Example

Putting it all together:

export function UserCreateForm({ onSubmit }: Props) {
  const [form, setForm] = useState<CreateUserDto>({ name: '', email: '' });
  const [errors, setErrors] = useState<string[]>([]);
  const [loading, setLoading] = useState(false);
  const [success, setSuccess] = useState(false);

  const validate = (dto: CreateUserDto) => {
    const errs: string[] = [];
    if (!dto.name.trim()) errs.push('Name is required');
    if (!dto.email.includes('@')) errs.push('Invalid email');
    return errs;
  };

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => setForm(prev => ({ ...prev, [e.target.name]: e.target.value }));

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const errs = validate(form);
    if (errs.length > 0) return setErrors(errs);

    setLoading(true);
    setErrors([]);
    setSuccess(false);

    try {
      await onSubmit(form);
      setSuccess(true);
      setForm({ name: '', email: '' });
    } catch (err) {
      setErrors([String(err)]);
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-3 max-w-md">
      {errors.map(err => (
        <p key={err} className="text-red-500 text-sm">{err}</p>
      ))}

      <Input label="Name" name="name" value={form.name} onChange={handleChange} />
      <Input label="Email" name="email" value={form.email} onChange={handleChange} />

      {loading && <p className="text-gray-500">Saving...</p>}
      {success && <p className="text-green-600">User created!</p>}

      <button disabled={loading} className="bg-blue-600 text-white px-4 py-2 rounded">
        {loading ? 'Saving...' : 'Create'}
      </button>
    </form>
  );
}

This is the exact structure you’ll replicate for every CRUD form in future full-stack parts.


10. What’s Next?

In Part 2.4, we dive into UX states — loading, error, empty, optimistic UI, skeletons, and transitions — to make your app feel polished and responsive during real API interactions.

Related

Leave a comment

Sign in to leave a comment.

Comments