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
- Add a job through the form. Reload: it is still there (it is in MongoDB now).
- Open the job, change the notes and save. Check the document in Atlas.
- Stop the server and try to change a status. Does the old status come back, with an error message?
- 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
initialandonSubmit. - Show saving, field errors and failures, and display what the server returned.