React builds interfaces from components: functions that take props and return JSX, an HTML-like syntax that compiles to JavaScript. Instead of changing the DOM yourself, you describe what the page should look like for the current data, and React updates the DOM when the data changes. Vite creates a ready-to-run project with a fast dev server.
useState stores data that changes, such as the list of students or the text in an input. Calling the setter re-renders the component. Never mutate state (students.push); create a new array or object instead. Inputs whose value comes from state and whose onChange updates it are called controlled inputs. Values you can compute from state, such as a filtered list, should be computed during render rather than stored.
Lists need a stable key on each item so React can tell rows apart. useEffect runs code after rendering, which suits loading data; return a cleanup function (here, aborting the fetch) for when the component goes away. Development mode with <StrictMode> runs effects twice on purpose to reveal missing cleanups. npm run build produces static files you can deploy anywhere.
npm create vite@latest results-app -- --template react
cd results-app
npm install
npm run dev # opens http://localhost:5173 and reloads as you save[
{ "id": 1, "name": "Amina Hassan", "score": 88 },
{ "id": 2, "name": "Juma Said", "score": 42 },
{ "id": 3, "name": "Neema Kimaro", "score": 71 }
]import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>,
);// A component is a function that takes props and returns JSX.
function StudentRow({ student }) {
const passed = student.score >= 30;
return (
<li>
{student.name} — {student.score} {passed ? "✔" : <strong>(needs support)</strong>}
</li>
);
}
export default function StudentList({ students }) {
if (students.length === 0) return <p>No students match.</p>;
return (
<ul>
{students.map((s) => (
<StudentRow key={s.id} student={s} /> // key lets React track each row
))}
</ul>
);
}import { useEffect, useState } from "react";
import StudentList from "./StudentList.jsx";
export default function App() {
const [students, setStudents] = useState([]);
const [status, setStatus] = useState("loading"); // "loading" | "ready" | "error"
const [query, setQuery] = useState("");
const [name, setName] = useState("");
const [score, setScore] = useState("");
// Runs after the first render; the cleanup cancels the request if the component goes away.
useEffect(() => {
const controller = new AbortController();
fetch("/students.json", { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => {
setStudents(data);
setStatus("ready");
})
.catch((err) => {
if (err.name !== "AbortError") setStatus("error");
});
return () => controller.abort();
}, []);
function handleSubmit(event) {
event.preventDefault();
if (name.trim().length < 2 || score === "") return;
// Never mutate state: build a new array so React sees the change.
setStudents([...students, { id: Date.now(), name: name.trim(), score: Number(score) }]);
setName("");
setScore("");
}
// Derived values are computed during render, not stored in state.
const visible = students.filter((s) => s.name.toLowerCase().includes(query.toLowerCase()));
return (
<main>
<h1>Form 4 results</h1>
<form onSubmit={handleSubmit}>
<input aria-label="Name" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input aria-label="Score" type="number" min="0" max="100" value={score} onChange={(e) => setScore(e.target.value)} />
<button>Add</button>
</form>
<input aria-label="Search" placeholder="Search..." value={query} onChange={(e) => setQuery(e.target.value)} />
{status === "loading" && <p>Loading…</p>}
{status === "error" && <p role="alert">Could not load students.</p>}
{status === "ready" && <StudentList students={visible} />}
</main>
);
}Key points
- Components are functions of props that return JSX; React updates the DOM for you.
- Keep changing data in
useState, always replace rather than mutate it, and derive the rest during render. - Give list items a stable
key, and load data inuseEffectwith a cleanup function.
Exercise
Extend the app: show the number of students and the class average, add a Remove button on every row, and save the list to localStorage so it survives a reload (load the JSON file only when nothing is saved yet).
Show solution
Try the exercise yourself first — then compare your approach with this one.
useState(() => loadSaved() ?? []) reads localStorage only on the first render. The fetch effect runs only while the status is "loading", that is when nothing was saved. A second effect saves the list whenever it changes. The average is derived from students during render, and the updater form setStudents(prev => ...) always works on the latest list.
import { useEffect, useState } from "react";
const STORAGE_KEY = "results-app.students";
// Read saved students once, when the component first renders.
function loadSaved() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? null;
} catch {
return null;
}
}
export default function App() {
const [students, setStudents] = useState(() => loadSaved() ?? []);
const [status, setStatus] = useState(() => (loadSaved() ? "ready" : "loading"));
const [name, setName] = useState("");
const [score, setScore] = useState("");
useEffect(() => {
if (status !== "loading") return; // already loaded from localStorage
const controller = new AbortController();
fetch("/students.json", { signal: controller.signal })
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
.then((data) => {
setStudents(data);
setStatus("ready");
})
.catch((err) => err.name !== "AbortError" && setStatus("error"));
return () => controller.abort();
}, [status]);
// Save whenever the list changes (after the initial load).
useEffect(() => {
if (status === "ready") localStorage.setItem(STORAGE_KEY, JSON.stringify(students));
}, [students, status]);
function add(event) {
event.preventDefault();
if (name.trim().length < 2 || score === "") return;
setStudents((prev) => [...prev, { id: Date.now(), name: name.trim(), score: Number(score) }]);
setName("");
setScore("");
}
function remove(id) {
setStudents((prev) => prev.filter((s) => s.id !== id));
}
const average = students.length
? (students.reduce((sum, s) => sum + s.score, 0) / students.length).toFixed(1)
: "–";
if (status === "loading") return <p>Loading…</p>;
if (status === "error") return <p role="alert">Could not load students.</p>;
return (
<main>
<h1>Form 4 results</h1>
<p>
{students.length} students · class average <strong>{average}</strong>
</p>
<form onSubmit={add}>
<input aria-label="Name" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input aria-label="Score" type="number" min="0" max="100" value={score} onChange={(e) => setScore(e.target.value)} />
<button>Add</button>
</form>
<ul>
{students.map((s) => (
<li key={s.id}>
{s.name} — {s.score}{" "}
<button type="button" onClick={() => remove(s.id)} aria-label={`Remove ${s.name}`}>
Remove
</button>
</li>
))}
</ul>
</main>
);
}