React Integration
The LearnHub API finally gets its front end. This lesson connects a React (Vite) app to the Express API: a dev proxy instead of CORS headaches, one API client module, and components that handle loading, errors and expired sessions.
On this page · 7 min · Advanced
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Advanced
- Updated
- Oct 28, 2026
Related tutorials
Interview questions
Learning objectives#
- Proxy API calls in development with Vite
- Write one API client that handles JSON, credentials and Problem Details
- Show loading, error and empty states in components
- React to 401 responses by sending users to login
Explanation#
Development: the React app runs on localhost:5173 and the API on localhost:3000. Vite’s server.proxy forwards /api to the API, so the browser sees one origin — no CORS configuration needed locally. Production: serve both under one domain via a reverse proxy, or configure CORS.
One module (src/api/client.js) wraps fetch: base URL from import.meta.env.VITE_API_URL, JSON headers, credentials: "include" for httpOnly cookies, and conversion of error responses (Problem Details) into a thrown ApiError.
Components call small functions (getCourses, enroll) — never fetch directly. That keeps URLs and error handling in one place.
Every data-fetching component handles three states: loading, error (with retry), and data (including empty). Cancel requests with AbortController when the component unmounts.
A 401 means the session expired: redirect to /login (keeping the current page to return to). A 403 shows “not allowed”; a 400 shows the field errors from the problem’s errors array.
Syntax#
// vite.config.js
export default defineConfig({
plugins: [react()],
server: { proxy: { '/api': 'http://localhost:3000' } },
});
Code example: An API client and a courses page#
// ── src/api/client.js ──
const BASE_URL = import.meta.env.VITE_API_URL ?? ''; // '' → same origin (Vite proxy in dev)
export class ApiError extends Error {
constructor(status, problem) {
super(problem.detail ?? problem.error ?? 'Request failed');
this.status = status;
this.errors = problem.errors ?? [];
}
}
export async function api(path, { method = 'GET', body, signal } = {}) {
const res = await fetch(BASE_URL + path, {
method,
signal,
credentials: 'include', // send the httpOnly auth cookie
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
window.location.assign('/login?returnTo=' + encodeURIComponent(window.location.pathname));
throw new ApiError(401, { detail: 'Please sign in again' });
}
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw new ApiError(res.status, data ?? {});
return data;
}
export const getCourses = (level, signal) => api(`/api/v1/courses?level=${level}`, { signal });
export const enroll = courseId => api(`/api/v1/courses/${courseId}/enrollments`, { method: 'POST' });
// ── src/CoursesPage.jsx ──
import { useEffect, useState } from 'react';
export default function CoursesPage() {
const [level, setLevel] = useState('beginner');
const [state, setState] = useState({ status: 'loading', courses: [], error: null });
const [message, setMessage] = useState('');
useEffect(() => {
const controller = new AbortController();
setState(s => ({ ...s, status: 'loading' }));
getCourses(level, controller.signal)
.then(result => setState({ status: 'ready', courses: result.data, error: null }))
.catch(err => { if (err.name !== 'AbortError') setState({ status: 'error', courses: [], error: err.message }); });
return () => controller.abort(); // cancel if level changes or the page unmounts
}, [level]);
async function handleEnroll(course) {
try {
await enroll(course.id);
setMessage(`You are enrolled in ${course.title}`);
} catch (err) {
setMessage(err.status === 409 ? 'You are already enrolled' : err.message);
}
}
return (
<section>
<h1>Courses</h1>
<select value={level} onChange={e => setLevel(e.target.value)}>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
</select>
{message && <p role="status">{message}</p>}
{state.status === 'loading' && <p>Loading courses…</p>}
{state.status === 'error' && <p role="alert">Could not load courses: {state.error}</p>}
{state.status === 'ready' && state.courses.length === 0 && <p>No {level} courses yet.</p>}
{state.status === 'ready' && (
<ul>
{state.courses.map(course => (
<li key={course.id}>
{course.title} — ₹{course.price}
<button onClick={() => handleEnroll(course)}>Enroll</button>
</li>
))}
</ul>
)}
</section>
);
}
The page shows “Loading courses…”, then the beginner courses from the API. Switching the level cancels the previous request and loads the new list. Clicking Enroll shows “You are enrolled in …”, or “You are already enrolled” for a 409. If the session has expired, the API answers 401 and the user is sent to /login?returnTo=/courses.
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- The Vite proxy forwards /api to Express, so development needs no CORS setup.
- api() adds credentials, JSON headers and converts error responses into ApiError with status and field errors.
- A 401 redirects to login and remembers the current page.
- The effect cancels the previous request with AbortController when the level changes.
- The component renders loading, error, empty and list states, and maps 409 to a friendly message.
Real-world use case#
The LearnHub web app has about 40 API functions in src/api/, all built on api(). When the API switched from Bearer tokens to httpOnly cookies, only client.js changed.
Common mistakes#
Base URLs, headers and error handling get copied everywhere and diverge. Centralise in one client.
fetch does not reject on 404 or 500. Check res.ok and throw yourself.
Cross-origin requests do not send cookies without it, so every call looks unauthenticated.
Best practices#
- Vite proxy in development
- One API client module
- Loading / error / empty / data states
- Abort stale requests; handle 401 globally
Remember this#
Dev: Vite proxy /api → same origin, no CORS.
fetch does not throw on 4xx/5xx — check res.ok.
401 → login. 403 → not allowed. 400 → field errors.
Practice exercise#
Your task: Extract the loading logic into a reusable hook useApi(fn, deps) that returns { status, data, error } and aborts on unmount.
Need a hint?
Show solution
import { useEffect, useState } from 'react';
export function useApi(fn, deps) {
const [state, setState] = useState({ status: 'loading', data: null, error: null });
useEffect(() => {
const controller = new AbortController();
setState({ status: 'loading', data: null, error: null });
fn(controller.signal)
.then(data => setState({ status: 'ready', data, error: null }))
.catch(err => { if (err.name !== 'AbortError') setState({ status: 'error', data: null, error: err.message }); });
return () => controller.abort();
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
return state;
}
// usage: const { status, data, error } = useApi(signal => getCourses(level, signal), [level]);
Mini quiz#
Why use Vite’s server.proxy in development?
Want more? Practise all Node.js REST API MCQs · Node.js REST API interview questions
My notes#
Saved in this browser.