← Terug naar tutorials

Een To-Do App bouwen met React (voor gevorderden)

reactto-do appjavascripthooksstate managementlocalstoragefrontend developmentwebontwikkeling

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:


Vereisten

Installeer:

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:


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?


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:


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:

  1. cancelQueries: voorkomt dat een lopende fetch je optimistische state overschrijft.
  2. snapshot: bewaar vorige data om te kunnen rollbacken.
  3. setQueryData: update de cache direct voor instant UI feedback.
  4. onError: rollback naar snapshot.
  5. 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:

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:

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:

Belangrijk concept: queryKey moet alle inputs bevatten die de response beïnvloeden. Anders krijg je cache collisions.


15) Foutafhandeling en UX

Gevorderde apps onderscheiden:

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:

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)


Samenvatting

Je hebt nu een gevorderde To-Do app opgezet met:

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.