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 3.5 — Shaping API Contracts for the Frontend (React Integration)
Your API becomes truly useful only when the frontend can rely on it. In this post, you’ll learn how to shape consistent REST contracts, shared types, and error formats for React applications.
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.
Comments