Build a job board that fetches postings and loads more on demand. The shape is paginated fetching: keep the jobs loaded so far, and a "Load more" button that requests the next slice and appends it. While a request is in flight, show a loading state; when everything's loaded, retire the button.
type Job = { id: number; title: string; company: string };
function fetchJobs(offset: number, limit: number): Promise<Job[]>;
// A self-contained component. No props.
function App(): JSX.Element;
mount → fetchJobs(0, 5) → first 5 jobs, loading shown meanwhile
"Load more" → fetchJobs(5, 5) → next 5 appended below the first 5
when fewer than `limit` come back (or total reached) → no more pages → hide the button
[...jobs, ...more].Paginated loading is one list that grows. Keep the jobs you've loaded and a loading flag; "Load more" fetches the next slice starting at the current length and appends it. When a page comes back short, you've hit the end and the button retires.
You can't (or don't want to) load everything at once, so you load a page, then more pages on demand. The state is just the accumulated list. The next request always starts where the list currently ends — offset = jobs.length — and its results are concatenated, never replacing. Two flags round it out: loading (to show progress and block double-clicks) and a derived "is there more?" so you know when to stop offering the button.
State: jobs (everything loaded so far) and loading. On mount, fetch the first page. loadMore() sets loading, calls fetchJobs(jobs.length, PAGE), appends the result ([...jobs, ...more]), and clears loading. "There's more" is true while the last page came back full (=== PAGE); a short page means the end. The button is disabled while loading and hidden when there's no more.
A first attempt tracks a page number and replaces the list:
const [page, setPage] = useState(0);
useEffect(() => {
fetchJobs(page * PAGE, PAGE).then(setJobs); // replaces — earlier jobs vanish
}, [page]);
Replacing means each "Load more" shows only the latest five and drops the rest — not an infinite list. You'd then reconstruct the full list anyway. Accumulating directly ([...jobs, ...more]) with offset = jobs.length keeps it simple: the list is the source of truth, and the offset falls out of it.
import { useState, useEffect, useRef } from 'react';
import './styles.css';
type Job = { id: number; title: string; company: string };
const PAGE = 5;
const ALL_JOBS: Job[] = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
title: ['Frontend Engineer', 'Backend Engineer', 'Designer', 'PM'][i % 4],
company: ['Acme', 'Globex', 'Initech', 'Hooli', 'Umbrella'][i % 5],
}));
function fetchJobs(offset: number, limit: number): Promise<Job[]> {
// Simulated paginated API.
return new Promise((resolve) =>
setTimeout(() => resolve(ALL_JOBS.slice(offset, offset + limit)), 600),
);
}
export default function App() {
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const started = useRef(false);
function loadMore() {
setLoading(true);
fetchJobs(jobs.length, PAGE).then((more) => {
setJobs((prev) => [...prev, ...more]);
setHasMore(more.length === PAGE);
setLoading(false);
});
}
useEffect(() => {
if (started.current) return; // guard React 18 StrictMode's double-invoke
started.current = true;
loadMore(); // first page on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className="container">
<h1>Job Board</h1>
<ul className="jobs">
{jobs.map((job) => (
<li className="job" key={job.id}>
<p className="job-title">{job.title}</p>
<p className="job-meta">{job.company}</p>
</li>
))}
</ul>
{loading && <p className="status">Loading…</p>}
{!loading && hasMore && (
<button className="load-more" onClick={loadMore}>
Load more
</button>
)}
{!loading && !hasMore && <p className="status">No more jobs.</p>}
</main>
);
}
jobs accumulates; loadMore fetches at offset = jobs.length and appends with the functional updater (prev) => [...prev, ...more] (so concurrent-safe and never stale). hasMore is set from whether the page came back full — a short page (more.length < PAGE) means the source is exhausted. The render shows "Loading…" during a fetch, the button only when not loading and more remains, and "No more jobs." at the end. The mount effect kicks off page one.
12 jobs total, PAGE = 5.
loadMore(): loading = true, fetchJobs(0, 5). The list is empty so "Loading…" shows.[1..5]; jobs = [1..5], hasMore = (5 === 5) = true, loading = false. Five jobs render with "Load more."fetchJobs(5, 5) → [6..10]; jobs = [1..10] (appended), hasMore = true.fetchJobs(10, 5) → slice(10, 15) = [11, 12] (only 2 left); jobs = [1..12], hasMore = (2 === 5) = false.jobs.length — 0, 5, 10 — so no page was missed or repeated.[...prev, ...more].offset = jobs.length.loading.hasMore check you keep requesting empty pages. Fix: short page → done.jobs in append. Reading jobs directly can be stale across rapid loads. Fix: functional updater.useRef "started" guard so the first load fires once.loadMore from an IntersectionObserver sentinel instead of a button.This version moves the complete pagination contract into a reusable hook. The component receives render ready state and a single guarded action.
import { useCallback, useEffect, useRef, useState } from 'react';
import './styles.css';
type Job = { id: number; title: string; company: string };
const PAGE = 5;
const ALL_JOBS: Job[] = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
title: ['Frontend Engineer', 'Backend Engineer', 'Designer', 'PM'][i % 4],
company: ['Acme', 'Globex', 'Initech', 'Hooli', 'Umbrella'][i % 5],
}));
function fetchJobs(offset: number, limit: number): Promise<Job[]> {
return new Promise((resolve) =>
setTimeout(() => resolve(ALL_JOBS.slice(offset, offset + limit)), 600),
);
}
function useJobBoard() {
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const started = useRef(false);
const inFlight = useRef(false);
const loadMore = useCallback(() => {
if (inFlight.current || !hasMore) return;
inFlight.current = true;
setLoading(true);
fetchJobs(jobs.length, PAGE).then((more) => {
setJobs((current) => [...current, ...more]);
setHasMore(more.length === PAGE);
setLoading(false);
inFlight.current = false;
});
}, [hasMore, jobs.length]);
useEffect(() => {
if (started.current) return;
started.current = true;
loadMore();
}, [loadMore]);
return { jobs, loading, hasMore, loadMore };
}
export default function App() {
const { jobs, loading, hasMore, loadMore } = useJobBoard();
return (
<main className="container">
<h1>Job Board</h1>
<ul className="jobs">
{jobs.map((job) => (
<li className="job" key={job.id}>
<p className="job-title">{job.title}</p>
<p className="job-meta">{job.company}</p>
</li>
))}
</ul>
{loading && <p className="status">Loading…</p>}
{!loading && hasMore && <button className="load-more" onClick={loadMore}>Load more</button>}
{!loading && !hasMore && <p className="status">No more jobs.</p>}
</main>
);
}This version uses reducer events for every state transition. A synchronous request guard complements the visible loading state and prevents duplicate offsets.
import { useCallback, useEffect, useReducer, useRef } from 'react';
import './styles.css';
type Job = { id: number; title: string; company: string };
type State = { jobs: Job[]; loading: boolean; hasMore: boolean };
type Action = { type: 'request' } | { type: 'received'; jobs: Job[] };
const PAGE = 5;
const ALL_JOBS: Job[] = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
title: ['Frontend Engineer', 'Backend Engineer', 'Designer', 'PM'][i % 4],
company: ['Acme', 'Globex', 'Initech', 'Hooli', 'Umbrella'][i % 5],
}));
function fetchJobs(offset: number, limit: number): Promise<Job[]> {
return new Promise((resolve) =>
setTimeout(() => resolve(ALL_JOBS.slice(offset, offset + limit)), 600),
);
}
function reducer(state: State, action: Action): State {
if (action.type === 'request') return { ...state, loading: true };
return {
jobs: [...state.jobs, ...action.jobs],
loading: false,
hasMore: action.jobs.length === PAGE,
};
}
export default function App() {
const [state, dispatch] = useReducer(reducer, { jobs: [], loading: false, hasMore: true });
const started = useRef(false);
const inFlight = useRef(false);
const loadMore = useCallback(() => {
if (inFlight.current || !state.hasMore) return;
inFlight.current = true;
dispatch({ type: 'request' });
fetchJobs(state.jobs.length, PAGE).then((jobs) => {
dispatch({ type: 'received', jobs });
inFlight.current = false;
});
}, [state.hasMore, state.jobs.length]);
useEffect(() => {
if (started.current) return;
started.current = true;
loadMore();
}, [loadMore]);
return (
<main className="container">
<h1>Job Board</h1>
<ul className="jobs">
{state.jobs.map((job) => (
<li className="job" key={job.id}>
<p className="job-title">{job.title}</p>
<p className="job-meta">{job.company}</p>
</li>
))}
</ul>
{state.loading && <p className="status">Loading…</p>}
{!state.loading && state.hasMore && <button className="load-more" onClick={loadMore}>Load more</button>}
{!state.loading && !state.hasMore && <p className="status">No more jobs.</p>}
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a job board that fetches postings and loads more on demand. The shape is paginated fetching: keep the jobs loaded so far, and a "Load more" button that requests the next slice and appends it. While a request is in flight, show a loading state; when everything's loaded, retire the button.
type Job = { id: number; title: string; company: string };
function fetchJobs(offset: number, limit: number): Promise<Job[]>;
// A self-contained component. No props.
function App(): JSX.Element;
mount → fetchJobs(0, 5) → first 5 jobs, loading shown meanwhile
"Load more" → fetchJobs(5, 5) → next 5 appended below the first 5
when fewer than `limit` come back (or total reached) → no more pages → hide the button
[...jobs, ...more].Paginated loading is one list that grows. Keep the jobs you've loaded and a loading flag; "Load more" fetches the next slice starting at the current length and appends it. When a page comes back short, you've hit the end and the button retires.
You can't (or don't want to) load everything at once, so you load a page, then more pages on demand. The state is just the accumulated list. The next request always starts where the list currently ends — offset = jobs.length — and its results are concatenated, never replacing. Two flags round it out: loading (to show progress and block double-clicks) and a derived "is there more?" so you know when to stop offering the button.
State: jobs (everything loaded so far) and loading. On mount, fetch the first page. loadMore() sets loading, calls fetchJobs(jobs.length, PAGE), appends the result ([...jobs, ...more]), and clears loading. "There's more" is true while the last page came back full (=== PAGE); a short page means the end. The button is disabled while loading and hidden when there's no more.
A first attempt tracks a page number and replaces the list:
const [page, setPage] = useState(0);
useEffect(() => {
fetchJobs(page * PAGE, PAGE).then(setJobs); // replaces — earlier jobs vanish
}, [page]);
Replacing means each "Load more" shows only the latest five and drops the rest — not an infinite list. You'd then reconstruct the full list anyway. Accumulating directly ([...jobs, ...more]) with offset = jobs.length keeps it simple: the list is the source of truth, and the offset falls out of it.
import { useState, useEffect, useRef } from 'react';
import './styles.css';
type Job = { id: number; title: string; company: string };
const PAGE = 5;
const ALL_JOBS: Job[] = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
title: ['Frontend Engineer', 'Backend Engineer', 'Designer', 'PM'][i % 4],
company: ['Acme', 'Globex', 'Initech', 'Hooli', 'Umbrella'][i % 5],
}));
function fetchJobs(offset: number, limit: number): Promise<Job[]> {
// Simulated paginated API.
return new Promise((resolve) =>
setTimeout(() => resolve(ALL_JOBS.slice(offset, offset + limit)), 600),
);
}
export default function App() {
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const started = useRef(false);
function loadMore() {
setLoading(true);
fetchJobs(jobs.length, PAGE).then((more) => {
setJobs((prev) => [...prev, ...more]);
setHasMore(more.length === PAGE);
setLoading(false);
});
}
useEffect(() => {
if (started.current) return; // guard React 18 StrictMode's double-invoke
started.current = true;
loadMore(); // first page on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className="container">
<h1>Job Board</h1>
<ul className="jobs">
{jobs.map((job) => (
<li className="job" key={job.id}>
<p className="job-title">{job.title}</p>
<p className="job-meta">{job.company}</p>
</li>
))}
</ul>
{loading && <p className="status">Loading…</p>}
{!loading && hasMore && (
<button className="load-more" onClick={loadMore}>
Load more
</button>
)}
{!loading && !hasMore && <p className="status">No more jobs.</p>}
</main>
);
}
jobs accumulates; loadMore fetches at offset = jobs.length and appends with the functional updater (prev) => [...prev, ...more] (so concurrent-safe and never stale). hasMore is set from whether the page came back full — a short page (more.length < PAGE) means the source is exhausted. The render shows "Loading…" during a fetch, the button only when not loading and more remains, and "No more jobs." at the end. The mount effect kicks off page one.
12 jobs total, PAGE = 5.
loadMore(): loading = true, fetchJobs(0, 5). The list is empty so "Loading…" shows.[1..5]; jobs = [1..5], hasMore = (5 === 5) = true, loading = false. Five jobs render with "Load more."fetchJobs(5, 5) → [6..10]; jobs = [1..10] (appended), hasMore = true.fetchJobs(10, 5) → slice(10, 15) = [11, 12] (only 2 left); jobs = [1..12], hasMore = (2 === 5) = false.jobs.length — 0, 5, 10 — so no page was missed or repeated.[...prev, ...more].offset = jobs.length.loading.hasMore check you keep requesting empty pages. Fix: short page → done.jobs in append. Reading jobs directly can be stale across rapid loads. Fix: functional updater.useRef "started" guard so the first load fires once.loadMore from an IntersectionObserver sentinel instead of a button.This version moves the complete pagination contract into a reusable hook. The component receives render ready state and a single guarded action.
import { useCallback, useEffect, useRef, useState } from 'react';
import './styles.css';
type Job = { id: number; title: string; company: string };
const PAGE = 5;
const ALL_JOBS: Job[] = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
title: ['Frontend Engineer', 'Backend Engineer', 'Designer', 'PM'][i % 4],
company: ['Acme', 'Globex', 'Initech', 'Hooli', 'Umbrella'][i % 5],
}));
function fetchJobs(offset: number, limit: number): Promise<Job[]> {
return new Promise((resolve) =>
setTimeout(() => resolve(ALL_JOBS.slice(offset, offset + limit)), 600),
);
}
function useJobBoard() {
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const started = useRef(false);
const inFlight = useRef(false);
const loadMore = useCallback(() => {
if (inFlight.current || !hasMore) return;
inFlight.current = true;
setLoading(true);
fetchJobs(jobs.length, PAGE).then((more) => {
setJobs((current) => [...current, ...more]);
setHasMore(more.length === PAGE);
setLoading(false);
inFlight.current = false;
});
}, [hasMore, jobs.length]);
useEffect(() => {
if (started.current) return;
started.current = true;
loadMore();
}, [loadMore]);
return { jobs, loading, hasMore, loadMore };
}
export default function App() {
const { jobs, loading, hasMore, loadMore } = useJobBoard();
return (
<main className="container">
<h1>Job Board</h1>
<ul className="jobs">
{jobs.map((job) => (
<li className="job" key={job.id}>
<p className="job-title">{job.title}</p>
<p className="job-meta">{job.company}</p>
</li>
))}
</ul>
{loading && <p className="status">Loading…</p>}
{!loading && hasMore && <button className="load-more" onClick={loadMore}>Load more</button>}
{!loading && !hasMore && <p className="status">No more jobs.</p>}
</main>
);
}This version uses reducer events for every state transition. A synchronous request guard complements the visible loading state and prevents duplicate offsets.
import { useCallback, useEffect, useReducer, useRef } from 'react';
import './styles.css';
type Job = { id: number; title: string; company: string };
type State = { jobs: Job[]; loading: boolean; hasMore: boolean };
type Action = { type: 'request' } | { type: 'received'; jobs: Job[] };
const PAGE = 5;
const ALL_JOBS: Job[] = Array.from({ length: 12 }, (_, i) => ({
id: i + 1,
title: ['Frontend Engineer', 'Backend Engineer', 'Designer', 'PM'][i % 4],
company: ['Acme', 'Globex', 'Initech', 'Hooli', 'Umbrella'][i % 5],
}));
function fetchJobs(offset: number, limit: number): Promise<Job[]> {
return new Promise((resolve) =>
setTimeout(() => resolve(ALL_JOBS.slice(offset, offset + limit)), 600),
);
}
function reducer(state: State, action: Action): State {
if (action.type === 'request') return { ...state, loading: true };
return {
jobs: [...state.jobs, ...action.jobs],
loading: false,
hasMore: action.jobs.length === PAGE,
};
}
export default function App() {
const [state, dispatch] = useReducer(reducer, { jobs: [], loading: false, hasMore: true });
const started = useRef(false);
const inFlight = useRef(false);
const loadMore = useCallback(() => {
if (inFlight.current || !state.hasMore) return;
inFlight.current = true;
dispatch({ type: 'request' });
fetchJobs(state.jobs.length, PAGE).then((jobs) => {
dispatch({ type: 'received', jobs });
inFlight.current = false;
});
}, [state.hasMore, state.jobs.length]);
useEffect(() => {
if (started.current) return;
started.current = true;
loadMore();
}, [loadMore]);
return (
<main className="container">
<h1>Job Board</h1>
<ul className="jobs">
{state.jobs.map((job) => (
<li className="job" key={job.id}>
<p className="job-title">{job.title}</p>
<p className="job-meta">{job.company}</p>
</li>
))}
</ul>
{state.loading && <p className="status">Loading…</p>}
{!state.loading && state.hasMore && <button className="load-more" onClick={loadMore}>Load more</button>}
{!state.loading && !state.hasMore && <p className="status">No more jobs.</p>}
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.