Node.js REST API · Delivery

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.

Advanced 7 min Lesson 24 of 25
Before you start:CORSReact: useEffect
0/25 in Node.js REST API
On this page · 7 min · Advanced

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#

JavaScript Illustrative
// vite.config.js
export default defineConfig({
  plugins: [react()],
  server: { proxy: { '/api': 'http://localhost:3000' } },
});

Code example: An API client and a courses page#

JSXsrc/CoursesPage.jsx Runnable
// ── 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>
  );
}
Expected output
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#

  1. The Vite proxy forwards /api to Express, so development needs no CORS setup.
  2. api() adds credentials, JSON headers and converts error responses into ApiError with status and field errors.
  3. A 401 redirects to login and remembers the current page.
  4. The effect cancels the previous request with AbortController when the level changes.
  5. 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#

fetch in every component

Base URLs, headers and error handling get copied everywhere and diverge. Centralise in one client.

Treating fetch errors as only network errors

fetch does not reject on 404 or 500. Check res.ok and throw yourself.

Forgetting credentials: "include"

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?
Call fn(signal) inside useEffect and store the result in state.
Show solution
JSX Illustrative
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#

React Integration quizMultiple choice Question 1 of 3

Why use Vite’s server.proxy in development?

Score so far: 0/0

Want more? Practise all Node.js REST API MCQs · Node.js REST API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.