377 lines
15 KiB
Markdown
377 lines
15 KiB
Markdown
# Installation der Pakete
|
||
|
||
## Dependencies
|
||
|
||
```bash
|
||
npm install react-router-dom bootstrap uuid axios react-icons
|
||
```
|
||
|
||
## DevDependencies
|
||
|
||
```bash
|
||
npm install sass -D
|
||
```
|
||
|
||
## Configuration von Vite
|
||
|
||
```js
|
||
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](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) 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](https://react.dev/learn/react-compiler/installation).
|
||
|
||
## 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](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) 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.
|
||
|
||
```js
|
||
export const createInitialState = (items) => {
|
||
return { items: items || [] };
|
||
};
|
||
```
|
||
|
||
Später wird sie so verwendet:
|
||
|
||
```js
|
||
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'`.
|
||
|
||
```js
|
||
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.
|
||
|
||
```js
|
||
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.
|
||
|
||
```jsx
|
||
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:
|
||
|
||
1. `useProductsState()` – liest `{ items }`
|
||
2. `useProductsDispatch()` – liefert `dispatch`
|
||
3. `useProductsActions()` – fertige Callbacks `updateAmount` / `updatePrice`
|
||
|
||
Komponenten sollen nicht `dispatch({ type: '…' })` selbst zusammenbauen. Die Action-Objekte gehören in den Store, analog zu Redux Action Creators.
|
||
|
||
```js
|
||
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:
|
||
|
||
```js
|
||
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:
|
||
|
||
```jsx
|
||
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:
|
||
|
||
```js
|
||
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 React
|
||
- **`reducer.js`** – alle State-Übergänge testbar ohne Komponenten
|
||
- **`ProductsStore.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_).
|
||
|
||
1. **Merge (`pendingPatches`):**
|
||
|
||
- Tippt der Nutzer schnell hintereinander Menge `5` und Preis `19.99`, merkt sich die Map: `{ stock: 5, price: 19.99 }`.
|
||
|
||
2. **Timer-Reset (`persistTimers`):**
|
||
|
||
- Läuft bereits ein Timer für dieses Produkt, wird er mit `clearTimeout` gestoppt.
|
||
- 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.
|
||
|
||
1. **Puffer leeren:** Holt das zusammengefasste Patch-Objekt aus `pendingPatches.current` und löscht es sofort aus der Map (verhindert doppelte Requests).
|
||
2. **Timer aufräumen:** Entfernt eventuell noch vorhandene Timer-Referenzen.
|
||
3. **API-Call (`patchProduct`):** Sendet den minimalen Patch an den Server.
|
||
4. **Erfolg:** Aktualisiert das Produkt im State mit den offiziellen Serverdaten (`SET_ITEM`).
|
||
5. **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:
|
||
|
||
```javascript
|
||
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 `dispatch` und reichen die Daten an `schedulePatch` weiter.
|
||
|
||
- **`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:**
|
||
|
||
* **`useLayoutEffect` für `setAuthToken(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.
|
||
|
||
|
||
* **`useEffect` für `persistAuth(...)**`:
|
||
* *Zweck hier:* Das Schreiben in den `localStorage`/`IndexedDB` ist ein Side Effect, der die visuelle Darstellung nicht direkt beeinflusst und daher die UI nicht blockieren muss. |