Skip to main content

πŸ”— Connecting Python Backends to React

You've built the API; now let's put a real interface on it. Modern apps run the React frontend and the Python backend as two separate programs that talk over HTTP and JSON. This lesson covers the decoupled architecture, the CORS and authentication plumbing that makes it work, and clean patterns for fetching and caching data in React.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Describe the decoupled architecture and its trade-offs
  • Configure CORS correctly in Django and Flask
  • Implement a JWT authentication flow across React and Python
  • Build a reusable Axios client with request/response interceptors
  • Manage server state in React with React Query (TanStack Query)

Estimated Time: 55–75 minutes  β€’  Difficulty: Intermediate

Hands-on: Wire a React component to a DRF endpoint with an authenticated Axios client and React Query.

In This Lesson

Decoupled Architecture

In a decoupled (or "headless") architecture, the frontend and backend are separate applications. React owns the browser UI; Python owns the data and business logic. They meet at a single, well-defined boundary: an HTTP + JSON API.

Real-world analogy: a restaurant with a kitchen (backend) and a dining room (frontend), connected by waitstaff (the API). The kitchen doesn't care how tables are arranged, and diners don't need to see the stove β€” the waitstaff carry a clear, agreed-upon interface between them.

Decoupled React frontend and Python backend A React frontend of components, state, and an API client exchanges HTTP and JSON with a Python backend of API endpoints, business logic, and a database. Frontend (React) Components State management API client Backend (Python) API endpoints Business logic Database HTTP / JSON
Figure 1 β€” Two independent apps, one contract. The API is the only thing they share.
BenefitsChallenges
Teams work independentlyTwo apps to build, deploy, and monitor
Best tool for each layerAPI contract needs careful design
Scale frontend and backend separatelyCross-origin (CORS) configuration
One API serves web, mobile, and third partiesAuth must work across separate systems

Configuring CORS

Because React (say, http://localhost:3000) and your API (http://localhost:8000) live on different origins, the browser's same-origin policy blocks the requests unless the server explicitly opts in with CORS (Cross-Origin Resource Sharing) headers.

πŸ“– What is an "origin"?

An origin is the combination of scheme + host + port. http://localhost:3000 and http://localhost:8000 differ by port, so they're different origins β€” and CORS applies.

Django

pip install django-cors-headers
# settings.py
INSTALLED_APPS = [
    # ...
    "corsheaders",
]

MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware",   # as high as possible
    "django.middleware.common.CommonMiddleware",
    # ... the rest
]

# Be specific β€” never open to every origin in production.
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "https://yourdomain.com",
]
CORS_ALLOW_CREDENTIALS = True   # only if you use cookies

Flask

# pip install flask-cors
from flask import Flask
from flask_cors import CORS

app = Flask(__name__)
CORS(app, resources={r"/api/*": {"origins": "http://localhost:3000"}})

⚠️ Security note

Allowing all origins (CORS_ALLOW_ALL_ORIGINS = True or Access-Control-Allow-Origin: *) is convenient in early development but a real risk in production. Always pin to the exact origins you trust before you ship.

The JWT Auth Flow

For decoupled apps, JWT (JSON Web Token) authentication is the common choice. The client logs in once, receives a short-lived access token and a longer-lived refresh token, then attaches the access token to each request. When it expires, the refresh token quietly mints a new one.

sequenceDiagram participant R as React participant A as Python API R->>A: POST /api/token/ (username, password) A-->>R: access + refresh tokens R->>A: GET /api/books/ (Authorization: Bearer access) A-->>R: 200 OK + data Note over R,A: access token expires R->>A: POST /api/token/refresh/ (refresh) A-->>R: new access token

Backend (Django + Simple JWT)

pip install djangorestframework-simplejwt
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ],
}

# urls.py
from rest_framework_simplejwt.views import (
    TokenObtainPairView, TokenRefreshView,
)

urlpatterns += [
    path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
]

Frontend auth helper

// auth.js
import axios from "axios";

const API = "http://localhost:8000/api";

export async function login(username, password) {
  const { data } = await axios.post(`${API}/token/`, { username, password });
  localStorage.setItem("access", data.access);
  localStorage.setItem("refresh", data.refresh);
}

export function logout() {
  localStorage.removeItem("access");
  localStorage.removeItem("refresh");
}

export async function refreshAccessToken() {
  const refresh = localStorage.getItem("refresh");
  const { data } = await axios.post(`${API}/token/refresh/`, { refresh });
  localStorage.setItem("access", data.access);
  return data.access;
}

export const isAuthenticated = () => Boolean(localStorage.getItem("access"));

⚠️ Where to store tokens

