Part 2.3 — Handling Forms, Validation & Submission to REST Endpoints
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:
State — what the user typed
Validation — is the input acceptable?
Submission — send a typed payload to a REST endpoint
UX feedback — loading, success, error
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
Part 2.5 — Consuming REST APIs with Shared Types & Error Handling Strategies
Real-world frontends need safety: typed API responses, predictable errors, and consistent client logic. This post teaches you the patterns professionals use to integrate React with REST cleanly.
Part 2.4 — UX State: Loading, Error, and Empty States in React
A React app becomes truly usable when it handles all states gracefully: loading, error, empty, and success. In this post, you’ll learn the UX patterns professionals rely on.
Part 2.2 — Building CRUD Interfaces: Posts, Users, and API Contracts
CRUD is the backbone of nearly every full-stack app. In this post, you’ll build clean React CRUD interfaces that match real REST API contracts you’ll implement in NestJS and Prisma later.
Comments