The API can already search, filter, sort, paginate and count. This lesson gives it a face. The interesting design decision is where the state lives. Instead of keeping the search text and page number in component state, we keep them in the address bar: /?status=applied&q=bank&page=2. That gives you three things for free: the back button works, reloading keeps your place, and the person can bookmark or share a view.
The hook follows the URL
import { useCallback, useEffect, useState } from 'react';
import { api } from '../api.js';
const EMPTY = { items: [], total: 0, page: 1, pages: 1 };
// Lesson 33: the list now follows the filters in the address bar (q, status, sort, page), and the stats come with it.
export function useJobs({ q = '', status = '', sort = 'newest', page = 1, limit = 8 } = {}) {
const [data, setData] = useState(EMPTY);
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [version, setVersion] = useState(0); // bump this number to load again
// Build the query string once; the effect below re-runs only when it really changes.
const query = new URLSearchParams({ ...(q && { q }), ...(status && { status }), sort, page: String(page), limit: String(limit) }).toString();
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError('');
Promise.all([api(`/jobs?${query}`, { signal: controller.signal }), api('/jobs/stats', { signal: controller.signal })])
.then(([list, s]) => { setData(list); setStats(s); })
.catch((err) => { if (err.name !== 'AbortError') setError(err.message); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [query, version]);
const reload = useCallback(() => setVersion((v) => v + 1), []);
const createJob = useCallback(async (values) => {
const saved = await api('/jobs', { method: 'POST', body: values });
reload();
return saved;
}, [reload]);
const changeStatus = useCallback(async (id, next) => {
let before;
setData((d) => ({ ...d, items: d.items.map((j) => { if (j.id === id) before = j; return j.id === id ? { ...j, status: next } : j; }) }));
try {
await api(`/jobs/${id}`, { method: 'PUT', body: { status: next } });
reload(); // the counts and the active filter may have changed
} catch (err) {
setData((d) => ({ ...d, items: d.items.map((j) => (j.id === id ? before : j)) }));
setError(`Could not change the status: ${err.message}`);
}
}, [reload]);
const removeJob = useCallback(async (id) => {
await api(`/jobs/${id}`, { method: 'DELETE' });
reload();
}, [reload]);
return { ...data, stats, loading, error, createJob, changeStatus, removeJob, reload };
}
- It takes
{ q, status, sort, page, limit }and builds the query string withURLSearchParams, which also takes care of encoding special characters. - The effect depends on
query(a string) andversion(a number). When the filters change, the query changes and the effect fetches again. When data changes (a job is added, deleted or moved),reload()increasesversion, which runs the effect again so the list and the statistics refresh. - It loads the list and the statistics together with
Promise.all, and aborts the old requests in the cleanup. That is what prevents the classic search bug: you typebathenbank, and the slower first answer arrives last and overwrites the right one.
The page reads the URL
import { Link, useSearchParams } from 'react-router-dom';
import JobCard from '../components/JobCard.jsx';
import JobForm from '../components/JobForm.jsx';
import StatsBar from '../components/StatsBar.jsx';
import Filters from '../components/Filters.jsx';
import Pagination from '../components/Pagination.jsx';
import { useJobs } from '../hooks/useJobs.js';
export default function JobsPage() {
// The address bar is the state: ?q=&status=&sort=&page=
const [params, setParams] = useSearchParams();
const q = params.get('q') || '';
const status = params.get('status') || '';
const sort = params.get('sort') || 'newest';
const page = Math.max(1, Number(params.get('page')) || 1);
const { items, total, pages, stats, loading, error, createJob, changeStatus, removeJob } = useJobs({ q, status, sort, page });
// Change one filter, drop the page back to 1, and keep the others. `replace` avoids a history entry per keystroke.
const setFilter = (change) => {
const next = new URLSearchParams(params);
for (const [k, v] of Object.entries(change)) (v ? next.set(k, v) : next.delete(k));
next.delete('page');
setParams(next, { replace: true });
};
const setPage = (p) => { const next = new URLSearchParams(params); next.set('page', String(p)); setParams(next); };
const filtered = Boolean(q || status);
return (
<>
<h1>My jobs</h1>
<StatsBar stats={stats} />
<details className="card">
<summary>Add a job</summary>
<JobForm onSubmit={createJob} />
</details>
<Filters q={q} status={status} sort={sort} onChange={setFilter} />
{error && <p className="error" role="alert">{error}</p>}
<p className="muted" aria-live="polite">{loading ? 'Loading…' : `${total} ${total === 1 ? 'job' : 'jobs'}${filtered ? ' match' : ''}`}</p>
{!loading && !error && items.length === 0 && (
<p>{filtered ? 'No job matches these filters.' : 'No jobs yet. Open "Add a job" to start.'}</p>
)}
{items.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 danger" onClick={() => { if (window.confirm(`Delete ${job.role} at ${job.company}?`)) removeJob(job.id); }}>Delete</button>
</p>
</div>
))}
<Pagination page={page} pages={pages} onPage={setPage} />
</>
);
}
useSearchParams() works like useState for the query string: it returns the current parameters and a function to change them. The page reads q, status, sort and page from it, with sensible defaults, and hands them to useJobs. setFilter copies the current parameters, applies the change, and drops `page`. If you are on page 4 and choose a status with only two pages of results, staying on page 4 would show nothing. replace: true replaces the current history entry rather than adding one, so typing in the search box does not create dozens of back-button steps.
Small, focused components
import { STATUSES } from './JobCard.jsx';
// Numbers from GET /api/jobs/stats. interviewRate is null until there is at least one application.
export default function StatsBar({ stats }) {
if (!stats) return null;
return (
<section aria-label="Your numbers" className="stats">
<div className="stat"><strong>{stats.total}</strong><span>total</span></div>
{STATUSES.map((s) => <div className="stat" key={s}><strong>{stats.byStatus[s]}</strong><span>{s}</span></div>)}
<div className="stat"><strong>{stats.interviewRate === null ? '–' : `${stats.interviewRate}%`}</strong><span>reached interview</span></div>
</section>
);
}
import { STATUSES } from './JobCard.jsx';
// The filters live in the URL (/?status=applied&q=bank), so a page can be bookmarked, shared and reloaded.
export default function Filters({ q, status, sort, onChange }) {
return (
<form className="filters" role="search" onSubmit={(e) => e.preventDefault()}>
<div className="field">
<label htmlFor="q">Search</label>
<input id="q" type="search" placeholder="Company, role or place" value={q} onChange={(e) => onChange({ q: e.target.value })} />
</div>
<div className="field">
<label htmlFor="filter-status">Status</label>
<select id="filter-status" value={status} onChange={(e) => onChange({ status: e.target.value })}>
<option value="">All</option>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="field">
<label htmlFor="sort">Sort by</label>
<select id="sort" value={sort} onChange={(e) => onChange({ sort: e.target.value })}>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="company">Company A to Z</option>
</select>
</div>
</form>
);
}
export default function Pagination({ page, pages, onPage }) {
if (pages <= 1) return null;
return (
<nav className="pager" aria-label="Pages">
<button className="btn secondary" disabled={page <= 1} onClick={() => onPage(page - 1)}>Previous</button>
<span aria-live="polite">Page {page} of {pages}</span>
<button className="btn secondary" disabled={page >= pages} onClick={() => onPage(page + 1)}>Next</button>
</nav>
);
}
- `StatsBar` shows the numbers from
/api/jobs/stats.interviewRatecan benull(lesson 19), and the bar shows “–” instead of a number that would mean nothing. It sits inside<section aria-label=...>so screen readers can find it. - `Filters` is a
role="search"form with labelled controls. It does not own any state: it receives the values and a singleonChange, so it is easy to reuse and test. - `Pagination` disables Previous and Next at the ends and announces the page with
aria-live="polite".
Notice the “no results” messages. “No job matches these filters” and “No jobs yet” are different situations that need different words, so the code distinguishes them with a filtered flag. The count line also changes between “1 job” and “3 jobs”. Details like this are what make a screen feel finished.
Your task
- Run
npm run seedon the server, sign in asdemo@example.com, and explore the dashboard. - Apply a filter, go to page 2, and reload. You should stay where you were.
- Copy the address and open it in a private window after signing in: the same view appears.
- Add a “Clear filters” button that empties all the parameters.
How to check it worked
- Search, status and sort change the list and the address.
- Changing a filter resets to page 1.
- Adding or moving a job updates the statistics too.
Summary
- Keep list state in the URL with
useSearchParamsso views can be shared and reloaded. - Abort old requests so slow answers cannot overwrite new ones.
- Write separate messages for “nothing yet” and “nothing matches”.