localStorage is simple and used widely, but it's readable by any JavaScript running on the page, so it's vulnerable to XSS. For higher-security apps, store the refresh token in an httpOnly cookie the browser sends automatically and JavaScript can't read. Whatever you choose, keep access tokens short-lived.

A Reusable Axios Client

Rather than repeat headers and error handling in every call, create one configured Axios instance. Interceptors let you attach the token to every outgoing request and react to auth failures on every response β€” including transparently refreshing an expired access token.

// api/client.js
import axios from "axios";
import { refreshAccessToken, logout } from "../auth";

const apiClient = axios.create({
  baseURL: "http://localhost:8000/api",
  headers: { "Content-Type": "application/json" },
});

// Request: attach the access token if we have one.
apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem("access");
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

// Response: on a 401, try one silent refresh, then retry the request.
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config;
    if (error.response?.status === 401 && !original._retried) {
      original._retried = true;
      try {
        const newToken = await refreshAccessToken();
        original.headers.Authorization = `Bearer ${newToken}`;
        return apiClient(original);        // replay the original request
      } catch {
        logout();
        window.location.assign("/login");  // refresh failed β€” start over
      }
    }
    return Promise.reject(error);
  }
);

export default apiClient;

πŸ’‘ Why interceptors matter

Without them, token handling leaks into every component. With them, your components just call apiClient.get("/books/") and never think about auth headers or refresh logic β€” a clean separation that scales to hundreds of calls.

API Service Modules

Group related endpoints into small service modules. Components import a service instead of talking to Axios directly, so if a URL changes you fix it in one place.

// api/bookService.js
import apiClient from "./client";

const bookService = {
  list: (params) => apiClient.get("/books/", { params }),
  get: (id) => apiClient.get(`/books/${id}/`),
  create: (data) => apiClient.post("/books/", data),
  update: (id, data) => apiClient.put(`/books/${id}/`, data),
  remove: (id) => apiClient.delete(`/books/${id}/`),
};

export default bookService;

A first pass at consuming it with plain hooks looks like this β€” functional, but note how much boilerplate loading/error state it needs:

// components/BookList.jsx
import { useState, useEffect } from "react";
import bookService from "../api/bookService";

export default function BookList() {
  const [books, setBooks] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    bookService
      .list()
      .then((res) => setBooks(res.data.results ?? res.data))
      .catch(() => setError("Failed to load books"))
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <p>Loading…</p>;
  if (error) return <p role="alert">{error}</p>;

  return (
    <ul>
      {books.map((b) => (
        <li key={b.id}>{b.title} β€” {b.author}</li>
      ))}
    </ul>
  );
}

πŸ’‘ Note the pagination shape

DRF's default pagination wraps results in { count, next, previous, results }. The res.data.results ?? res.data above handles both paginated and unpaginated responses gracefully.

Managing Server State

Data from an API isn't ordinary UI state β€” it lives on the server, can go stale, and needs caching, refetching, and deduplication. Hand-rolling all that with useState/useEffect gets repetitive fast. React Query (TanStack Query) was built exactly for this.

πŸ“– Client state vs server state

Client state is owned by the browser (a form's current text, a modal's open/closed flag). Server state is fetched, cached, and can become outdated. Tools like React Query specialize in the second kind so you stop reinventing it.

Setup

npm install @tanstack/react-query
// main.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";

const queryClient = new QueryClient();

export default function Root() {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  );
}

Reading data with useQuery

// hooks/useBooks.js
import { useQuery } from "@tanstack/react-query";
import bookService from "../api/bookService";

export function useBooks() {
  return useQuery({
    queryKey: ["books"],
    queryFn: async () => {
      const res = await bookService.list();
      return res.data.results ?? res.data;
    },
  });
}

// components/BookList.jsx
import { useBooks } from "../hooks/useBooks";

export default function BookList() {
  const { data: books, isLoading, isError } = useBooks();

  if (isLoading) return <p>Loading…</p>;
  if (isError) return <p role="alert">Failed to load books</p>;

  return (
    <ul>
      {books.map((b) => (
        <li key={b.id}>{b.title}</li>
      ))}
    </ul>
  );
}

Writing data with useMutation

After a create/update/delete, invalidate the cached query so the list refetches and stays in sync automatically:

// hooks/useCreateBook.js
import { useMutation, useQueryClient } from "@tanstack/react-query";
import bookService from "../api/bookService";

export function useCreateBook() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (data) => bookService.create(data),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["books"] }),
  });
}

// components/BookForm.jsx
import { useState } from "react";
import { useCreateBook } from "../hooks/useCreateBook";

