← Terug naar tutorials

Een eenvoudige to-do app bouwen met React (voor beginners)

reactto-do appbeginnersjavascriptfrontendcomponentenstatetutorial

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:


Benodigdheden

Zorg dat je dit hebt geïnstalleerd:

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?


Projectstructuur begrijpen

In je project zie je (vereenvoudigd) iets als:

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:

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?


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:

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?

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:

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?

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?


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?

Goed:

setTodos((prev) => [newTodo, ...prev]);

2) key gebruiken als index

Fout:

{todos.map((todo, index) => (
  <TodoItem key={index} ... />
))}

Waarom fout?

Goed:

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:

  1. Bewerk een taak: dubbelklik op de titel om te editen.
  2. Verwijder alle voltooide taken: knop “Ruim op”.
  3. Validatie: toon een melding als iemand een lege taak toevoegt.
  4. Sortering: open taken bovenaan, voltooide onderaan.
  5. Datum/tijd: voeg createdAt toe 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:

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.