CRUD in React: Create, Edit and Delete Jobs Through the API

MERN Stack Full Course: MongoDB, Express, React and Node.js · React Fundamentals · 11 min read · Updated

Change data, then the screen

  1. The person fills in a form, or presses Edit or Delete.
  2. The click calls our api helper, which sends the request with the token and the JSON.
  3. The server validates and changes the data, then replies with the result.
  4. Only when the server confirms do we update the list in state, using new arrays.
  5. The button shows a saving state while it waits. Delete asks for confirmation, and failures show a message.

Everything the person does on screen must now reach the database: add, change and delete. The three things to get right in a real app are the same three you have met on the server: the happy path, the unhappy path (show the server's field errors and failures), and the waiting (show that something is happening and prevent double clicks).

The hook now changes data

import { useCallback, useEffect, useState } from 'react';
import { api } from '../api.js';
import { useAuth } from '../context/AuthContext.jsx';

// Lesson 31: the hook now also changes data on the server (create, update, delete).
export function useJobs() {
  const { ready, user } = useAuth();
  const [jobs, setJobs] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');

  const load = useCallback(async (signal) => {
    setLoading(true);
    setError('');
    try {
      const page = await api('/jobs?limit=50', { signal });
      setJobs(page.items);
    } catch (err) {
      if (err.name !== 'AbortError') setError(err.message);
    } finally {
      if (!signal?.aborted) setLoading(false);
    }
  }, []);

  useEffect(() => {
    if (!ready) return undefined;
    if (!user) { setLoading(false); setError('Could not sign in. Is the server running, and did you run npm run seed?'); return undefined; }
    const controller = new AbortController();
    load(controller.signal);
    return () => controller.abort();
  }, [ready, user, load]);

  // Create: wait for the server's answer (it adds the real id), then put the saved job at the top.
  const createJob = useCallback(async (values) => {
    const saved = await api('/jobs', { method: 'POST', body: values });
    setJobs((cur) => [saved, ...cur]);
    return saved;
  }, []);

  // Update with an "optimistic" screen: change it at once, and undo if the server says no.
  const changeStatus = useCallback(async (id, status) => {
    let before;
    setJobs((cur) => cur.map((j) => { if (j.id === id) before = j; return j.id === id ? { ...j, status } : j; }));
    try {
      await api(`/jobs/${id}`, { method: 'PUT', body: { status } });
    } catch (err) {
      setJobs((cur) => cur.map((j) => (j.id === id ? before : j)));
      setError(`Could not change the status: ${err.message}`);
    }
  }, []);

  const removeJob = useCallback(async (id) => {
    await api(`/jobs/${id}`, { method: 'DELETE' });
    setJobs((cur) => cur.filter((j) => j.id !== id));
  }, []);

  return { jobs, loading, error, setError, createJob, changeStatus, removeJob, reload: () => load() };
}
  • `createJob` waits for the server, which creates the real id, then puts the saved job at the top of the list. We do not invent an id on the client any more.
  • `changeStatus` is an optimistic update: it changes the screen at once, then sends the request. If the server refuses, it puts the old job back and shows an error. The screen feels instant, and the truth is restored if something goes wrong. Use this style for small, low-risk changes, and not for things like payments.
  • `removeJob` waits for the server first, and then removes the job from the list, since deleting is not something to guess.
  • All three are wrapped in useCallback, so they keep their identity. That will matter for performance in lesson 35.

A form that adds and edits

import { useState } from 'react';
import { STATUSES } from './JobCard.jsx';

const EMPTY = { company: '', role: '', location: '', status: 'saved', salaryNote: '', notes: '' };

// Used for adding AND for editing: `initial` fills the fields, `onSubmit` returns a promise.
export default function JobForm({ onSubmit, initial = EMPTY, submitLabel = 'Add job', resetAfter = true }) {
  const [values, setValues] = useState({ ...EMPTY, ...initial });
  const [errors, setErrors] = useState({});
  const [saving, setSaving] = useState(false);
  const [message, setMessage] = useState('');

  const change = (e) => setValues((v) => ({ ...v, [e.target.name]: e.target.value }));

  async function submit(e) {
    e.preventDefault();
    const found = {};
    if (!values.company.trim()) found.company = 'Company is required';
    if (!values.role.trim()) found.role = 'Role is required';
    setErrors(found);
    setMessage('');
    if (Object.keys(found).length) return;

    setSaving(true);
    try {
      await onSubmit(values);
      if (resetAfter) setValues(EMPTY);
    } catch (err) {
      setErrors(err.details || {});          // field errors the server found
      setMessage(err.message);
    } finally {
      setSaving(false);
    }
  }

  return (
    <form className="card form" onSubmit={submit} noValidate>
      <div className="field">
        <label htmlFor="company">Company</label>
        <input id="company" name="company" value={values.company} onChange={change} aria-invalid={Boolean(errors.company)} />
        {errors.company && <p className="error">{errors.company}</p>}
      </div>
      <div className="field">
        <label htmlFor="role">Role</label>
        <input id="role" name="role" value={values.role} onChange={change} aria-invalid={Boolean(errors.role)} />
        {errors.role && <p className="error">{errors.role}</p>}
      </div>
      <div className="field">
        <label htmlFor="location">Location</label>
        <input id="location" name="location" value={values.location} onChange={change} />
      </div>
      <div className="field">
        <label htmlFor="status">Status</label>
        <select id="status" name="status" value={values.status} onChange={change}>
          {STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
        </select>
      </div>
      <div className="field">
        <label htmlFor="salaryNote">Pay (as advertised)</label>
        <input id="salaryNote" name="salaryNote" value={values.salaryNote} onChange={change} />
      </div>
      <div className="field wide">
        <label htmlFor="notes">Notes</label>
        <textarea id="notes" name="notes" rows="3" value={values.notes} onChange={change} />
      </div>
      <div className="wide">
        <button className="btn" type="submit" disabled={saving}>{saving ? 'Saving…' : submitLabel}</button>
        {message && <p className="error" role="alert">{message}</p>}
      </div>
    </form>
  );
}

The same component now serves both purposes. initial fills the fields (empty for adding, the job for editing), submitLabel changes the button text, and onSubmit returns a promise. While it runs, saving is true: the button is disabled and says “Saving…”, which stops double submissions. If the server answers with field errors, err.details goes straight into the errors state, so a message appears under the right field. If it answers with another error, the message is shown below the button inside role="alert", which screen readers announce.

import { Link } from 'react-router-dom';
import JobCard from '../components/JobCard.jsx';
import JobForm from '../components/JobForm.jsx';
import { useJobs } from '../hooks/useJobs.js';

export default function JobsPage() {
  const { jobs, loading, error, createJob, changeStatus, removeJob } = useJobs();

  return (
    <>
      <h1>My jobs ({jobs.length})</h1>
      <JobForm onSubmit={createJob} />
      {loading && <p className="muted">Loading your jobs…</p>}
      {error && <p className="error" role="alert">{error}</p>}
      {!loading && !error && jobs.length === 0 && <p className="muted">No jobs yet. Add your first one above.</p>}
      {jobs.map((job) => (
        <div key={job.id}>
          <JobCard job={job} onStatusChange={changeStatus} />
          <p className="muted">
            <Link to={`/jobs/${job.id}`}>Open details</Link>{' · '}
            <button className="link-btn" onClick={() => { if (window.confirm(`Delete ${job.role} at ${job.company}?`)) removeJob(job.id); }}>Delete</button>
          </p>
        </div>
      ))}
    </>
  );
}

Deleting uses window.confirm here. It is basic but accessible and quick to build. A custom dialog is nicer and is a good exercise. Before you delete anything, ask.

The edit page

import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../api.js';
import { useAuth } from '../context/AuthContext.jsx';
import JobForm from '../components/JobForm.jsx';

export default function JobDetailPage() {
  const { id } = useParams();
  const navigate = useNavigate();
  const { ready } = useAuth();
  const [job, setJob] = useState(null);
  const [error, setError] = useState('');
  const [saved, setSaved] = useState(false);

  useEffect(() => {
    if (!ready) return undefined;
    const controller = new AbortController();
    api(`/jobs/${id}`, { signal: controller.signal })
      .then(setJob)
      .catch((err) => err.name !== 'AbortError' && setError(err.status === 404 ? 'That job was not found.' : err.message));
    return () => controller.abort();
  }, [id, ready]);

  async function save(values) {
    const updated = await api(`/jobs/${id}`, { method: 'PUT', body: values });
    setJob(updated);
    setSaved(true);
  }

  async function remove() {
    if (!window.confirm('Delete this job?')) return;
    await api(`/jobs/${id}`, { method: 'DELETE' });
    navigate('/'); // go back to the list
  }

  if (error) return <p className="error" role="alert">{error} <Link to="/">Back to my jobs</Link></p>;
  if (!job) return <p className="muted">Loading…</p>;
  return (
    <>
      <h1>{job.role} at {job.company}</h1>
      {/* key={job.updatedAt} makes React build a fresh form when the saved job changes */}
      <JobForm key={job.updatedAt} initial={job} onSubmit={save} submitLabel="Save changes" resetAfter={false} />
      {saved && <p role="status" className="ok">Saved.</p>}
      <p className="muted">
        <Link to="/">← Back to my jobs</Link>{' · '}
        <button className="link-btn danger" onClick={remove}>Delete this job</button>
      </p>
    </>
  );
}

The detail page shows the form filled with the job. The key={job.updatedAt} on JobForm makes React build a fresh form whenever the saved job changes, so the fields always show what the server holds. The save answer is stored back in job, and a “Saved.” note uses role="status". After a delete it calls navigate('/') to go back to the list.

Handling failure like a professional

  • Never leave the person guessing: loading text, disabled buttons and clear messages.
  • Keep the failed input in the form so they can fix it, instead of clearing it.
  • Roll back optimistic changes when the server says no.
  • Do not show raw technical errors. The API's messages were written to be shown.

Your task

  1. Add a job through the form. Reload: it is still there (it is in MongoDB now).
  2. Open the job, change the notes and save. Check the document in Atlas.
  3. Stop the server and try to change a status. Does the old status come back, with an error message?
  4. Submit the form with a company longer than 120 characters. Where does the message appear?

How to check it worked

  • New, edited and deleted jobs survive a reload.
  • A failed status change is rolled back with a message.
  • The submit button is disabled while saving.

Summary

  • Create and delete wait for the server; small changes can be optimistic with a rollback.
  • One form component serves adding and editing through initial and onSubmit.
  • Show saving, field errors and failures, and display what the server returned.

Download the solution (mern-lesson-31.zip)

Frequently asked questions

What is an optimistic update?

Changing the screen immediately, assuming the server will accept the change, and undoing it if the server refuses. It makes small actions feel instant. Avoid it where a wrong guess has real cost.

Why wait for the server when creating a job?

Because the server creates the real id and applies its own cleaning and defaults. Showing the saved version keeps the screen true to the data.

How do I stop double submissions?

Disable the submit button while the request is in progress, using a saving state, and show a "Saving…" label. The server can also protect itself with unique indexes or idempotent routes.