export default function BookForm() {
  const [title, setTitle] = useState("");
  const createBook = useCreateBook();

  function handleSubmit(e) {
    e.preventDefault();
    createBook.mutate({ title }, { onSuccess: () => setTitle("") });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button type="submit" disabled={createBook.isPending}>
        {createBook.isPending ? "Adding…" : "Add book"}
      </button>
    </form>
  );
}

βœ… What React Query handles for you

Caching, background refetching, loading/error flags, request deduplication, retry on failure, and cache invalidation after writes β€” all the tedious parts of server state, in a few lines per endpoint.

Hands-on Exercise

πŸ‹οΈ Wire a Task List to Your API

Objective: Connect a React component to a DRF /api/tasks/ endpoint end to end, with an authenticated client and React Query.

Instructions:

  1. Configure CORS on the backend to allow http://localhost:3000.
  2. Create an Axios apiClient with a request interceptor that attaches the JWT access token.
  3. Write a taskService with list and create methods.
  4. Build a useTasks query hook and a useCreateTask mutation hook that invalidates ["tasks"] on success.
  5. Render the list and an add-task form; confirm a new task appears without a manual refresh.
πŸ’‘ Hint

The magic that makes the list update after adding a task is queryClient.invalidateQueries({ queryKey: ["tasks"] }) in the mutation's onSuccess. Make sure the query hook and the invalidation use the exact same query key.

βœ… Solution
// api/taskService.js
import apiClient from "./client";

const taskService = {
  list: () => apiClient.get("/tasks/"),
  create: (data) => apiClient.post("/tasks/", data),
};
export default taskService;

// hooks/useTasks.js
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import taskService from "../api/taskService";

export function useTasks() {
  return useQuery({
    queryKey: ["tasks"],
    queryFn: async () => {
      const res = await taskService.list();
      return res.data.results ?? res.data;
    },
  });
}

export function useCreateTask() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (data) => taskService.create(data),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tasks"] }),
  });
}

// components/TaskList.jsx
import { useState } from "react";
import { useTasks, useCreateTask } from "../hooks/useTasks";

export default function TaskList() {
  const { data: tasks, isLoading, isError } = useTasks();
  const createTask = useCreateTask();
  const [title, setTitle] = useState("");

  if (isLoading) return <p>Loading…</p>;
  if (isError) return <p role="alert">Could not load tasks</p>;

  return (
    <div>
      <ul>
        {tasks.map((t) => (
          <li key={t.id}>{t.title}</li>
        ))}
      </ul>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          createTask.mutate({ title }, { onSuccess: () => setTitle("") });
        }}
      >
        <input value={title} onChange={(e) => setTitle(e.target.value)} />
        <button disabled={createTask.isPending}>Add</button>
      </form>
    </div>
  );
}

🎯 Quick Quiz

Question 1: Why do you usually need to configure CORS when a React dev server calls a Python API?

Question 2: In the Axios setup, what is a response interceptor best used for?

Question 3: After a successful create mutation, how does React Query keep the list fresh?

Best Practices

βœ… Do

  • Keep the base URL in an environment variable (import.meta.env.VITE_API_URL), never hard-coded across the app.
  • Centralize auth in Axios interceptors so components stay clean.
  • Use React Query (or SWR) for server state; reserve useState for UI state.
  • Keep access tokens short-lived and refresh them silently.
  • Handle loading and error states in every data-driven component.

⚠️ Don't

  • Don't allow all CORS origins in production.
  • Don't scatter raw fetch/axios calls with duplicated headers through components.
  • Don't store long-lived secrets in localStorage without weighing the XSS risk.
  • Don't assume responses are unpaginated β€” handle DRF's results envelope.

Deployment at a glance

Two common shapes: deploy the two apps separately (React on a static host like Netlify/Vercel, the API on a server), or build React and let the Python app serve the static files. Separate deployment scales each side independently but requires CORS and possibly a custom domain; single-server deployment is simpler to reason about but couples releases.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • A decoupled app is two programs sharing one HTTP/JSON contract β€” flexible, scalable, and reusable across clients.
  • CORS must be configured on the backend to allow your frontend's origin; pin it in production.
  • JWT auth uses a short-lived access token and a refresh token; Axios interceptors attach and refresh them transparently.
  • React Query manages server state β€” caching, refetching, and cache invalidation β€” so you stop hand-rolling loading and error plumbing.

πŸ“š Further Reading

πŸš€ What's Next?

You've closed the loop from database to browser. Next you'll put all three lessons together in the Weekend Project: Python Web Frameworks, building a complete full-stack app end to end.

πŸŽ‰ Nice work!

You can now connect a Python API to a React frontend the way real teams do. On to the weekend build.