Part 2.2 — Building CRUD Interfaces: Posts, Users, and API Contracts
CRUD is where your frontend stops being a static interface and starts behaving like a real product. Whether you're managing posts, users, tasks, or comments, the pattern is always the same:
Create → Read → Update → Delete.
In this post, we'll build reusable CRUD UI patterns in React + TypeScript and align them with REST contracts your NestJS backend will expose in Part 3.
1. The Core Concept: CRUD Is About Flows
A CRUD UI isn’t just a list with buttons — it’s a set of flows:
Fetch all items (READ)
Create new items (CREATE)
Edit existing items (UPDATE)
Delete items (DELETE)
The UI must reflect loading, error, empty, and success states.
We’ll build each step cleanly and type-safely.
For our examples, we’ll use a simple Post model.
2. Define Shared Types
// src/types/post.ts
export interface Post {
id: number;
title: string;
body: string;
}Later, this type will match your Prisma model and NestJS DTOs exactly.
3. REST API Contracts (Frontend Perspective)
Before writing UI, define your API contract — what endpoints exist and how they behave.
GET /api/posts → Post[]
POST /api/posts → creates and returns a Post
PATCH /api/posts/:id → updates fields
DELETE /api/posts/:id → deletes a post
This mirrors what you’ll implement in NestJS.
4. Build API Helpers
// src/api/posts.ts
import { http } from '../lib/http';
import type { Post } from '../types/post';
export function getPosts() {
return http<Post[]>('/api/posts');
}
export function createPost(data: { title: string; body: string }) {
return http<Post>('/api/posts', {
method: 'POST',
body: JSON.stringify(data),
});
}
export function updatePost(id: number, data: Partial<Post>) {
return http<Post>(`/api/posts/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
export function deletePost(id: number) {
return http<void>(`/api/posts/${id}`, { method: 'DELETE' });
}Notice how TypeScript enforces the contract.
If the backend changes shape later, TypeScript will alert you.
5. Build a Custom Hook for CRUD State
// src/hooks/usePosts.ts
import { useEffect, useState } from 'react';
import type { Post } from '../types/post';
import { getPosts, createPost, updatePost, deletePost } from '../api/posts';
export function usePosts() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getPosts().then(setPosts).finally(() => setLoading(false));
}, []);
const addPost = async (title: string, body: string) => {
const newPost = await createPost({ title, body });
setPosts(prev => [...prev, newPost]);
};
const editPost = async (id: number, data: Partial<Post>) => {
const updated = await updatePost(id, data);
setPosts(prev => prev.map(p => (p.id === id ? updated : p)));
};
const removePost = async (id: number) => {
await deletePost(id);
setPosts(prev => prev.filter(p => p.id !== id));
};
return { posts, loading, addPost, editPost, removePost };
}This isolates all CRUD logic from the UI.
6. Display a CRUD List (READ)
// src/components/PostList.tsx
import type { Post } from '../types/post';
interface PostListProps {
posts: Post[];
onEdit: (id: number) => void;
onDelete: (id: number) => void;
}
export function PostList({ posts, onEdit, onDelete }: PostListProps) {
if (posts.length === 0) {
return <p className="text-gray-500 mt-4">No posts yet.</p>;
}
return (
<ul className="space-y-2 mt-4">
{posts.map(post => (
<li key={post.id} className="p-3 border rounded flex justify-between">
<div>
<h2 className="font-bold">{post.title}</h2>
<p>{post.body}</p>
</div>
<div className="space-x-2">
<button onClick={() => onEdit(post.id)} className="text-blue-500">Edit</button>
<button onClick={() => onDelete(post.id)} className="text-red-500">Delete</button>
</div>
</li>
))}
</ul>
);
}This component is reusable for any future post-like data.
7. Create New Posts (CREATE)
// src/components/CreatePostForm.tsx
import { useState } from 'react';
interface Props {
onCreate: (title: string, body: string) => Promise<void>;
}
export function CreatePostForm({ onCreate }: Props) {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const submit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!title.trim() || !body.trim()) return;
await onCreate(title.trim(), body.trim());
setTitle('');
setBody('');
};
return (
<form onSubmit={submit} className="space-y-2 mt-4">
<input
className="border rounded p-2 w-full"
placeholder="Post title"
value={title}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
/>
<textarea
className="border rounded p-2 w-full"
placeholder="Post body"
value={body}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setBody(e.target.value)}
/>
<button className="bg-green-600 text-white px-4 py-2 rounded">Create</button>
</form>
);
}This matches the POST contract precisely.
8. Edit Posts (UPDATE)
Editable fields are just controlled forms with default values.
// src/components/EditPostForm.tsx
import { useState } from 'react';
import type { Post } from '../types/post';
interface Props {
post: Post;
onSave: (id: number, data: Partial<Post>) => Promise<void>;
onCancel: () => void;
}
export function EditPostForm({ post, onSave, onCancel }: Props) {
const [title, setTitle] = useState(post.title);
const [body, setBody] = useState(post.body);
const submit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
await onSave(post.id, { title, body });
};
return (
<form onSubmit={submit} className="space-y-2">
<input
value={title}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTitle(e.target.value)}
className="border rounded p-2 w-full"
/>
<textarea
value={body}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setBody(e.target.value)}
className="border rounded p-2 w-full"
/>
<div className="space-x-2">
<button className="bg-blue-600 text-white px-4 py-2 rounded" type="submit">
Save
</button>
<button onClick={onCancel} className="text-gray-500" type="button">
Cancel
</button>
</div>
</form>
);
}Later, you can replace this with modal-based editing.
9. Combine Everything Into the Page
// src/pages/PostsPage.tsx
import { usePosts } from '../hooks/usePosts';
import { PostList } from '../components/PostList';
import { CreatePostForm } from '../components/CreatePostForm';
import { EditPostForm } from '../components/EditPostForm';
import { useState } from 'react';
export function PostsPage() {
const { posts, loading, addPost, editPost, removePost } = usePosts();
const [editingId, setEditingId] = useState<number | null>(null);
if (loading) return <p>Loading...</p>;
const editingPost = posts.find(p => p.id === editingId) || null;
return (
<section className="max-w-xl mx-auto mt-10">
<h1 className="text-2xl font-bold">Posts</h1>
{editingPost ? (
<EditPostForm
post={editingPost}
onSave={editPost}
onCancel={() => setEditingId(null)}
/>
) : (
<CreatePostForm onCreate={addPost} />
)}
<PostList
posts={posts}
onEdit={id => setEditingId(id)}
onDelete={removePost}
/>
</section>
);
}You’ve now built:
typed CRUD endpoints
reusable hooks
reusable forms
a full CRUD page
This is the exact architecture professional React teams use.
10. What’s Next?
In Part 2.3, you’ll take forms deeper — adding client-side validation, reusable input components, and proper submission flows that match your coming NestJS DTOs.
This is where frontend and backend contracts start to align tightly.
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.3 — Handling Forms, Validation & Submission to REST Endpoints
Forms connect users to data. In this post, you’ll learn how to validate input, manage UX states, and submit cleanly typed data to real REST endpoints.
Comments