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.
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 createdownloads the scaffolder on demand.
The Todos contract#
What every backend serves; no trailing slashes:
| Request | Response |
|---|---|
GET /health | {"ok":true} |
GET /todos | [{id,subject,details}] ordered by id |
GET /todos/:id | object or 404 {"error":"not found"} |
POST /todos {subject,details} | 201 object |
PUT /todos/:id {subject,details} | 200 object |
DELETE /todos/:id | 204 |
| blank/missing fields | 400 {"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:
npm create vite@latest todofront -- --template reactcd todofrontnpm install- Install the router:
npm install react-router- Tell the app where the API lives. Vite exposes any variable prefixed with
VITE_to the browser throughimport.meta.env. The value is the API's origin only: no path, no trailing slash. The code adds/todositself.
# .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 devand 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:
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
srccreate acomponentsfolder for small pieces of UI and apagesfolder for components that act as routes.
Create the following components#
src/components/Post.jsx
export default function Post() { return <h1>Post</h1>;}src/pages/AllPosts.jsx
export default function AllPosts() { return <h1>All posts</h1>;}src/pages/SinglePost.jsx
export default function SinglePost() { return <h1>Post</h1>;}src/pages/Form.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 inAllPosts/post/:idrenders one todo inSinglePost/newrendersFormto create a todo/edit/:idrendersFormto edit a todo
src/App.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()
// 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
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
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
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 statehandleSubmit: called with the form data on submitbuttonLabel: the text on the submit button
src/pages/Form.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.)
// 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}<Route path="/new" element={<Form initialTodo={nullTodo} handleSubmit={addTodo} buttonLabel="create todo" />}/>const button = { backgroundColor: "navy", color: "whitesmoke", display: "block", margin: "auto" };<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
// 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();}<Route path="/edit/:id" element={<EditForm posts={posts} handleSubmit={updateTodo} />}/>src/pages/EditForm.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
<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
// 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("/");}<Route path="/post/:id" element={<SinglePost posts={posts} deleteTodo={deleteTodo} />} />src/pages/SinglePost.jsx (final version)
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)
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