Een eenvoudige to-do app bouwen met React (voor beginners)
In deze tutorial bouw je stap voor stap een eenvoudige maar nette to-do app met React. Je leert niet alleen wat je moet typen, maar ook waarom je het zo doet: componenten, state, events, formulieren, lijst-rendering, immutability, localStorage, en een kleine dosis styling. Alles is gericht op beginners, met echte commando’s die je direct kunt uitvoeren.
Wat je gaat bouwen
Aan het einde heb je een app waarin je:
- Taken kunt toevoegen via een invoerveld
- Taken kunt afvinken als “klaar”
- Taken kunt verwijderen
- Taken kunt filteren (alle / open / klaar)
- Taken kunt opslaan in
localStoragezodat ze blijven staan na verversen
Benodigdheden
Zorg dat je dit hebt geïnstalleerd:
- Node.js (bevat ook
npm) - Een editor zoals VS Code
- Basiskennis van HTML en JavaScript
Controleer je versies:
node -v
npm -v
Als je commando’s als bovenstaande niet herkent, installeer dan eerst Node.js via de officiële website.
Project aanmaken met Vite (aanrader)
Er zijn meerdere manieren om React te starten. Een moderne en snelle manier is Vite. Dit maakt een ontwikkelserver die snel herlaadt.
Maak een nieuw project:
npm create vite@latest todo-react-beginner -- --template react
cd todo-react-beginner
npm install
npm run dev
Je ziet nu een lokale URL in je terminal (meestal http://localhost:5173). Open die in je browser.
Waarom Vite?
- Snelle start en snelle herlaadtijd
- Moderne build pipeline
- Minder “magie” dan sommige oudere setups
Projectstructuur begrijpen
In je project zie je (vereenvoudigd) iets als:
index.html– de pagina waarin je app wordt geladensrc/main.jsx– startpunt dat React aan de pagina koppeltsrc/App.jsx– jouw hoofdcomponentsrc/index.css– globale styling
React werkt met componenten: herbruikbare bouwstenen die UI beschrijven. In React beschrijf je UI als een functie van state: als de state verandert, rendert React opnieuw.
Opruimen: start met een lege App
Open src/App.jsx en vervang de inhoud door dit:
import { useEffect, useMemo, useState } from "react";
import "./App.css";
export default function App() {
return (
<div className="app">
<h1>To-do</h1>
</div>
);
}
Maak ook een bestand src/App.css aan (als het nog niet bestaat), en zet er alvast iets kleins in:
.app {
max-width: 720px;
margin: 40px auto;
padding: 0 16px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
h1 {
margin-bottom: 16px;
}
Stap 1: Data-model voor taken (state)
Een to-do item is meestal minimaal:
- een unieke
id - een
title(tekst) - een boolean
done(klaar of niet)
In React bewaar je dit in state met useState. Voeg dit toe in App.jsx:
import { useEffect, useMemo, useState } from "react";
import "./App.css";
export default function App() {
const [todos, setTodos] = useState([]);
return (
<div className="app">
<h1>To-do</h1>
<pre>{JSON.stringify(todos, null, 2)}</pre>
</div>
);
}
Je ziet nu een lege lijst ([]) in beeld.
Waarom useState?
- React componenten zijn functies.
- Lokale variabelen “onthouden” geen waarden tussen renders.
useStategeeft React een manier om data te bewaren en bij wijzigingen te her-renderen.
Stap 2: Een formulier om taken toe te voegen
We maken een invoerveld en een knop. We hebben ook state nodig voor de huidige inputtekst.
Voeg dit toe in App.jsx:
export default function App() {
const [todos, setTodos] = useState([]);
const [title, setTitle] = useState("");
function handleSubmit(e) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
const newTodo = {
id: crypto.randomUUID(),
title: trimmed,
done: false,
};
setTodos((prev) => [newTodo, ...prev]);
setTitle("");
}
return (
<div className="app">
<h1>To-do</h1>
<form onSubmit={handleSubmit} className="todo-form">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Nieuwe taak..."
aria-label="Nieuwe taak"
/>
<button type="submit">Toevoegen</button>
</form>
<pre>{JSON.stringify(todos, null, 2)}</pre>
</div>
);
}
Belangrijke concepten
1) e.preventDefault()
Een HTML-formulier probeert standaard de pagina te herladen bij submit. In een React-app wil je dat meestal niet, dus voorkom je dat.
2) “Controlled component”
Het inputveld is “controlled” omdat de waarde uit React state komt (value={title}) en elke wijziging via onChange de state bijwerkt. Voordelen:
- Je hebt altijd de actuele waarde in state
- Je kunt makkelijk valideren, resetten, trimmen, etc.
3) Immutability
Let op deze regel:
setTodos((prev) => [newTodo, ...prev]);
We maken een nieuwe array in plaats van de bestaande te veranderen. Dit is belangrijk omdat React wijzigingen vaak detecteert via referenties: een nieuwe array betekent “er is iets veranderd”.
4) Unieke id met crypto.randomUUID()
Dit is een moderne manier om unieke ids te maken in de browser. Mocht je omgeving dit niet ondersteunen, dan kun je tijdelijk Date.now() gebruiken, maar randomUUID is netter.
Stap 3: Takenlijst renderen
Nu vervangen we de pre door een echte lijst.
Onder het formulier:
<ul className="todo-list">
{todos.map((todo) => (
<li key={todo.id} className="todo-item">
{todo.title}
</li>
))}
</ul>
Je App.jsx bevat nu ongeveer:
import { useEffect, useMemo, useState } from "react";
import "./App.css";
export default function App() {
const [todos, setTodos] = useState([]);
const [title, setTitle] = useState("");
function handleSubmit(e) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
const newTodo = {
id: crypto.randomUUID(),
title: trimmed,
done: false,
};
setTodos((prev) => [newTodo, ...prev]);
setTitle("");
}
return (
<div className="app">
<h1>To-do</h1>
<form onSubmit={handleSubmit} className="todo-form">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Nieuwe taak..."
aria-label="Nieuwe taak"
/>
<button type="submit">Toevoegen</button>
</form>
<ul className="todo-list">
{todos.map((todo) => (
<li key={todo.id} className="todo-item">
{todo.title}
</li>
))}
</ul>
</div>
);
}
Waarom is key verplicht?
React rendert lijsten efficiënt. Met key kan React items herkennen tussen renders. Zonder stabiele keys kan React verkeerde items “hergebruiken”, wat bugs kan geven (bijvoorbeeld verkeerde checkbox die aangevinkt blijft).
Stap 4: Een taak afvinken (toggle done)
We voegen een checkbox toe en passen todos aan wanneer je klikt.
Vervang de <li>-inhoud door:
<li key={todo.id} className="todo-item">
<label className="todo-label">
<input
type="checkbox"
checked={todo.done}
onChange={() => {
setTodos((prev) =>
prev.map((t) =>
t.id === todo.id ? { ...t, done: !t.done } : t
)
);
}}
/>
<span className={todo.done ? "done" : ""}>{todo.title}</span>
</label>
</li>
Wat gebeurt hier precies?
checked={todo.done}maakt de checkbox ook “controlled”.- Bij
onChangemaken we een nieuwe array metmap. - Voor het juiste item maken we een nieuw object met
{ ...t, done: !t.done }.
Dit is opnieuw immutability: we wijzigen niet direct t.done = ..., maar maken een kopie.
Stap 5: Een taak verwijderen
We voegen een verwijderknop toe. Pas de <li> aan:
<li key={todo.id} className="todo-item">
<label className="todo-label">
<input
type="checkbox"
checked={todo.done}
onChange={() => {
setTodos((prev) =>
prev.map((t) =>
t.id === todo.id ? { ...t, done: !t.done } : t
)
);
}}
/>
<span className={todo.done ? "done" : ""}>{todo.title}</span>
</label>
<button
type="button"
className="danger"
onClick={() => {
setTodos((prev) => prev.filter((t) => t.id !== todo.id));
}}
aria-label={`Verwijder ${todo.title}`}
>
Verwijderen
</button>
</li>
Waarom type="button"?
In een formulier is een <button> standaard een submitknop. Deze knop staat weliswaar in de lijst, maar kan alsnog onverwacht submitten als hij binnen een <form> valt of door browsergedrag. type="button" maakt het expliciet.
Stap 6: Styling toevoegen (leesbaarheid en UX)
Zet dit in src/App.css:
.app {
max-width: 720px;
margin: 40px auto;
padding: 0 16px;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: #111;
}
.todo-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.todo-form input {
flex: 1;
padding: 10px 12px;
border: 1px solid #cfcfcf;
border-radius: 8px;
font-size: 16px;
}
.todo-form button {
padding: 10px 12px;
border: 1px solid #111;
background: #111;
color: white;
border-radius: 8px;
cursor: pointer;
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 10px;
}
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid #e6e6e6;
border-radius: 10px;
background: #fff;
}
.todo-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.done {
text-decoration: line-through;
color: #666;
}
.danger {
border: 1px solid #b00020;
background: white;
color: #b00020;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
}
.danger:hover {
background: #fff0f2;
}
Stap 7: Componenten maken (opruimen van App.jsx)
Nu App.jsx groeit, is het handig om delen te splitsen in componenten. Dit is niet verplicht, maar het maakt je code leesbaarder en herbruikbaar.
We maken twee componenten:
TodoFormvoor het toevoegenTodoItemvoor één taak
Je kunt dit in hetzelfde bestand doen (prima voor beginners), of in aparte bestanden. We doen het in hetzelfde bestand om het simpel te houden.
Vervang App.jsx door:
import { useEffect, useMemo, useState } from "react";
import "./App.css";
function TodoForm({ title, setTitle, onSubmit }) {
return (
<form onSubmit={onSubmit} className="todo-form">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Nieuwe taak..."
aria-label="Nieuwe taak"
/>
<button type="submit">Toevoegen</button>
</form>
);
}
function TodoItem({ todo, onToggle, onDelete }) {
return (
<li className="todo-item">
<label className="todo-label">
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
<span className={todo.done ? "done" : ""}>{todo.title}</span>
</label>
<button
type="button"
className="danger"
onClick={() => onDelete(todo.id)}
aria-label={`Verwijder ${todo.title}`}
>
Verwijderen
</button>
</li>
);
}
export default function App() {
const [todos, setTodos] = useState([]);
const [title, setTitle] = useState("");
function handleSubmit(e) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
const newTodo = {
id: crypto.randomUUID(),
title: trimmed,
done: false,
};
setTodos((prev) => [newTodo, ...prev]);
setTitle("");
}
function handleToggle(id) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
}
function handleDelete(id) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
return (
<div className="app">
<h1>To-do</h1>
<TodoForm title={title} setTitle={setTitle} onSubmit={handleSubmit} />
<ul className="todo-list">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</ul>
</div>
);
}
Waarom props?
TodoItem weet niets over de globale lijst. Dat is goed: het component is “dom” (presentational). Het krijgt data (todo) en callbacks (onToggle, onDelete). De state blijft in App, wat de “bron van waarheid” is.
Stap 8: Filteren (alle / open / klaar)
We voegen een filter toe zodat je kunt kiezen welke taken je ziet.
Filter-state toevoegen
In App:
const [filter, setFilter] = useState("all");
We willen een afgeleide lijst maken op basis van todos en filter. Daarvoor is useMemo handig: het berekent alleen opnieuw als de invoer verandert.
Voeg toe:
const visibleTodos = useMemo(() => {
if (filter === "open") return todos.filter((t) => !t.done);
if (filter === "done") return todos.filter((t) => t.done);
return todos;
}, [todos, filter]);
Filterknoppen in de UI
Plaats onder het formulier:
<div className="filters" role="group" aria-label="Filters">
<button
type="button"
className={filter === "all" ? "active" : ""}
onClick={() => setFilter("all")}
>
Alle
</button>
<button
type="button"
className={filter === "open" ? "active" : ""}
onClick={() => setFilter("open")}
>
Open
</button>
<button
type="button"
className={filter === "done" ? "active" : ""}
onClick={() => setFilter("done")}
>
Klaar
</button>
</div>
En gebruik visibleTodos in plaats van todos:
{visibleTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} onDelete={handleDelete} />
))}
Styling voor filters
Voeg toe aan App.css:
.filters {
display: flex;
gap: 8px;
margin: 10px 0 16px;
}
.filters button {
padding: 8px 10px;
border-radius: 999px;
border: 1px solid #d0d0d0;
background: white;
cursor: pointer;
}
.filters button.active {
border-color: #111;
background: #111;
color: white;
}
Waarom useMemo?
Zonder useMemo werkt het ook. Maar useMemo maakt duidelijk: “dit is afgeleide data”. Het kan nuttig zijn als je lijst groot wordt. Voor beginners is het vooral leerzaam: je ziet het verschil tussen brondata (todos) en afgeleide data (visibleTodos).
Stap 9: Statistieken tonen (totaal, open, klaar)
Dit is een kleine toevoeging die je app “af” laat voelen.
Bereken aantallen:
const stats = useMemo(() => {
const total = todos.length;
const done = todos.filter((t) => t.done).length;
const open = total - done;
return { total, open, done };
}, [todos]);
Toon in de UI, bijvoorbeeld boven de lijst:
<p className="stats">
Totaal: <strong>{stats.total}</strong> · Open: <strong>{stats.open}</strong> · Klaar:{" "}
<strong>{stats.done}</strong>
</p>
Styling:
.stats {
margin: 0 0 10px;
color: #333;
}
Stap 10: Opslaan in localStorage (blijft na refresh)
Zonder opslag is alles weg bij verversen. localStorage is een eenvoudige browseropslag voor kleine hoeveelheden data.
1) Laden bij starten
Je wilt bij de eerste render proberen taken te laden. Dit kan met een “lazy initializer” in useState, zodat het maar één keer gebeurt:
const [todos, setTodos] = useState(() => {
const raw = localStorage.getItem("todos");
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed;
} catch {
return [];
}
});
Waarom zo?
localStorage.getItemis snel, maar je wilt het niet bij elke render doen.JSON.parsekan falen als de data corrupt is.- We controleren of het resultaat een array is.
2) Opslaan bij elke wijziging
Gebruik useEffect om te reageren op veranderingen in todos:
useEffect(() => {
localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]);
Belangrijk: wat is useEffect?
useEffectdraait na het renderen.- Het is bedoeld voor “side effects”: dingen buiten React om, zoals opslag, netwerkrequests, timers, DOM-manipulatie.
- Door
[todos]als dependency mee te geven, draait het effect alleen wanneertodosverandert.
Volledige App.jsx (met alles)
Hier is een complete versie die alles combineert. Vergelijk dit met jouw bestand en pas aan waar nodig.
import { useEffect, useMemo, useState } from "react";
import "./App.css";
function TodoForm({ title, setTitle, onSubmit }) {
return (
<form onSubmit={onSubmit} className="todo-form">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Nieuwe taak..."
aria-label="Nieuwe taak"
/>
<button type="submit">Toevoegen</button>
</form>
);
}
function TodoItem({ todo, onToggle, onDelete }) {
return (
<li className="todo-item">
<label className="todo-label">
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
<span className={todo.done ? "done" : ""}>{todo.title}</span>
</label>
<button
type="button"
className="danger"
onClick={() => onDelete(todo.id)}
aria-label={`Verwijder ${todo.title}`}
>
Verwijderen
</button>
</li>
);
}
export default function App() {
const [todos, setTodos] = useState(() => {
const raw = localStorage.getItem("todos");
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed;
} catch {
return [];
}
});
const [title, setTitle] = useState("");
const [filter, setFilter] = useState("all");
useEffect(() => {
localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]);
function handleSubmit(e) {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) return;
const newTodo = {
id: crypto.randomUUID(),
title: trimmed,
done: false,
};
setTodos((prev) => [newTodo, ...prev]);
setTitle("");
}
function handleToggle(id) {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
}
function handleDelete(id) {
setTodos((prev) => prev.filter((t) => t.id !== id));
}
const visibleTodos = useMemo(() => {
if (filter === "open") return todos.filter((t) => !t.done);
if (filter === "done") return todos.filter((t) => t.done);
return todos;
}, [todos, filter]);
const stats = useMemo(() => {
const total = todos.length;
const done = todos.filter((t) => t.done).length;
const open = total - done;
return { total, open, done };
}, [todos]);
return (
<div className="app">
<h1>To-do</h1>
<TodoForm title={title} setTitle={setTitle} onSubmit={handleSubmit} />
<div className="filters" role="group" aria-label="Filters">
<button
type="button"
className={filter === "all" ? "active" : ""}
onClick={() => setFilter("all")}
>
Alle
</button>
<button
type="button"
className={filter === "open" ? "active" : ""}
onClick={() => setFilter("open")}
>
Open
</button>
<button
type="button"
className={filter === "done" ? "active" : ""}
onClick={() => setFilter("done")}
>
Klaar
</button>
</div>
<p className="stats">
Totaal: <strong>{stats.total}</strong> · Open: <strong>{stats.open}</strong> · Klaar:{" "}
<strong>{stats.done}</strong>
</p>
<ul className="todo-list">
{visibleTodos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</ul>
{visibleTodos.length === 0 && (
<p className="empty">Geen taken in deze filter.</p>
)}
</div>
);
}
Voeg nog styling toe voor de lege melding:
.empty {
margin-top: 12px;
color: #666;
}
Veelgemaakte fouten en hoe je ze voorkomt
1) State direct muteren
Fout:
todos.push(newTodo);
setTodos(todos);
Waarom fout?
todosblijft dezelfde array-referentie.- React kan updates missen of onvoorspelbaar gedrag vertonen.
Goed:
setTodos((prev) => [newTodo, ...prev]);
2) key gebruiken als index
Fout:
{todos.map((todo, index) => (
<TodoItem key={index} ... />
))}
Waarom fout?
- Als je items verwijdert of sorteert, verschuiven indexen.
- React kan dan “verkeerde” componenten matchen.
Goed:
- Gebruik een stabiele id zoals
todo.id.
3) Formulier submit herlaadt pagina
Als je preventDefault vergeet, lijkt het alsof je app “reset”. Dat is gewoon de pagina die herlaadt.
Extra uitbreidingen (oefeningen)
Als je verder wilt, probeer dit:
- Bewerk een taak: dubbelklik op de titel om te editen.
- Verwijder alle voltooide taken: knop “Ruim op”.
- Validatie: toon een melding als iemand een lege taak toevoegt.
- Sortering: open taken bovenaan, voltooide onderaan.
- Datum/tijd: voeg
createdAttoe en toon die in de UI.
Build maken voor productie
Als je je app wilt “bouwen” (minify en optimaliseren):
npm run build
Om lokaal te testen wat je build doet:
npm run preview
Samenvatting
Je hebt nu een complete beginners-to-do app in React gebouwd met:
useStatevoor statebeheer- Controlled inputs voor formulieren
- Lijsten renderen met
mapen stabielekeys - Immutability met
mapenfilter useMemovoor afgeleide data (filtering en statistieken)useEffect+localStoragevoor opslag
Als je wilt, kan ik ook een versie maken met aparte bestanden (components/TodoForm.jsx, components/TodoItem.jsx) en een iets strakkere mappenstructuur, of je helpen met een “edit mode” voor taken.