React · Shipping

Complete CRUD Project

This project puts the React track together: components, state, forms, validation, effects, an API module and good loading and error states — in one small product manager you can extend.

Advanced 8 min Lesson 41 of 42Examples target React 19 with Vite.
0/42 in React
On this page · 8 min · Advanced

Learning objectives#

  • Combine list, form and API layers into one feature
  • Reuse one form for create and edit
  • Delete with confirmation and optimistic updates
  • Structure the code so it can grow

Explanation#

Structure: an api module (replace the fake with fetch to your Node.js or ASP.NET Core backend), a ProductForm used for both create and edit, a ProductTable, and a ProductsPage that owns the state.

The page keeps products, the item being edited, status and error. Child components receive data and callbacks.

Create/update: validate in the form, call the API, then update the list with the server’s response.

Delete: ask for confirmation, remove the row immediately (optimistic), and put it back if the API call fails.

Search is derived from state — no extra request needed for small lists.

Extend it: add pagination with search params, React Router routes for /products/new and /products/:id/edit, authentication and tests.

Code example: A product manager with create, read, update and delete#

JSXProductsPage.jsx Runnable
import { useEffect, useState } from 'react';


// ── api.js — swap this fake for fetch calls to your backend ──
const db = new Map([[1, { id: 1, name: 'Pen', price: 20 }], [2, { id: 2, name: 'Notebook', price: 60 }]]);
let nextId = 3;
const delay = (value, ms = 300) => new Promise(r => setTimeout(() => r(value), ms));
const api = {
  list: () => delay([...db.values()]),
  create: p => { const item = { ...p, id: nextId++ }; db.set(item.id, item); return delay(item); },
  update: p => { db.set(p.id, p); return delay(p); },
  remove: id => (id === 999 ? Promise.reject(new Error('Server refused')) : delay(db.delete(id))),
};


// ── ProductForm — used for both create and edit ──
function ProductForm({ initial, onSave, onCancel }) {
  const [values, setValues] = useState(initial ?? { name: '', price: '' });
  const [error, setError] = useState('');


  function handleSubmit(e) {
    e.preventDefault();
    const price = Number(values.price);
    if (values.name.trim().length < 2) return setError('Name must have at least 2 characters.');
    if (!(price > 0)) return setError('Price must be greater than 0.');
    onSave({ ...values, name: values.name.trim(), price });
  }


  return (
    <form onSubmit={handleSubmit}>
      <input value={values.name} onChange={e => setValues({ ...values, name: e.target.value })} aria-label="Name" placeholder="Name" />
      <input value={values.price} onChange={e => setValues({ ...values, price: e.target.value })} aria-label="Price" placeholder="Price" type="number" />
      <button>{initial ? 'Update' : 'Add'}</button>
      {onCancel && <button type="button" onClick={onCancel}>Cancel</button>}
      {error && <p role="alert">{error}</p>}
    </form>
  );
}


