Two problems appear as the app grows. First, many components need the same information, such as who is signed in, and passing it as props through every level (called *prop drilling*) is tedious and fragile. Second, the same loading logic gets copied from page to page. React answers the first with context and the second with custom hooks.
Context: share without passing props
import { createContext, useContext, useEffect, useState } from 'react';
import { api, setToken } from '../api.js';
// Context shares a value with every component below it, without passing props through each level.
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [ready, setReady] = useState(false); // false until we have tried to sign in
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
// TEMPORARY: sign in as the demo user from `npm run seed`. Lesson 32 replaces this with real sign-in pages.
const res = await api('/auth/login', { method: 'POST', body: { email: 'demo@example.com', password: 'Demo12345' }, signal: controller.signal });
setToken(res.token);
setUser(res.user);
} catch { /* the pages show their own errors */ }
if (!controller.signal.aborted) setReady(true);
})();
return () => controller.abort();
}, []);
return <AuthContext.Provider value={{ user, ready }}>{children}</AuthContext.Provider>;
}
// A custom hook: a function whose name starts with "use" and that calls other hooks.
export const useAuth = () => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>');
return ctx;
};
createContext(null)makes a context. A provider component puts a value into it, and any component below the provider can read that value, however deep.AuthProviderowns the state (userandready) and offers them throughvalue.childrenis whatever you wrap it around.useAuth()is a custom hook: a function whose name starts withusethat calls other hooks (useContexthere). It reads the context and throws a clear error if you forget to wrap the app in the provider.readyisfalseuntil the first attempt to sign in has finished. Pages can wait for it, so they do not flash a “signed out” screen to someone who is, in fact, signed in.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import { AuthProvider } from './context/AuthContext.jsx';
import './index.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
);
Here AuthProvider wraps App, inside BrowserRouter so that later code can navigate. As before, the effect signs in as the demo user. It is temporary and lives in one place now, rather than in every page, which makes it easy to remove in lesson 32.
Custom hooks: reuse logic
import { useCallback, useEffect, useState } from 'react';
import { api } from '../api.js';
import { useAuth } from '../context/AuthContext.jsx';
// All the "load a list from the API" logic in one place, so a page only says what it wants to show.
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]);
return { jobs, setJobs, loading, error, reload: () => load() };
}
useJobs moves the list loading out of the page. It uses useAuth to wait for the sign-in, useState for the data, loading and error, and useEffect to fetch with an abort controller. It returns what a page needs: jobs, loading, error. A custom hook is just a function, and every component that calls it gets its own copy of the state. They share the *code*, not the *data*.
useCallback wraps load so that the function keeps the same identity between draws. That matters here because load appears in an effect's dependency array: a new function on every draw would make the effect run again and again.
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, setJobs, loading, error } = useJobs();
const changeStatus = (id, status) => setJobs((cur) => cur.map((j) => (j.id === id ? { ...j, status } : j)));
const addJob = (values) => setJobs((cur) => [{ ...values, id: `local-${Date.now()}` }, ...cur]);
return (
<>
<h1>My jobs ({jobs.length})</h1>
<JobForm onSubmit={addJob} />
{loading && <p className="muted">Loading your jobs…</p>}
{error && <p className="error" role="alert">{error}</p>}
{jobs.map((job) => (
<div key={job.id}>
<JobCard job={job} onStatusChange={changeStatus} />
<p className="muted"><Link to={`/jobs/${job.id}`}>Open details</Link></p>
</div>
))}
</>
);
}
Compare this page with lesson 29. It went from about 35 lines of effects and states to a single call, useJobs(). The page now says what to show; the hook knows how to get it. This is the main reason to write a custom hook: make the pages read like a description of the screen.
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../api.js';
import { useAuth } from '../context/AuthContext.jsx';
import StatusBadge from '../components/StatusBadge.jsx';
export default function JobDetailPage() {
const { id } = useParams();
const { ready } = useAuth();
const [job, setJob] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
if (!ready) return undefined; // wait until the temporary sign-in has finished
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]);
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 (
<article className="card">
<h1>{job.role}</h1>
<p>{job.company}{job.location ? ` · ${job.location}` : ''}</p>
<StatusBadge status={job.status} />
{job.notes && <p>{job.notes}</p>}
<p><Link to="/">← Back to my jobs</Link></p>
</article>
);
}
Your task
- Run the app and check that it behaves like lesson 29, with the loading logic now in the hook.
- Add a
useDocumentTitle(title)hook that setsdocument.titlein an effect, and use it in both pages. - In React Developer Tools, find
AuthProviderand its state. - Temporarily remove
AuthProviderfrommain.jsxand read the error thatuseAuththrows.
How to check it worked
- Behaviour is the same as before, with less code in the pages.
useAuthexplains the mistake when the provider is missing.
Summary
- Context shares a value with all components below a provider; use it for rarely changing data like the user.
- A custom hook is a
use...function that reuses stateful logic. Each caller gets its own state. - Wrap functions in
useCallbackwhen they are dependencies of an effect.