FRONTEND · React

React: consume the API

Scaffold a React app with Vite, wire up React Router, fetch the todo list from your deployed API and build the list / show / create / edit / delete flow with hooks.

Reactv19.3© Arthur Bernier Jrrewritten · verified26 code blocks1011 words

Prerequisites#

  • A deployed Todos API from one of the backend guides. Every backend guide serves the same contract, below.
  • Node.js 22.22 or newer (React Router 8's minimum; Node 24 is the current LTS).
  • A terminal and an editor. Nothing else is installed globally: npm create downloads the scaffolder on demand.

The Todos contract#

What every backend serves; no trailing slashes:

RequestResponse
GET /health{"ok":true}
GET /todos[{id,subject,details}] ordered by id
GET /todos/:idobject or 404 {"error":"not found"}
POST /todos {subject,details}201 object
PUT /todos/:id {subject,details}200 object
DELETE /todos/:id204
blank/missing fields400 {"error":"…"}

Objects contain exactly id (integer), subject, details. CORS allows any origin.

Pick any backend#

All eight are live and interchangeable:

Every one serves the same contract, so the VITE_API_URL variable below can point at any of them. Each repo ships a check.mjs you can run against your own deploy: node check.mjs https://your-api.up.railway.app.

Setup#

  • Scaffold the project with Vite's React template and move into it:
SH
npm create vite@latest todofront -- --template reactcd todofrontnpm install
  • Install the router:
SH
npm install react-router
  • Tell the app where the API lives. Vite exposes any variable prefixed with VITE_ to the browser through import.meta.env. The value is the API's origin only: no path, no trailing slash. The code adds /todos itself.
ENV
# .env.local  (git-ignored by the Vite template)VITE_API_URL=https://api-production-02380.up.railway.app

For a deployed build, put the same line in a committed .env.production instead; Vite reads it during npm run build, and the URL is public, not a secret.

  • Start the dev server with npm run dev and open http://localhost:5173. You should see the Vite + React starter page.

Setting Up React Router#

React Router has three modes (declarative, data and framework). This walkthrough uses declarative mode: <BrowserRouter>, <Routes>, <Route>, <Link> and the hooks useParams / useNavigate. It is the smallest API surface and it is what the 2021 version used.

Everything is imported from the single react-router package. The old react-router-dom package was removed in v8.

Wrap the app in the router in the entry file, src/main.jsx:

JSX
import { StrictMode } from "react";import { createRoot } from "react-dom/client";import { BrowserRouter } from "react-router";import "./index.css";import App from "./App.jsx"; createRoot(document.getElementById("root")).render(  <StrictMode>    {/* Wrap App so it and every child can use the router */}    <BrowserRouter>      <App />    </BrowserRouter>  </StrictMode>);

Setting Up Our Files#

  • In src create a components folder for small pieces of UI and a pages folder for components that act as routes.

Create the following components#

src/components/Post.jsx

JSX
export default function Post() {  return <h1>Post</h1>;}

src/pages/AllPosts.jsx

JSX
export default function AllPosts() {  return <h1>All posts</h1>;}

src/pages/SinglePost.jsx

JSX
export default function SinglePost() {  return <h1>Post</h1>;}

src/pages/Form.jsx

JSX
export default function Form() {  return <h1>Form</h1>;}

Importing our components#

Bring the pages into App.jsx and set up four client-side routes:

  • / renders every todo in AllPosts
  • /post/:id renders one todo in SinglePost
  • /new renders Form to create a todo
  • /edit/:id renders Form to edit a todo

src/App.jsx

JSX
import { useState, useEffect } from "react";import { Routes, Route, Link, useNavigate } from "react-router"; import AllPosts from "./pages/AllPosts.jsx";import SinglePost from "./pages/SinglePost.jsx";import Form from "./pages/Form.jsx"; // Read the API origin from .env.local (see Setup); fall back to a local server.const API = import.meta.env.VITE_API_URL ?? "http://localhost:8080"; export default function App() {  ////////////////////  // Style objects  ////////////////////  const h1 = { textAlign: "center", margin: "10px" };   ///////////////  // State  ///////////////  const [posts, setPosts] = useState([]);   //////////////  // Functions  //////////////   //////////////  // Effects  //////////////   //////////////////////////  // Returned JSX  //////////////////////////  return (    <div className="App">      <h1 style={h1}>My Todo List</h1>      <Routes>        <Route path="/" element={<AllPosts posts={posts} />} />        <Route path="/post/:id" element={<SinglePost posts={posts} />} />        <Route path="/new" element={<Form />} />        <Route path="/edit/:id" element={<Form />} />      </Routes>    </div>  );}

Getting our todos#

Write a getTodos function that fetches the list from the API, then call it from an effect that runs once after the first render.

Two details the React docs insist on: check response.ok before parsing, and use an ignore flag so that if the component unmounts (or Strict Mode runs the effect twice in development) a late response cannot overwrite newer state.

src/App.jsx — fragment: the Functions and Effects sections inside App()

JSX
// fragment: goes inside App(), replacing the empty Functions and Effects sections//////////////// Functions////////////// // Fetch the list of todos from the APIasync function getTodos() {  const response = await fetch(`${API}/todos`);  if (!response.ok) throw new Error(`GET ${API}/todos failed: ${response.status}`);  const data = await response.json();  setPosts(data);} //////////////// Effects////////////// // Load the list once when the app mountsuseEffect(() => {  let ignore = false;  fetch(`${API}/todos`)    .then((response) => response.json())    .then((data) => {      if (!ignore) setPosts(data);    });  return () => {    ignore = true;  };}, []);

Rendering our todos#

App already passes posts to AllPosts, so render them.

src/pages/AllPosts.jsx

JSX
import Post from "../components/Post.jsx"; export default function AllPosts({ posts }) {  // One Post component per todo; the id is the stable key  return posts.map((post) => <Post post={post} key={post.id} />);}

Define what one post looks like.

src/components/Post.jsx

JSX
import { Link } from "react-router"; export default function Post({ post }) {  const div = {    textAlign: "center",    border: "3px solid",    margin: "10px auto",    width: "80%",  };  return (    <div style={div}>      <Link to={`/post/${post.id}`}>        <h1>{post.subject}</h1>      </Link>      <h2>{post.details}</h2>    </div>  );}

SinglePost component#

Read the id from the URL with useParams, find the matching todo, and handle the moment before the list has loaded.

src/pages/SinglePost.jsx

JSX
import { Link, useParams } from "react-router"; export default function SinglePost({ posts }) {  const { id } = useParams(); // URL params are strings  const post = posts.find((p) => String(p.id) === id);   const div = {    textAlign: "center",    border: "3px solid green",    width: "80%",    margin: "30px auto",  };   // The list is fetched after the first render, so there is a moment with no post yet  if (!post) return <p style={{ textAlign: "center" }}>Loading…</p>;   return (    <div style={div}>      <h1>{post.subject}</h1>      <h2>{post.details}</h2>      <Link to="/">        <button>Go Back</button>      </Link>    </div>  );}

You can now click through to a single todo.

Setting up our form#

One benefit of React is reusability. The same form serves /new and /edit/:id. It takes three props:

  • initialTodo: the object that seeds the form state
  • handleSubmit: called with the form data on submit
  • buttonLabel: the text on the submit button

src/pages/Form.jsx

JSX
import { useState } from "react";import { useNavigate } from "react-router"; export default function Form({ initialTodo, handleSubmit, buttonLabel }) {  const navigate = useNavigate();   // Seed the form with the initial todo  const [formData, setFormData] = useState(initialTodo);   // Standard controlled-input change handler  function handleChange(event) {    setFormData({ ...formData, [event.target.name]: event.target.value });  }   async function onSubmit(event) {    event.preventDefault(); // no full-page form post    await handleSubmit(formData);    navigate("/"); // back to the list  }   return (    <form onSubmit={onSubmit}>      <input type="text" name="subject" value={formData.subject} onChange={handleChange} />      <input type="text" name="details" value={formData.details} onChange={handleChange} />      <input type="submit" value={buttonLabel} />    </form>  );}

Creating a todo#

Back in App.jsx: add a function that POSTs the new todo, an empty todo object, the props on the /new route, and a button that links to the form. (These are fragments; the finished App.jsx is at the bottom of the page.)

JSX
// fragment: inside App()// State & other variablesconst nullTodo = { subject: "", details: "" }; // Functionsasync function addTodo(newTodo) {  await fetch(`${API}/todos`, {    method: "POST",    headers: { "Content-Type": "application/json" },    body: JSON.stringify(newTodo),  });  await getTodos(); // refresh the list}
JSX
<Route  path="/new"  element={<Form initialTodo={nullTodo} handleSubmit={addTodo} buttonLabel="create todo" />}/>
JSX
const button = { backgroundColor: "navy", color: "whitesmoke", display: "block", margin: "auto" };
JSX
<h1 style={h1}>My Todo List</h1><Link to="/new">  <button style={button}>Create New Todo</button></Link>

Editing todos#

The edit screen is the route /edit/:id. A small wrapper reads the id, finds the todo, and renders the same Form with the update function. Because the id lives in the URL, refreshing the edit page still works.

src/App.jsx — fragment

JSX
// fragment: inside App(), with the other functionsasync function updateTodo(todo) {  await fetch(`${API}/todos/${todo.id}`, {    method: "PUT",    headers: { "Content-Type": "application/json" },    body: JSON.stringify(todo),  });  await getTodos();}
JSX
<Route  path="/edit/:id"  element={<EditForm posts={posts} handleSubmit={updateTodo} />}/>

src/pages/EditForm.jsx

JSX
import { useParams } from "react-router";import Form from "./Form.jsx"; export default function EditForm({ posts, handleSubmit }) {  const { id } = useParams();  const todo = posts.find((p) => String(p.id) === id);  if (!todo) return <p style={{ textAlign: "center" }}>Loading…</p>;  // key forces a fresh Form (and fresh state) when the id changes  return <Form key={todo.id} initialTodo={todo} handleSubmit={handleSubmit} buttonLabel="update todo" />;}

Add an edit link to the single-post view.

src/pages/SinglePost.jsx — fragment: the JSX inside the div

JSX
<h1>{post.subject}</h1><h2>{post.details}</h2><Link to={`/edit/${post.id}`}>  <button>Edit</button></Link><Link to="/">  <button>Go Back</button></Link>

Deleting a todo#

Last piece: a delete function in App, passed to SinglePost, wired to a button.

src/App.jsx — fragment

JSX
// fragment: inside App()const navigate = useNavigate(); // at the top of App, with the other hooks async function deleteTodo(todo) {  await fetch(`${API}/todos/${todo.id}`, { method: "DELETE" });  await getTodos();  navigate("/");}
JSX
<Route path="/post/:id" element={<SinglePost posts={posts} deleteTodo={deleteTodo} />} />

src/pages/SinglePost.jsx (final version)

JSX
import { Link, useParams } from "react-router"; export default function SinglePost({ posts, deleteTodo }) {  const { id } = useParams();  const post = posts.find((p) => String(p.id) === id);   const div = {    textAlign: "center",    border: "3px solid green",    width: "80%",    margin: "30px auto",  };   if (!post) return <p style={{ textAlign: "center" }}>Loading…</p>;   return (    <div style={div}>      <h1>{post.subject}</h1>      <h2>{post.details}</h2>      <Link to={`/edit/${post.id}`}>        <button>Edit</button>      </Link>      <button onClick={() => deleteTodo(post)}>Delete</button>      <Link to="/">        <button>Go Back</button>      </Link>    </div>  );}

The finished App.jsx#

All the fragments above, in place:

src/App.jsx (final version)

JSX
import { useState, useEffect } from "react";import { Routes, Route, Link, useNavigate } from "react-router"; import AllPosts from "./pages/AllPosts.jsx";import SinglePost from "./pages/SinglePost.jsx";import Form from "./pages/Form.jsx";import EditForm from "./pages/EditForm.jsx"; // Read the API origin from .env.local (see Setup); fall back to a local server.const API = import.meta.env.VITE_API_URL ?? "http://localhost:8080"; export default function App() {  ////////////////////  // Style objects  ////////////////////  const h1 = { textAlign: "center", margin: "10px" };  const button = { backgroundColor: "navy", color: "whitesmoke", display: "block", margin: "auto" };   ///////////////  // State & other variables  ///////////////  const [posts, setPosts] = useState([]);  const nullTodo = { subject: "", details: "" };  const navigate = useNavigate();   //////////////  // Functions  //////////////  async function getTodos() {    const response = await fetch(`${API}/todos`);    if (!response.ok) throw new Error(`GET ${API}/todos failed: ${response.status}`);    const data = await response.json();    setPosts(data);  }   async function addTodo(newTodo) {    await fetch(`${API}/todos`, {      method: "POST",      headers: { "Content-Type": "application/json" },      body: JSON.stringify(newTodo),    });    await getTodos();  }   async function updateTodo(todo) {    await fetch(`${API}/todos/${todo.id}`, {      method: "PUT",      headers: { "Content-Type": "application/json" },      body: JSON.stringify(todo),    });    await getTodos();  }   async function deleteTodo(todo) {    await fetch(`${API}/todos/${todo.id}`, { method: "DELETE" });    await getTodos();    navigate("/");  }   //////////////  // Effects  //////////////  useEffect(() => {    let ignore = false;    fetch(`${API}/todos`)      .then((response) => response.json())      .then((data) => {        if (!ignore) setPosts(data);      });    return () => {      ignore = true;    };  }, []);   //////////////////////////  // Returned JSX  //////////////////////////  return (    <div className="App">      <h1 style={h1}>My Todo List</h1>      <Link to="/new">        <button style={button}>Create New Todo</button>      </Link>      <Routes>        <Route path="/" element={<AllPosts posts={posts} />} />        <Route path="/post/:id" element={<SinglePost posts={posts} deleteTodo={deleteTodo} />} />        <Route          path="/new"          element={<Form initialTodo={nullTodo} handleSubmit={addTodo} buttonLabel="create todo" />}        />        <Route path="/edit/:id" element={<EditForm posts={posts} handleSubmit={updateTodo} />} />      </Routes>    </div>  );}

Full CRUD against your API, in React. Run npm run build and Vite writes a static dist/ folder you can deploy anywhere.

The finished example#

The code on this page, built exactly as written, lives at github.com/arthurbernierjr/dtc-react and runs at dtc-react-production.up.railway.app. It is pointed at the Python / Django REST framework API (https://api-production-02380.up.railway.app); to point it at any other backend from the table above, change VITE_API_URL in .env.production and rebuild.

See also