Skip to content

Part 2.2 — Building CRUD Interfaces: Posts, Users, and API Contracts

Site Console Site Console
6 min read Updated Nov 26, 2025 Web Development 0 comments
Building CRUD Interfaces in React with TypeScript

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/postsPost[]
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

Leave a comment

Sign in to leave a comment.

Comments