// ── ProductTable ──
function ProductTable({ products, onEdit, onDelete }) {
  if (products.length === 0) return <p>No products match.</p>;
  return (
    <table>
      <thead><tr><th>Name</th><th>Price</th><th /></tr></thead>
      <tbody>
        {products.map(p => (
          <tr key={p.id}>
            <td>{p.name}</td>
            <td>₹{p.price}</td>
            <td>
              <button onClick={() => onEdit(p)}>Edit</button>
              <button onClick={() => onDelete(p)}>Delete</button>
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}


// ── ProductsPage — owns the state ──
export default function ProductsPage() {
  const [products, setProducts] = useState([]);
  const [status, setStatus] = useState('loading');
  const [error, setError] = useState('');
  const [editing, setEditing] = useState(null);
  const [query, setQuery] = useState('');


  useEffect(() => {
    api.list().then(list => { setProducts(list); setStatus('ready'); }).catch(() => setStatus('error'));
  }, []);


  async function handleCreate(product) {
    const created = await api.create(product);
    setProducts(list => [...list, created]);
  }


  async function handleUpdate(product) {
    const saved = await api.update(product);
    setProducts(list => list.map(p => (p.id === saved.id ? saved : p)));
    setEditing(null);
  }


  async function handleDelete(product) {
    if (!window.confirm(`Delete ${product.name}?`)) return;
    const previous = products;
    setProducts(list => list.filter(p => p.id !== product.id));   // optimistic
    try {
      await api.remove(product.id);
    } catch {
      setProducts(previous);                                         // roll back
      setError(`Could not delete ${product.name}.`);
    }
  }


  if (status === 'loading') return <p>Loading products…</p>;
  if (status === 'error') return <p role="alert">Could not load products.</p>;


  const visible = products.filter(p => p.name.toLowerCase().includes(query.toLowerCase()));


  return (
    <main>
      <h1>Products ({products.length})</h1>
      <input value={query} onChange={e => setQuery(e.target.value)} aria-label="Search" placeholder="Search" />
      {error && <p role="alert">{error}</p>}
      <ProductTable products={visible} onEdit={setEditing} onDelete={handleDelete} />
      <h2>{editing ? `Edit ${editing.name}` : 'Add a product'}</h2>
      <ProductForm
        key={editing?.id ?? 'new'}                 // fresh form state for each product
        initial={editing}
        onSave={editing ? handleUpdate : handleCreate}
        onCancel={editing ? () => setEditing(null) : null}
      />
    </main>
  );
}
Expected output
“Loading products…” appears briefly, then a table with Pen and Notebook. Adding “Bag” at 650 appends a row. Clicking Edit fills the form (“Edit Pen”); Update saves and returns to “Add a product”. Delete asks for confirmation and removes the row instantly; if the server refused, the row would come back with an error message. The search box filters the table as you type.

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. api wraps every backend call; replacing it with fetch calls connects the app to a real API.
  2. ProductForm validates and calls onSave; key resets it when switching products.
  3. ProductsPage owns products, status, editing and query, and passes data and callbacks down.
  4. Create and update use the server’s response to update the list.
  5. Delete updates the UI optimistically and rolls back on failure.
  6. The visible list is derived from products and query on every render.

Real-world use case#

Admin screens for categories, suppliers, coupons and users all follow this same template, so a new CRUD screen takes an afternoon instead of a week.

Common mistakes#

Refetching everything after each change

Reloading the whole list after every save is slower and flickers. Merge the server’s response into state.

Stale form when switching items

Without key, the form keeps the previous product’s values. A key per item resets it.

Optimistic updates without rollback

If the server fails, the UI shows data that no longer matches the database. Keep the previous state and restore it.

Best practices#

  • Separate API, list, form and page components
  • One form for create and edit
  • Confirm destructive actions
  • Optimistic updates with rollback

Remember this#

Page owns state → table and form get data + callbacks.

key={item.id} resets a form for a new item.

Optimistic: update UI, call API, roll back on error.

Practice exercise#

Your task: Extend the project: add a "Sort by price" checkbox that sorts the visible products from cheapest to most expensive.

Need a hint?
Derive: const sorted = sortByPrice ? [...visible].sort((a, b) => a.price - b.price) : visible.
Show solution
JSX Runnable
import { useState } from 'react';


const PRODUCTS = [{ id: 1, name: 'Notebook', price: 60 }, { id: 2, name: 'Pen', price: 20 }];


export default function SortedProducts() {
  const [sortByPrice, setSortByPrice] = useState(true);
  const sorted = sortByPrice ? [...PRODUCTS].sort((a, b) => a.price - b.price) : PRODUCTS;
  return (
    <>
      <label><input type="checkbox" checked={sortByPrice} onChange={e => setSortByPrice(e.target.checked)} /> Sort by price</label>
      <ul>{sorted.map(p => <li key={p.id}>{p.name} — ₹{p.price}</li>)}</ul>
    </>
  );
}

Mini quiz#

Complete CRUD Project quizMultiple choice Question 1 of 3

Why give ProductForm key={editing?.id ?? "new"}?

Score so far: 0/0

Want more? Practise all React MCQs · React interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.