Een To-Do App bouwen met React (voor gevorderden)
Deze tutorial bouwt een To-Do applicatie met React die verder gaat dan de standaard “lijstje met checkboxen”. Je implementeert een schaalbare architectuur, offline ondersteuning, performante rendering, robuuste validatie, tests, en een nette ontwikkelworkflow. We gebruiken moderne React (function components, hooks), TypeScript, Vite, React Router, TanStack Query, Zustand, en een JSON API via json-server. Je kunt onderdelen vervangen door eigen voorkeuren, maar de principes blijven gelijk.
Doel en scope
We bouwen een app met:
- CRUD voor taken (aanmaken, lezen, bijwerken, verwijderen)
- Filters (status, zoekterm, labels)
- Sortering en paginering (client-side en server-side varianten)
- Optimistische updates met rollback bij fouten
- Offline-first gedrag met cache en “replay” van mutaties (basis)
- Formulier-validatie
- Performante rendering (memoization, virtualisatie)
- Tests (unit, integratie, e2e)
- Nette projectstructuur en conventies
Vereisten
Installeer:
- Node.js (bij voorkeur LTS)
- npm of pnpm (voorbeelden hieronder met npm)
- Git
Controleer versies:
node -v
npm -v
git --version
1) Project opzetten met Vite + React + TypeScript
Maak een nieuw project:
npm create vite@latest todo-advanced -- --template react-ts
cd todo-advanced
npm install
npm run dev
Open de devserver-URL die Vite toont.
Aanvullende dependencies
We voegen routing, state, data fetching, validatie, en tooling toe:
npm install react-router-dom @tanstack/react-query zustand zod
npm install -D @types/node eslint prettier eslint-config-prettier eslint-plugin-react-hooks
Optioneel voor virtualisatie (grote lijsten):
npm install @tanstack/react-virtual
2) API simuleren met json-server
Voor gevorderde patronen (optimistische updates, query invalidation) is een echte API handig. We gebruiken json-server.
Installeer:
npm install -D json-server
Maak db.json in de root:
{
"todos": [
{
"id": "t_1",
"title": "Architectuur uitdenken",
"completed": false,
"labels": ["werk"],
"createdAt": "2026-02-14T10:00:00.000Z",
"updatedAt": "2026-02-14T10:00:00.000Z"
}
]
}
Start de server (bijvoorbeeld op poort 3001):
npx json-server --watch db.json --port 3001
Test met curl:
curl http://localhost:3001/todos
Scripts toevoegen
Pas package.json aan:
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"api": "json-server --watch db.json --port 3001"
}
}
Start in twee terminals:
npm run api
npm run dev
3) Projectstructuur voor schaalbaarheid
Een veelgemaakte fout is alles in components/ gooien. Voor groei is “feature-first” vaak beter.
Voorstel:
src/
app/
App.tsx
router.tsx
queryClient.ts
features/
todos/
api/
todosApi.ts
components/
TodoList.tsx
TodoListItem.tsx
TodoEditor.tsx
TodoFilters.tsx
hooks/
useTodos.ts
model/
todo.ts
todoSchemas.ts
store/
todosUiStore.ts
pages/
TodosPage.tsx
shared/
api/
http.ts
components/
Button.tsx
Input.tsx
lib/
dates.ts
id.ts
main.tsx
Waarom dit werkt:
- features/ groepeert alles rond één domein (todos)
- shared/ bevat generieke utilities en UI
- app/ bevat app-brede configuratie (router, query client)
4) Basis: types, schema’s en domeinmodel
src/features/todos/model/todo.ts
export type TodoId = string;
export type Todo = {
id: TodoId;
title: string;
completed: boolean;
labels: string[];
createdAt: string; // ISO
updatedAt: string; // ISO
};
export type CreateTodoInput = {
title: string;
labels?: string[];
};
export type UpdateTodoInput = Partial<Pick<Todo, "title" | "completed" | "labels">>;
Validatie met Zod: todoSchemas.ts
Zod is nuttig om API-responses te valideren (niet alleen forms). Dit voorkomt dat je app “stil” stuk gaat bij onverwachte data.
import { z } from "zod";
export const todoSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
completed: z.boolean(),
labels: z.array(z.string()).default([]),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime()
});
export const todosSchema = z.array(todoSchema);
export type Todo = z.infer<typeof todoSchema>;
5) HTTP laag met fetch + foutenmodel
src/shared/api/http.ts
We maken een kleine wrapper om consistente fouten en JSON parsing te krijgen.
export class HttpError extends Error {
status: number;
body: unknown;
constructor(message: string, status: number, body: unknown) {
super(message);
this.status = status;
this.body = body;
}
}
type Json = Record<string, unknown> | unknown[] | string | number | boolean | null;
export async function http<T extends Json>(
input: RequestInfo | URL,
init?: RequestInit
): Promise<T> {
const res = await fetch(input, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {})
}
});
const text = await res.text();
const body = text ? (JSON.parse(text) as unknown) : null;
if (!res.ok) {
throw new HttpError(`HTTP ${res.status}`, res.status, body);
}
return body as T;
}
Waarom text eerst?
- Sommige endpoints sturen lege responses;
res.json()faalt dan. - Met
text()kun je veilig conditioneel parsen.
6) Todos API module
src/features/todos/api/todosApi.ts
import { http } from "../../../shared/api/http";
import { todosSchema, todoSchema, type Todo } from "../model/todoSchemas";
import type { CreateTodoInput, UpdateTodoInput, TodoId } from "../model/todo";
const API_URL = "http://localhost:3001";
export async function fetchTodos(): Promise<Todo[]> {
const data = await http<unknown>(`${API_URL}/todos`);
return todosSchema.parse(data);
}
export async function createTodo(input: CreateTodoInput): Promise<Todo> {
const now = new Date().toISOString();
const payload = {
id: `t_${crypto.randomUUID()}`,
title: input.title,
completed: false,
labels: input.labels ?? [],
createdAt: now,
updatedAt: now
};
const data = await http<unknown>(`${API_URL}/todos`, {
method: "POST",
body: JSON.stringify(payload)
});
return todoSchema.parse(data);
}
export async function updateTodo(id: TodoId, patch: UpdateTodoInput): Promise<Todo> {
const payload = {
...patch,
updatedAt: new Date().toISOString()
};
const data = await http<unknown>(`${API_URL}/todos/${id}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
return todoSchema.parse(data);
}
export async function deleteTodo(id: TodoId): Promise<void> {
await http<unknown>(`${API_URL}/todos/${id}`, { method: "DELETE" });
}
Opmerking: crypto.randomUUID() werkt in moderne browsers. Voor oudere omgevingen kun je een fallback maken.
7) TanStack Query configureren
TanStack Query geeft je caching, refetching, status flags, retries, en vooral: een consistente manier om server-state te beheren.
src/app/queryClient.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10_000,
refetchOnWindowFocus: false,
retry: 1
},
mutations: {
retry: 0
}
}
});
src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/router";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>
);
8) Routing
src/app/router.tsx
import { createBrowserRouter } from "react-router-dom";
import { TodosPage } from "../features/todos/pages/TodosPage";
export const router = createBrowserRouter([
{
path: "/",
element: <TodosPage />
}
]);
9) UI state scheiden van server state (Zustand)
Server state (todos) komt uit TanStack Query. UI state (filters, sortering, zoekterm) hoort lokaal of in een kleine store.
src/features/todos/store/todosUiStore.ts
import { create } from "zustand";
type TodosUiState = {
search: string;
showCompleted: boolean;
label: string | null;
setSearch: (v: string) => void;
setShowCompleted: (v: boolean) => void;
setLabel: (v: string | null) => void;
};
export const useTodosUiStore = create<TodosUiState>((set) => ({
search: "",
showCompleted: true,
label: null,
setSearch: (search) => set({ search }),
setShowCompleted: (showCompleted) => set({ showCompleted }),
setLabel: (label) => set({ label })
}));
Waarom dit onderscheid belangrijk is:
- Server state heeft caching, invalidation, synchronisatie nodig.
- UI state is vaak vluchtig en hoeft niet naar de server.
- Door ze te scheiden voorkom je “prop drilling” en onnodige refetches.
10) Hooks voor todos: queries en mutaties
src/features/todos/hooks/useTodos.ts
We maken een hook die query + mutaties bundelt en optimistische updates doet.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Todo } from "../model/todoSchemas";
import type { CreateTodoInput, UpdateTodoInput, TodoId } from "../model/todo";
import { createTodo, deleteTodo, fetchTodos, updateTodo } from "../api/todosApi";
const todosKey = ["todos"] as const;
export function useTodos() {
const qc = useQueryClient();
const todosQuery = useQuery({
queryKey: todosKey,
queryFn: fetchTodos
});
const createMutation = useMutation({
mutationFn: (input: CreateTodoInput) => createTodo(input),
onSuccess: () => {
qc.invalidateQueries({ queryKey: todosKey });
}
});
const updateMutation = useMutation({
mutationFn: ({ id, patch }: { id: TodoId; patch: UpdateTodoInput }) => updateTodo(id, patch),
onMutate: async ({ id, patch }) => {
await qc.cancelQueries({ queryKey: todosKey });
const prev = qc.getQueryData<Todo[]>(todosKey);
if (prev) {
qc.setQueryData<Todo[]>(
todosKey,
prev.map((t) => (t.id === id ? { ...t, ...patch, updatedAt: new Date().toISOString() } : t))
);
}
return { prev };
},
onError: (_err, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(todosKey, ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: todosKey });
}
});
const deleteMutation = useMutation({
mutationFn: (id: TodoId) => deleteTodo(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: todosKey });
const prev = qc.getQueryData<Todo[]>(todosKey);
if (prev) qc.setQueryData<Todo[]>(todosKey, prev.filter((t) => t.id !== id));
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) qc.setQueryData(todosKey, ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: todosKey });
}
});
return {
todosQuery,
createMutation,
updateMutation,
deleteMutation
};
}
Dieper: optimistische updates correct doen
Belangrijke stappen:
- cancelQueries: voorkomt dat een lopende fetch je optimistische state overschrijft.
- snapshot: bewaar vorige data om te kunnen rollbacken.
- setQueryData: update de cache direct voor instant UI feedback.
- onError: rollback naar snapshot.
- onSettled: invalideer om server-truth te herladen.
Dit patroon is cruciaal bij apps waar latency merkbaar is.
11) De pagina en componenten
src/features/todos/pages/TodosPage.tsx
import { useMemo } from "react";
import { useTodos } from "../hooks/useTodos";
import { useTodosUiStore } from "../store/todosUiStore";
import { TodoEditor } from "../components/TodoEditor";
import { TodoFilters } from "../components/TodoFilters";
import { TodoList } from "../components/TodoList";
export function TodosPage() {
const { todosQuery, createMutation, updateMutation, deleteMutation } = useTodos();
const { search, showCompleted, label } = useTodosUiStore();
const filtered = useMemo(() => {
const list = todosQuery.data ?? [];
const s = search.trim().toLowerCase();
return list
.filter((t) => (showCompleted ? true : !t.completed))
.filter((t) => (label ? t.labels.includes(label) : true))
.filter((t) => (s ? t.title.toLowerCase().includes(s) : true))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}, [todosQuery.data, search, showCompleted, label]);
return (
<div style={{ maxWidth: 860, margin: "0 auto", padding: 24 }}>
<h1>To-Do (gevorderd)</h1>
<TodoEditor
isBusy={createMutation.isPending}
onCreate={(input) => createMutation.mutate(input)}
/>
<TodoFilters />
{todosQuery.isLoading && <p>Bezig met laden…</p>}
{todosQuery.isError && <p>Fout bij laden van taken.</p>}
<TodoList
todos={filtered}
isMutating={updateMutation.isPending || deleteMutation.isPending}
onToggle={(id, completed) => updateMutation.mutate({ id, patch: { completed } })}
onRename={(id, title) => updateMutation.mutate({ id, patch: { title } })}
onDelete={(id) => deleteMutation.mutate(id)}
/>
</div>
);
}
Formuliercomponent met Zod validatie: TodoEditor.tsx
We doen hier “pragmatische” validatie zonder extra form library. Voor grotere forms kun je later react-hook-form toevoegen.
import { useMemo, useState } from "react";
import { z } from "zod";
const schema = z.object({
title: z.string().trim().min(1, "Titel is verplicht").max(120, "Maximaal 120 tekens"),
labels: z
.string()
.optional()
.transform((v) => (v ?? "").trim())
});
export function TodoEditor(props: {
isBusy: boolean;
onCreate: (input: { title: string; labels?: string[] }) => void;
}) {
const [title, setTitle] = useState("");
const [labelsText, setLabelsText] = useState("");
const [touched, setTouched] = useState(false);
const parsed = useMemo(() => schema.safeParse({ title, labels: labelsText }), [title, labelsText]);
const error = touched && !parsed.success ? parsed.error.issues[0]?.message : null;
function submit(e: React.FormEvent) {
e.preventDefault();
setTouched(true);
if (!parsed.success) return;
const labels =
parsed.data.labels.length > 0
? parsed.data.labels.split(",").map((s) => s.trim()).filter(Boolean)
: [];
props.onCreate({ title: parsed.data.title, labels });
setTitle("");
setLabelsText("");
setTouched(false);
}
return (
<form onSubmit={submit} style={{ display: "grid", gap: 8, marginBottom: 16 }}>
<div style={{ display: "grid", gap: 6 }}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => setTouched(true)}
disabled={props.isBusy}
placeholder="Bijv. Tests schrijven"
style={{ width: "100%", padding: 8 }}
/>
</label>
{error && <small style={{ color: "crimson" }}>{error}</small>}
</div>
<label>
Labels (komma-gescheiden)
<input
value={labelsText}
onChange={(e) => setLabelsText(e.target.value)}
disabled={props.isBusy}
placeholder="werk, privé"
style={{ width: "100%", padding: 8 }}
/>
</label>
<button type="submit" disabled={props.isBusy || !parsed.success} style={{ padding: 10 }}>
Toevoegen
</button>
</form>
);
}
Filters: TodoFilters.tsx
import { useTodosUiStore } from "../store/todosUiStore";
export function TodoFilters() {
const { search, showCompleted, label, setSearch, setShowCompleted, setLabel } = useTodosUiStore();
return (
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 16 }}>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Zoek…"
style={{ flex: 1, padding: 8 }}
/>
<label style={{ display: "flex", gap: 6, alignItems: "center" }}>
<input
type="checkbox"
checked={showCompleted}
onChange={(e) => setShowCompleted(e.target.checked)}
/>
Toon voltooid
</label>
<select value={label ?? ""} onChange={(e) => setLabel(e.target.value || null)}>
<option value="">Alle labels</option>
<option value="werk">werk</option>
<option value="privé">privé</option>
</select>
</div>
);
}
Lijst en itemcomponenten
TodoList.tsx:
import type { Todo } from "../model/todoSchemas";
import { TodoListItem } from "./TodoListItem";
export function TodoList(props: {
todos: Todo[];
isMutating: boolean;
onToggle: (id: string, completed: boolean) => void;
onRename: (id: string, title: string) => void;
onDelete: (id: string) => void;
}) {
if (props.todos.length === 0) return <p>Geen taken gevonden.</p>;
return (
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 8 }}>
{props.todos.map((t) => (
<TodoListItem
key={t.id}
todo={t}
disabled={props.isMutating}
onToggle={props.onToggle}
onRename={props.onRename}
onDelete={props.onDelete}
/>
))}
</ul>
);
}
TodoListItem.tsx:
import { memo, useState } from "react";
import type { Todo } from "../model/todoSchemas";
export const TodoListItem = memo(function TodoListItem(props: {
todo: Todo;
disabled: boolean;
onToggle: (id: string, completed: boolean) => void;
onRename: (id: string, title: string) => void;
onDelete: (id: string) => void;
}) {
const { todo } = props;
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(todo.title);
function save() {
const next = draft.trim();
if (!next) return;
props.onRename(todo.id, next);
setIsEditing(false);
}
return (
<li
style={{
border: "1px solid #ddd",
borderRadius: 10,
padding: 12,
display: "grid",
gap: 8
}}
>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
<input
type="checkbox"
checked={todo.completed}
disabled={props.disabled}
onChange={(e) => props.onToggle(todo.id, e.target.checked)}
/>
{!isEditing ? (
<strong style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
{todo.title}
</strong>
) : (
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
disabled={props.disabled}
style={{ flex: 1, padding: 8 }}
/>
)}
{!isEditing ? (
<button onClick={() => setIsEditing(true)} disabled={props.disabled}>
Bewerken
</button>
) : (
<button onClick={save} disabled={props.disabled}>
Opslaan
</button>
)}
<button onClick={() => props.onDelete(todo.id)} disabled={props.disabled}>
Verwijderen
</button>
</div>
<small style={{ color: "#555" }}>
Labels: {todo.labels.length ? todo.labels.join(", ") : "—"} • Laatst bijgewerkt:{" "}
{new Date(todo.updatedAt).toLocaleString()}
</small>
</li>
);
});
Waarom memo hier zinvol is
Bij grotere lijsten kan een wijziging (bijv. zoekterm) veel renders veroorzaken. memo voorkomt re-render van items als props gelijk blijven. Let op: als je inline callbacks doorgeeft die elke render veranderen, kan memo minder effectief zijn. In dit voorbeeld komen callbacks uit de pagina, maar blijven functioneel gelijk genoeg; voor maximale winst kun je useCallback toevoegen.
12) Performance: virtualisatie bij grote lijsten
Bij duizenden items is map naar DOM-nodes duur. Virtualisatie rendert alleen wat zichtbaar is.
Installeer:
npm install @tanstack/react-virtual
Voorbeeld (conceptueel) in TodoList.tsx: je vervangt de <ul> door een scrollcontainer en gebruikt useVirtualizer. Dit is vooral nuttig als je taken veel content hebben. Houd rekening met variabele hoogtes; begin met vaste itemhoogte of meet dynamisch.
13) Offline-first basis: cache + bewaren in localStorage
TanStack Query bewaart cache in geheugen. Voor “tab sluiten en later terug” kun je persist toevoegen. Een eenvoudige aanpak:
- schrijf query data bij naar
localStorage - laad die bij start in als initialData
Eenvoudige persist (bewust minimalistisch)
In useTodos.ts kun je initialData gebruiken:
const STORAGE_KEY = "todos_cache_v1";
En in je query:
const todosQuery = useQuery({
queryKey: todosKey,
queryFn: fetchTodos,
initialData: () => {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return undefined;
try {
return JSON.parse(raw) as Todo[];
} catch {
return undefined;
}
}
});
En in TodosPage of een effect:
import { useEffect } from "react";
// ...
useEffect(() => {
if (todosQuery.data) {
localStorage.setItem("todos_cache_v1", JSON.stringify(todosQuery.data));
}
}, [todosQuery.data]);
Beperkingen:
- Geen conflict-resolutie
- Geen echte mutatie-queue
- Kan verouderde data tonen
Voor serieuzere offline ondersteuning wil je een persist-laag met “mutation queue” en background sync. Toch is dit al nuttig voor snelle perceived performance en basis offline lezen.
14) Server-side filtering en paginering (uitbreiding)
json-server ondersteunt query parameters zoals _page, _limit, q, en sortering _sort, _order.
Voorbeeld:
curl "http://localhost:3001/todos?_page=1&_limit=10&_sort=updatedAt&_order=desc&q=tests"
Je kunt je fetchTodos uitbreiden:
- queryKey afhankelijk maken van filters
- queryFn bouwen met URLSearchParams
Belangrijk concept: queryKey moet alle inputs bevatten die de response beïnvloeden. Anders krijg je cache collisions.
15) Foutafhandeling en UX
Gevorderde apps onderscheiden:
- loading (eerste load)
- fetching (achtergrond refetch)
- mutating (wijziging bezig)
- error met bruikbare boodschap
Je kunt HttpError inspecteren:
import { HttpError } from "../../../shared/api/http";
function getErrorMessage(err: unknown): string {
if (err instanceof HttpError) return `Serverfout (${err.status})`;
if (err instanceof Error) return err.message;
return "Onbekende fout";
}
En in de UI tonen bij todosQuery.error.
16) ESLint en Prettier (basis)
Maak .prettierrc:
{
"semi": true,
"singleQuote": false,
"printWidth": 100
}
Maak .eslintrc.cjs (eenvoudig, afhankelijk van je setup):
module.exports = {
env: { browser: true, es2022: true },
extends: ["eslint:recommended"],
plugins: ["react-hooks"],
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
};
Voeg scripts toe:
npm pkg set scripts.format="prettier -w ."
npm pkg set scripts.lint="eslint ."
Run:
npm run format
npm run lint
17) Tests: unit + integratie
Vitest + Testing Library
Installeer:
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
Maak vitest.config.ts:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"]
}
});
Maak src/test/setup.ts:
import "@testing-library/jest-dom";
Testscript:
npm pkg set scripts.test="vitest"
Voorbeeldtest voor filtering (pure functie is ideaal). Daarom loont het om logica uit componenten te trekken. Maak bijvoorbeeld filterTodos.ts en test die.
18) E2E tests (optioneel maar sterk)
Met Playwright:
npm install -D @playwright/test
npx playwright install
Init:
npx playwright init
Je kunt een test schrijven die:
- app opent
- taak toevoegt
- checkbox togglet
- verwijdert
Voor e2e is het handig om een aparte testdatabase te gebruiken of db.json te resetten in een script.
19) Build en deploy
Build:
npm run build
npm run preview
Deploy naar een statische host (bijv. Netlify, Vercel, GitHub Pages) kan direct met de dist/ output. Let op: je API (json-server) draait lokaal; voor productie heb je een echte backend nodig of een serverless oplossing.
20) Veelvoorkomende valkuilen en best practices
1) Alles in één globale store stoppen
Gebruik TanStack Query voor server state. Een globale store is niet nodig voor data die al uit de server komt.
2) Query keys te grof maken
Als filters/paginering de response veranderen, moeten ze in de queryKey. Anders toont je app “verkeerde” cache.
3) Optimistische updates zonder rollback
Zonder snapshot + rollback krijg je inconsistentie bij fouten. Altijd onMutate + onError overwegen.
4) Validatie alleen in het formulier
Valideer ook API-responses (Zod). Dit maakt fouten sneller zichtbaar en veiliger.
5) Performance pas laat aanpakken
Meet vroeg. Bij grote lijsten: memo, useMemo, en eventueel virtualisatie.
Volgende uitbreidingen (aanbevolen)
- Meerdere lijsten/projecten (route:
/lists/:id) - Labels beheren in een aparte feature met eigen API
- Realtime sync via WebSockets
- “Undo” na delete (tijdelijke snackbar + delayed mutation)
- Toegangsbeheer (auth) en per-user data
- Conflict-resolutie (bij offline edits)
Samenvatting
Je hebt nu een gevorderde To-Do app opgezet met:
- een duidelijke feature-first structuur
- een API-laag met consistente fouten
- schema-validatie met Zod
- TanStack Query voor caching en mutaties met optimistische updates
- Zustand voor UI state
- aandacht voor performance, offline basis, en testbaarheid
Als je wilt, kan ik ook een variant uitwerken met een echte backend (bijv. Express + SQLite of PostgreSQL) of met een meer formele “clean architecture” scheiding (use-cases, repositories, dependency injection) binnen React.