Installation der Pakete
Dependencies
npm install react-router-dom bootstrap uuid axios react-icons
DevDependencies
npm install sass -D
Configuration von Vite
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
css: {
preprocessorOptions: {
scss: {
quietDeps: true, // Unix
silenceDeprecations: [
'color-functions',
'global-builtin',
'import',
'legacy-js-api',
],
},
},
},
});
React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- @vitejs/plugin-react uses Babel (or oxc when used in rolldown-vite) for Fast Refresh
- @vitejs/plugin-react-swc uses SWC for Fast Refresh
React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see this documentation.
Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the TS template for information on how to integrate TypeScript and typescript-eslint in your project.
Products Store mit useContext und useReducer
TableProducts hält den Tabellen-State nicht mehr lokal in der Komponente. Stattdessen gibt es einen ausgelagerten Store aus Context + Reducer. So können Kind-Komponenten wie RowProduct den State lesen und Aktionen auslösen, ohne Callbacks per Props durchzureichen (kein Prop Drilling).
Dateistruktur
src/stores/products/
initialState.js // Startzustand (Factory)
reducer.js // reine State-Übergänge
ProductsStore.jsx // Context, Provider, Custom Hooks
index.js // öffentliche Exports
Step 1: initialState auslagern
Datei: src/stores/products/initialState.js
Der Startzustand wird als Factory-Funktion geschrieben, nicht als festes Objekt. So kann der Provider die geladenen Produkte (items) übergeben, und Validierung bleibt an einer Stelle.
export const createInitialState = (items) => {
return { items: items || [] };
};
Später wird sie so verwendet:
useReducer(productsReducer, items, createInitialState);
React ruft createInitialState(items) einmal beim ersten Render auf (lazy init). Das dritte Argument von useReducer ist genau dafür da.
Step 2: Reducer auslagern
Datei: src/stores/products/reducer.js
Der Reducer bleibt eine reine Funktion: (state, action) => newState. Keine Side Effects außer einem console.warn für unbekannte Actions.
Action-Typen liegen in einem Objekt PRODUCTS_ACTIONS. Tippfehler fallen so früher auf als bei losen Strings wie 'UPDATE_AMMOUNT'.
export const PRODUCTS_ACTIONS = {
UPDATE_AMOUNT: 'UPDATE_AMOUNT',
UPDATE_PRICE: 'UPDATE_PRICE',
};
Jede Action folgt dem Muster { type, payload }. Der Reducer kopiert den State (...state) und ersetzt nur das betroffene Item über map. Unveränderte Items behalten dieselbe Referenz – das ist wichtig für memo(RowProduct).
Step 3: Context aufteilen (State und Dispatch)
Datei: src/stores/products/ProductsStore.jsx
Best Practice: zwei Contexts, nicht einen.
| Context | Inhalt | Wann re-rendern Kind-Komponenten? |
|---|---|---|
ProductsStateContext |
{ items } |
bei jeder State-Änderung |
ProductsDispatchContext |
dispatch |
praktisch nie (dispatch ist stabil) |
RowProduct braucht nur Aktionen (also dispatch). Würde State und Dispatch in einem Value { state, dispatch } stecken, bekäme jede Zeile bei jeder Eingabe ein neues Objekt und würde neu rendern – memo wäre wirkungslos.
const ProductsStateContext = createContext(null);
const ProductsDispatchContext = createContext(null);
null als Default ist Absicht: so erkennt der Custom Hook, wenn jemand den Store außerhalb des Providers nutzt.
Step 4: Provider mit useReducer bauen
ProductsProvider ist die einzige Stelle, an der useReducer läuft. Er bekommt items (z. B. aus dem Fetch auf der Page) und reicht State sowie dispatch nach unten.
export const ProductsProvider = (props) => {
const { children, items = [] } = props;
const [state, dispatch] = useReducer(
productsReducer,
items,
createInitialState,
);
return (
<ProductsStateContext.Provider value={state}>
<ProductsDispatchContext.Provider value={dispatch}>
{children}
</ProductsDispatchContext.Provider>
</ProductsStateContext.Provider>
);
};
Wichtig: Die Komponente, die useContext aufruft, muss ein Kind des Providers sein – nicht dieselbe Komponente, die den Provider rendert. Deshalb gibt es TableProducts (Provider) und TableProductsView (Consumer).
Step 5: Custom Hooks statt nacktem useContext
Drei Hooks kapseln den Zugriff und werfen einen Fehler, wenn der Provider fehlt:
useProductsState()– liest{ items }useProductsDispatch()– liefertdispatchuseProductsActions()– fertige CallbacksupdateAmount/updatePrice
Komponenten sollen nicht dispatch({ type: '…' }) selbst zusammenbauen. Die Action-Objekte gehören in den Store, analog zu Redux Action Creators.
const updateAmount = useCallback(
(amount, id) => {
dispatch({ type: PRODUCTS_ACTIONS.UPDATE_AMOUNT, payload: { id, amount } });
},
[dispatch],
);
useCallback + stabiles dispatch hält die Funktionsreferenz gleich. Zusammen mit memo(RowProduct) rendern nur Zeilen neu, deren Item-Props sich geändert haben.
Step 6: Barrel-Export
Datei: src/stores/products/index.js
Andere Dateien importieren nur den Ordner, nicht interne Pfade:
import {
ProductsProvider,
useProductsState,
useProductsActions,
} from '../../stores/products';
Reducer und initialState bleiben intern austauschbar, ohne dass TableProducts oder RowProduct ihre Imports ändern müssen.
Step 7: TableProducts an den Store anbinden
TableProducts wrappt die Ansicht mit dem Provider und übergibt die Props als Initial-Items:
const TableProducts = (props) => {
return (
<ProductsProvider items={props.items}>
<TableProductsView />
</ProductsProvider>
);
};
TableProductsView liest den State und berechnet den Gesamtpreis lokal mit useMemo. Der Gesamtpreis ist abgeleiteter State – er gehört nicht in den Reducer, sondern wird aus items berechnet.
Die Handler onHandleAmount / onHandlePrice entfallen. RowProduct holt sie selbst aus dem Store.
Step 8: Prop Drilling in RowProduct entfernen
Vorher: TableProducts → RowProduct → Inputs (Callbacks als Props).
Nachher: RowProduct ruft useProductsActions() auf. Die Zeile bekommt nur noch die Produktdaten als Props (_id, title, stock, price, sku). Die _id bindet den Input-Wert an das richtige Item:
const { updateAmount, updatePrice } = useProductsActions();
const handleAmount = useCallback(
(value) => {
updateAmount(value, _id);
},
[updateAmount, _id],
);
Die Produktdaten bleiben Props (nicht aus dem Context gelesen). Würde jede Zeile items aus dem State-Context lesen, würden alle Zeilen bei jeder Änderung neu rendern.
Datenfluss
TableProductsPage fetch JSON → items
│
▼
TableProducts ProductsProvider(items)
│
├── TableProductsView useProductsState() → Tabelle + Total
│
└── RowProduct useProductsActions() → UPDATE_AMOUNT / UPDATE_PRICE
│
▼
productsReducer neuer items-Array → Context → UI
Kurz: warum diese Aufteilung?
initialState.js– Startzustand und Validierung, unabhängig von Reactreducer.js– alle State-Übergänge testbar ohne KomponentenProductsStore.jsx– React-Anbindung (Context, Provider, Hooks)- zwei Contexts – Zeilen, die nur dispatchen, re-rendern nicht unnötig
- Custom Hooks – klare API, Fehler wenn der Provider fehlt
- kein Prop Drilling – neue Kind-Komponenten können den Store direkt nutzen
Architektur & Funktionsweise des ProductsProvider
Dieser Store implementiert ein Optimistic UI mit entkoppeltem, gepuffertem Backend-Sync (Debounce & Merge).
UI Input (z. B. Tippen im Input-Feld)
│
├──▶ 1. dispatch(...) ──▶ State aktualisiert sich SOFORT (UI reagiert flüssig)
│
└──▶ 2. schedulePatch(...) ──▶ Änderungen sammeln & Timer starten (400 ms)
│
(nach 400 ms Ruhe)
│
▼
flushPatch(...) ──▶ HTTP PATCH an API
1. Kernkonzept: schedulePatch vs. flushPatch
Beim Bearbeiten von Feldern (wie Preis oder Menge) feuert die Eingabe bei jedem Tastendruck (onChange). Würde man jedes Mal sofort einen API-Request senden, würde das Backend mit HTTP-Aufrufen überflutet werden.
schedulePatch(id, patch) – Das Sammelbecken & der Timer
Zweck: Nimmt Änderungen entgegen, führt sie pro Produkt zusammen (mergen) und setzt den Countdown zurück (debouncen).
- Merge (
pendingPatches):
- Tippt der Nutzer schnell hintereinander Menge
5und Preis19.99, merkt sich die Map:{ stock: 5, price: 19.99 }.
- Timer-Reset (
persistTimers):
- Läuft bereits ein Timer für dieses Produkt, wird er mit
clearTimeoutgestoppt. - Ein neuer 400ms-Timer startet. Erst wenn der Nutzer 400 ms lang nichts mehr tippt, läuft der Timer ab.
flushPatch(id) – Der Ausführer
Zweck: Holt die gesammelten Änderungen ab und sendet den eigentlichen API-Request.
- Puffer leeren: Holt das zusammengefasste Patch-Objekt aus
pendingPatches.currentund löscht es sofort aus der Map (verhindert doppelte Requests). - Timer aufräumen: Entfernt eventuell noch vorhandene Timer-Referenzen.
- API-Call (
patchProduct): Sendet den minimalen Patch an den Server. - Erfolg: Aktualisiert das Produkt im State mit den offiziellen Serverdaten (
SET_ITEM). - Fehlerfall (Rollback): Schlägt der Request fehl, wird der Fehler gesetzt und via
loadProducts()der alte/gültige Serverzustand nachgeladen (Rollback des optimistischen UI-Updates).
2. Der Unmount-Cleanup (useEffect)
Verlässt der Nutzer die Seite oder wird der Provider abgebaut, greift die Cleanup-Funktion:
useEffect(() => {
return () => {
// 1. Laufende Timer sofort stoppen
timers.forEach((timer) => clearTimeout(timer));
timers.clear();
// 2. Ungespeicherte Änderungen sofort absenden ("Flush on Unmount")
patches.forEach((patch, id) => {
if (patch && Object.keys(patch).length > 0) {
patchProduct(id, patch).catch(() => {});
}
});
patches.clear();
};
}, []);
- Verhindert Datenverlust, wenn der Nutzer kurz nach einer Eingabe navigiert.
3. Die 3-Context-Architektur
Der Store teilt die Verantwortlichkeiten in drei separate Contexte auf, um unnötige Re-Renders im Komponentenbaum vollständig zu unterbinden:
| Context | Hook | Inhalt | Re-Render Verhalten |
|---|---|---|---|
ProductsStateContext |
useProductsState() |
state (Items, Loading, Error) |
Rendert nur neu, wenn sich der State ändert (z. B. Listen-Komponenten). |
ProductsDispatchContext |
useProductsDispatch() |
dispatch |
Rendert nie neu (Referenz bleibt über gesamten App-Lebenszyklus stabil). |
ProductsActionsContext |
useProductsActions() |
Methoden (updatePrice, addProduct, etc.) |
Rendert nie neu, da alle Actions mit useCallback und stabilen Abhängigkeiten memoisiert sind. |
4. Übersicht der Action-Methoden
-
updateAmount(amount, id)&updatePrice(price, id): -
Optimistisch & Debounced: Ändern sofort den lokalen State via
dispatchund reichen die Daten anschedulePatchweiter. -
addProduct(product),saveProduct(id, product),removeProduct(id): -
Pessimistisch & Direkt: Warten direkt auf die API-Antwort (
async/await) und updaten erst nach erfolgreichem Server-Response den Reducer-State. -
loadProducts(): -
Lädt die komplette Produktliste manuell neu (z. B. nach einem Rollback oder Pull-to-Refresh).
useLayoutEffect vs. useEffect
Der Hauptunterschied liegt im Timing der Ausführung relativ zum Browser-Rendering:
| Eigenschaft | useLayoutEffect |
useEffect |
|---|---|---|
| Ausführungsart | Synchron (blockiert das Painting) | Asynchron / Deferiert (nach dem Painting) |
| Zeitpunkt | Direkt nach den DOM-Mutationen, bevor der Browser den Bildschirm aktualisiert | Nachdem der Browser die Pixel auf den Bildschirm gezeichnet hat |
| Haupt-Use-Case | DOM-Messungen (getBoundingClientRect), visuelle DOM-Mutationen zur Vermeidung von Layout Shifts / Flackern |
Data Fetching, Subscriptions, Logging, Storage-Zugriffe |
| Performance-Impact | Kann UI-Ruckler verursachen, wenn Berechnungen zu lange dauern | Keine Blockierung der UI-Darstellung |
Analyse deines Codes:
-
**
useLayoutEffectfürsetAuthToken(state.token)**: -
Zweck hier: Der Token (z. B. in Axios-Headern oder API-Clients) wird synchron gesetzt, noch bevor Kindkomponenten in ihren eigenen
useEffect-Hooks initiale API-Aufrufe ausführen. Dadurch wird ein Race Condition vermieden, bei dem ein Kind-Effekt einen Request ohne gültigen Header losschickt. -
**
useEffectfürpersistAuth(...)**: -
Zweck hier: Das Schreiben in den
localStorage/IndexedDBist ein Side Effect, der die visuelle Darstellung nicht direkt beeinflusst und daher die UI nicht blockieren muss.