Status in der Praxis: Kleine Läden, präzise Auswahlkriterien, Pflanzenverfolger
Ersetzen Sie prop-drilling und die Redux-Zeremonie durch create(), Selectoren, asynchrone Aktionen, Persistenz-/DevTools-Middleware, Slices sowie eine App für das Hydratierungsverfahren.
Ein für Anfänger geeigneter Überblick über Zustand – die React-State-Bibliothek mit zig Millionen wöchentlichen Downloads – sowie ein von Anfang bis Ende erstelltes Beispiel zur Pflanzenpflege.
In dem Moment, in dem jemand endlich sagt, es gäbe eine bessere Methode
Vor Zustand: Was React tatsächlich tut
Webseiten beginnen als HTML-Tags – div, button, h1. Die manuelle Bearbeitung dieser Markup-Strukturen für ein umfangreiches Produkt ist mühsam: Jede Datenänderung bedeutet das Suchen nach Elementen und deren Neuverwenden. Ändert sich die Anmeldeseite? Dann müssen Dutzende Stellen angepasst werden. Wächst der Warenkorb? Nochmals Dutzende Stellen.
React ändert dieses Modell. Komponenten sind Funktionen, die die Benutzeroberfläche anhand der aktuellen Daten beschreiben; die Bibliothek entscheidet, was im DOM angepasst werden muss. Die Entwickler beschreiben, React aktualisiert.
Eine kleine Komponente sieht so aus:
function WelcomeBanner() {
return <h1>Hello, stranger</h1>
}
Diese Funktion gibt JSX zurück – eine HTML-ähnliche Syntax, die von React kompiliert wird, nicht echtes HTML.
Kurzes Glossar für die folgenden Abschnitte:
- JavaScript – die Sprache hinter
console.logundconst x = 5. - npm – der Paket-Installer.
npm install zustandlädt Zustand in das Projekt herunter. - Hooks – Funktionen, deren Namen mit
usebeginnen (useState,useEffect, benutzerdefinierte Store-Hooks). Sie bilden die moderne Schnittstelle für Daten und Effekte in React.
Falls Ihnen diese drei Konzepte klar sind, wird der Rest dieses Leitfadens leichter verständlich sein.
React ist der Koch. Sie geben ihm das Rezept und die Zutaten.
Was „State“ wirklich bedeutet
Der Zustand ist alles, was die Benutzeroberfläche sich merken muss: ob ein Benutzer eingeloggt ist oder nicht, die Größe des Warenkorbs, ob die Seitenleiste geöffnet ist, die URL des Avatars sowie der Text in einem Suchfeld. Einfache Variablen aktualisieren den Bildschirm nicht automatisch. React-Hooks speichern Werte und planen eine erneute Darstellung, wenn sich diese Werte ändern.
Der einfachste Hook ist useState:
import { useState } from 'react'
function Counter() {
const [count, setCount] = useState(0)
// Creates a state variable called count, starting at 0.
// setCount is the only way to change it.
// Every time setCount runs, React re-renders this component.
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
)
}
Lokaler Zustand ist dann vorteilhaft, wenn ein einzelner Komponente den Wert verwaltet. Probleme entstehen, wenn entfernte Komponenten denselben Zustand teilen müssen – wie das Anmeldeformular, der Avatar im Header, die Begrüßung auf dem Dashboard oder das Abmelden in den Einstellungen – ohne direkt miteinander verbunden zu sein.
Eigene useState-Aufrufe kommunizieren nicht miteinander. Der eigentliche Herausforderung besteht darin, Zustände über entfernte Teile der Benutzeroberfläche zu teilen.
Getrennte Zustände, die nicht miteinander kommunizieren können. Das Problem auf einen Blick.
Das Leid durch Prop-Vererbung
Die erste Lösung von React lautet „State nach oben heben“: Man platziert die gemeinsam genutzten Daten beim nächstgelegenen gemeinsamen Vorfahren und übermittelt sie als Props (Funktionparameter) nach unten.
Das funktioniert bei einfachen Strukturen. Es versagt jedoch, wenn user den Weg App → Layout → MainContent → Dashboard → Header → ProfilePic zurücklegen muss. Die Zwischenschichten nutzen die Daten nie; sie leiten sie lediglich weiter. Theme, Warenkorb und Auth-Tokens fügen jeweils weitere Übertragungsschritte durch unbeeinflusste Elternkomponenten hinzu. Refaktorisierungen zerstören dabei unverwandte Dateien.
Context sollte diese Übertragungsschleifen beenden. Ein Provider umhüllt den Baum; die Kindkomponenten rufen useContext auf. Das Problem: Die Verbraucherkomponenten werden oft neu gerendert, sobald sich irgend ein Teil des Context-Werts ändert, was bei häufigen State-Änderungen problematisch ist.
Redux (2015) bot vorhersehbaren externen Zustand sowie Selektoren an, die nur das Neu rendern, was sich geändert hat – und führte außerdem viele Formalitäten ein, deren Wartung für Teams zur Belastung wurde.
Der Bär tritt auf den Plan
Zustand stammt vom Poimandres-Kollektiv von Paul Henschel (ebenfalls hinter React Three Fiber und Jotai). Der Name bedeutet auf Deutsch „Zustand“. Es gibt ein Maskottchen in Form eines Bären. Das Projekt verfügt über Zehntausende GitHub-Sterne sowie etwa zwanzig Millionen wöchentliche npm-Downloads – mehr als Redux klassisch zusammen mit Redux Toolkit in vielen letzten Wochen.
Die gesamte Kern-API besteht aus einer einzigen Funktion, create. Man gibt eine Definition des Zustands sowie Aktionen an; als Rückgabe erhält man einen React-Hook. Kein Provider. Keine Konstanten für Aktionstypen. Keine separaten Reducer-Dateien. Beispiel:
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
Jeder Komponente kann diesen Hook aufrufen. Kein Relay-Race nötig.
Jede Komponente kommuniziert direkt mit dem Store. Ein Relay-Race ist nicht erforderlich.
Mentales Modell: Der Store ist eine festgepinnte Gruppen-Chat-Nachricht, die jeder lesen und bearbeiten kann. Redux erinnert eher an ein Gerichtsgebäude – man reicht einen Antrag ein (Action), wartet auf das Urteil des Reducers und erhält die Informationen über einen kontrollierten Kanal. Redux erkaufte sich Vorhersehbarkeit durch Bürokratie; Zustand setzt auf disziplinierte Aktualisierungen mit weniger Papierkram.
Der erste echte Store
Erstellen Sie eine Vite React-Anwendung und installieren Sie die Bibliothek:
npm create vite@latest my-first-zustand -- --template react
cd my-first-zustand
npm install
npm install zustand
npm run dev
Erstellen Sie useCounterStore.js:
// useCounterStore.js
import { create } from 'zustand'
// We're borrowing one function from the zustand package.
// That's all we need. Zustand doesn't hide other stuff from us, there genuinely isn't more.
const useCounterStore = create((set) => ({
// create takes a function as its only argument.
// That function receives two tools named set and get.
// We only need set for now. It's how we change state.
count: 0,
// This is a state field. It lives in the store.
// Any component in our app can read it.
increment: () =>
set((state) => ({ count: state.count + 1 })),
// This is an action. Actions are just functions that call set.
// We pass set a function that takes the OLD state and returns
// an object describing what should change.
// Zustand merges this object into the store for us.
decrement: () =>
set((state) => ({ count: state.count - 1 })),
// Another action. Same pattern. Decrement by one.
reset: () => set({ count: 0 }),
// When we don't need the old state, we can just pass a plain object.
// Zustand handles both forms.
}))
export default useCounterStore
// Ship the hook out so other files can import it.
Nutzen Sie sie:
// Counter.jsx
import useCounterStore from './useCounterStore'
function Counter() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
const reset = useCounterStore((state) => state.reset)
// Each line is a subscription.
// We grab exactly what we need and no more.
// This matters for performance, we'll get to why.
return (
<div>
<h1>Count {count}</h1>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>reset</button>
</div>
)
}
export default Counter
Montieren Sie <Counter /> überall ein. Die Zahlen ändern sich. Beachten Sie, was fehlt: Provider-Wrapper, Context-Initialisierung, Action-Konstanten, Reducers, connect, mapDispatchToProps, Thunk-Konfiguration. Einfach ein create-Aufruf und ein Hook.
„Warte, ist das wirklich alles?“
Hinter den Kulissen
create erstellt innerhalb des Modulbereichs ein einfaches Objekt für den Zustand sowie eine leere Zuhörerliste (ein Singleton sobald das Modul geladen wird).
Wenn eine Komponente den Hook verwendet:
- Zustand führt den Selektor (zum Beispiel
(state) => state.count) auf dem aktuellen Zustand aus und gibt diesen Wert zurück. - Der Hook registriert die Komponente zusammen mit dem Selektor als Zuhörer. Bei späteren Aktualisierungen werden alle Selektoren erneut ausgeführt und nur dann neu gerendert, wenn sich der ausgewählte Wert geändert hat.
Im Hauptlaufweg gibt es keinen React Context – es handelt sich eher um einen kleinen Ereignisemitter mit React-Integration. Da der Store außerhalb des React-Baums liegt, teilt sich jede Importierung desselben Moduls dieselbe Instanz. Vanilla-Multi-Instanz-Speicher existieren nur für den seltenen Fall, in dem sie benötigt werden; die meisten Anwendungen brauchen diesen Ausweg nie.
Der gesamte Lebenszyklus: vier Schritte und eine Feedbackschleife
Selektoren (nicht überfliegen)
Selektoren bestimmen wann ein Komponente neu gerendert wird.
Muster A – ganze Store-Datenstruktur (in der Regel falsch):
const store = useCounterStore()
// No selector. Zustand returns the entire store object.
// Your component now re-renders on ANY state change, even unrelated ones.
// If someone else changes a different piece of state you don't care about,
// this component still wastes a render cycle.
Muster B – ein einzelnes Feld (in der Regel richtig):
const count = useCounterStore((state) => state.count)
// Now your component only re-renders when count changes specifically.
// Other state can update freely. This component sleeps through it.
Muster C – Falle durch Objektliteralien:
const { count, increment } = useCounterStore((state) => ({
count: state.count,
increment: state.increment,
}))
// Looks clean. It's a trap.
// This selector returns a NEW object every time it runs.
// Zustand compares the new object to the old object. They're different objects.
// Result: this component re-renders on every state change in the entire store.
// Worse than pattern A for obvious reasons.
Jedes Mal entsteht ein neues Objekt, wodurch alles „neu“ erscheint, selbst wenn die Felder unverändert bleiben – dadurch erfolgen ständig erneute Renderungen.
Schlechter Selektor links. Guter Selektor rechts. Der Unterschied ist in großen Anwendungen enorm.
Braucht man mehrere Felder ohne ständige Erstellung neuer Objekte? Verwenden Sie useShallow:
import { useShallow } from 'zustand/react/shallow'
const { count, increment } = useCounterStore(
useShallow((state) => ({
count: state.count,
increment: state.increment,
}))
)
// useShallow tells Zustand "compare the returned object field by field."
// If count and increment didn't change individually, no re-render.
// Now you get clean destructuring AND performance.
Viele Produktions-Codebasen bevorzugen stattdessen mehrere spezifische Hook-Aufrufe. Faustregel: Nutzen Sie den kleinstmöglichen Datensatz; wenn Sie unsicher sind, teilen Sie die Hooks anstatt sie zu kombinieren.
Aktionen: synchron und asynchron
Aktionen befinden sich im selben Objekt wie der Zustand. Synchronisierte Aktualisierungen verwenden set:
const useCartStore = create((set, get) => ({
items: [],
addItem: (product) =>
set((state) => ({
items: [...state.items, product],
})),
// Spread the existing items into a new array.
// Add the new product at the end.
// Return the updated items array to merge back into state.
removeItem: (productId) =>
set((state) => ({
items: state.items.filter((item) => item.id !== productId),
})),
// Filter out the item with the matching id.
// Return the filtered array.
clear: () => set({ items: [] }),
// Reset to empty. No need to read old state.
}))
get liest den aktuellen Zustand innerhalb einer Aktion aus, ohne einen React-Listener zu registrieren – praktisch für Entscheidungsfindungen:
const useWalletStore = create((set, get) => ({
balance: 100,
tryWithdraw: (amount) => {
const currentBalance = get().balance
// Read the current balance at this exact moment.
// No subscription. No re-render. Just a fresh read.
if (currentBalance < amount) {
return { success: false, message: 'Not enough funds' }
// Bail out without touching state.
}
set((state) => ({ balance: state.balance - amount }))
return { success: true, message: 'Withdrawn successfully' }
},
}))
Ausgeführte Asynk-Aufgaben erfolgen mit herkömmlichem async/await – es ist kein thunk-Middleware nötig:
const usePostStore = create((set) => ({
posts: [],
loading: false,
error: null,
fetchPosts: async () => {
set({ loading: true, error: null })
// Flip loading to true. Clear any previous errors.
// Components showing a spinner will now show it.
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts')
const data = await response.json()
// Hit the API. Wait for it. Parse the JSON.
set({ posts: data, loading: false })
// Store the posts. Flip loading back to false.
// Components showing the list now have data.
} catch (err) {
set({ error: err.message, loading: false })
// If anything exploded, record the error message.
// Components can now show an error banner.
}
},
}))
Diese Abwesenheit von Standard-Pipeline-Code ist der praktische Unterschied zu herkömmlichen Redux-Asynk-Einrichtungen.
Middleware: stapelbare Funktionen
Middleware umhüllt den Store-Creator. Häufig verwendete Komponenten folgen.
Persist – Überleben von Neu laden
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
toggle: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}),
{
name: 'theme-storage',
// The key under which Zustand saves your state in localStorage.
// Name it whatever you want, just make it unique.
}
)
)
Das Theme (oder die von Ihnen gewählten Felder) wird nach dem Neuladen wieder angezeigt. In den DevTools → Application → Local Storage ist der JSON-Inhalt sichtbar.
DevTools – Aktionen überprüfen
Trotz der Redux-Bezeichnung funktioniert die Browser-Erweiterung, wenn der Store mit devtools umhüllt ist:
import { devtools } from 'zustand/middleware'
const useStore = create(
devtools(
(set) => ({
count: 0,
increment: () =>
set(
(s) => ({ count: s.count + 1 }),
false,
'counter/increment'
),
// The third argument is the action name shown in DevTools.
// If you skip it, you'll see "anonymous" for every action, which is useless for debugging.
}),
{ name: 'CounterStore' }
)
)
Immer – verschachtelte Aktualisierungen ohne verschachtelte Spread-Operatoren
Schmerzhafte, unveränderliche verschachtelte Datenstrukturen:
// Without immer, updating a deeply nested field:
updateCity: (city) => set((state) => ({
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city,
},
},
},
}))
Lass Updates immer so aussehen, als wären sie veränderlich, obwohl sie im Grunde unveränderlich bleiben:
import { immer } from 'zustand/middleware/immer'
const useStore = create(
immer((set) => ({
user: { profile: { address: { city: '' } } },
updateCity: (city) =>
set((state) => {
state.user.profile.address.city = city
// Looks like mutation. Isn't actually mutation.
// Immer tracks the change and produces a new immutable state object.
}),
}))
)
Außereine Hörer, die den Selektor berücksichtigen
Für Nicht-React-Hörer, die nur dann ausgelöst werden sollten, wenn sich ein ausgewählter Teilbereich ändert, bietet Zustand das subscribeWithSelector-Middleware an:
import { subscribeWithSelector } from 'zustand/middleware'
const useCartStore = create(
subscribeWithSelector((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}))
)
// Somewhere outside React, like in an analytics file:
useCartStore.subscribe(
(state) => state.items,
// The selector. We only care about items.
(items, previousItems) => {
console.log('Cart changed', { from: previousItems, to: items })
// This runs every time items changes, with both old and new values.
// No React component involved. No re-render. Just a side effect.
}
)
Aufschichten von Middleware
const useStore = create(
persist(
devtools(
subscribeWithSelector(
immer((set, get) => ({
// your store definition
}))
),
{ name: 'MyStore' }
),
{ name: 'my-store-storage' }
)
)
Einsmal hässlich, dann vergessen.
Die „Zwiebel“ aus Middleware – jede Schicht fügt eine weitere Funktion hinzu.
Skalierbare Datenbereiche
Ein einziger Datei ist in Ordnung, solange Warenkorb, Authentifizierung, Theme und Benachrichtigungen nicht miteinander kollidieren. Slice-Fabriken halten die Bereiche voneinander getrennt, während sie einen einzigen Store zusammenstellen:
// cartSlice.js
export const createCartSlice = (set, get) => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] })),
clearCart: () => set({ items: [] }),
})
// authSlice.js
export const createAuthSlice = (set, get) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
})
// useAppStore.js
import { create } from 'zustand'
import { createCartSlice } from './cartSlice'
import { createAuthSlice } from './authSlice'
const useAppStore = create((set, get) => ({
...createCartSlice(set, get),
...createAuthSlice(set, get),
}))
Komponenten wählen weiterhin aus, was sie benötigen. Ab etwa fünf Bereichen erfordern die Slices mehr Struktur; davor ist eine einzige Datei übersichtlicher.
Testen ohne UI-Initialisierung
Stores sind einfache Module – man kann sie zurücksetzen, Aktionen aufrufen und Aussagen überprüfen:
// useCounterStore.test.js
import useCounterStore from './useCounterStore'
describe('counter store', () => {
beforeEach(() => {
useCounterStore.setState({ count: 0 })
// Reset state before each test.
// setState is exposed on the hook itself, not just for components.
})
it('increments count', () => {
useCounterStore.getState().increment()
// getState gives you the current store object outside of React.
// .increment() calls the action.
expect(useCounterStore.getState().count).toBe(1)
// Verify the count went from 0 to 1.
})
it('resets count', () => {
useCounterStore.getState().increment()
useCounterStore.getState().increment()
useCounterStore.getState().reset()
expect(useCounterStore.getState().count).toBe(0)
})
})
Kein Renderer, kein Mock-Provider – Unit-Tests bleiben schnell und zuverlässig.
Projekt: Pflanzen-Bewässerungsstation
Bauen Sie einen Tracker für die Pflanzenpflege: Fügen Sie Pflanzen mit der Bewässerungsfrequenz hinzu, markieren Sie bewässerte Pflanzen, heben Sie überfällige hervor, zeigen Sie Statistiken an und speichern Sie die Daten bei Neu laden. Funktionen:
- Pflanze hinzufügen (Name + Tage zwischen den Bewässerungen)
- Liste der Pflanzen
- Bewässern mit einem Klick
- Pflanzen mit Trockenheitsproblem markieren
- Pflanzen entfernen
- Statistik-Panel
- Datenübertragung bei Neu laden
Die gesamte App auf einer Seite. Vier Komponenten, ein Store – zufriedene Pflanzen.
Datei 1 – Store
src/store/usePlantStore.js:
// src/store/usePlantStore.js
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// Helper function. Not exported. Just used internally.
// Given a plant object, returns true if it needs water.
const isThirsty = (plant) => {
const msPerDay = 1000 * 60 * 60 * 24
// milliseconds in a second times seconds in a minute
// times minutes in an hour times hours in a day
const daysSinceWatering = (Date.now() - plant.lastWatered) / msPerDay
return daysSinceWatering >= plant.frequencyDays
}
const usePlantStore = create(
persist(
(set, get) => ({
plants: [],
// The big array that holds every plant.
// Each plant will be an object with id, name, frequencyDays, lastWatered.
addPlant: (name, frequencyDays) =>
set((state) => ({
plants: [
...state.plants,
{
id: Date.now() + Math.random(),
// Quick unique id. Good enough for a personal app.
// For production use nanoid or uuid from npm.
name,
frequencyDays,
lastWatered: Date.now(),
// New plants count as freshly watered.
// Otherwise they'd show as thirsty the second they're added, which is mean.
},
],
})),
waterPlant: (id) =>
set((state) => ({
plants: state.plants.map((plant) =>
plant.id === id
? { ...plant, lastWatered: Date.now() }
: plant
),
// Find the matching plant, return a new object with updated timestamp.
// Leave all other plants untouched.
})),
removePlant: (id) =>
set((state) => ({
plants: state.plants.filter((plant) => plant.id !== id),
// Drop the matching plant. Keep everyone else.
})),
// These are getter-style helpers using get().
// They're not stored, they're computed from current state.
thirstyCount: () => get().plants.filter(isThirsty).length,
happyCount: () => get().plants.filter((p) => !isThirsty(p)).length,
isPlantThirsty: (id) => {
const plant = get().plants.find((p) => p.id === id)
return plant ? isThirsty(plant) : false
},
}),
{
name: 'plant-hydration-v1',
// localStorage key. Prefixing with v1 lets me change schema later
// without breaking existing users' data.
}
)
)
export default usePlantStore
Datei 2 – Hinzufügungsformular
// src/components/AddPlantForm.jsx
import { useState } from 'react'
import usePlantStore from '../store/usePlantStore'
function AddPlantForm() {
const [name, setName] = useState('')
const [frequency, setFrequency] = useState(3)
// These are local to this component.
// Form inputs are textbook useState territory.
// They don't need to be global.
const addPlant = usePlantStore((state) => state.addPlant)
// Only grab the action we need.
// We don't care about the plants array here, we don't pull it.
const handleSubmit = (event) => {
event.preventDefault()
// Prevent the default form submission that reloads the page.
// Modern React always wants this call on form events.
const trimmed = name.trim()
if (!trimmed) return
// Reject empty or whitespace-only names silently.
addPlant(trimmed, Number(frequency))
// Call the store action.
// Number() converts the string from the input into a number.
setName('')
setFrequency(3)
// Clear the form so the user can add another plant easily.
}
return (
<form onSubmit={handleSubmit} className="add-plant-form">
<h2>Add a Plant</h2>
<label className="field">
<span>Plant name</span>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Monstera, Pothos, Something Latin"
/>
</label>
<label className="field">
<span>Water every</span>
<div className="freq-input">
<input
type="number"
min="1"
max="60"
value={frequency}
onChange={(event) => setFrequency(event.target.value)}
/>
<span>days</span>
</div>
</label>
<button type="submit">Add Plant</button>
</form>
)
}
export default AddPlantForm
Datei 3 – Liste
// src/components/PlantList.jsx
import usePlantStore from '../store/usePlantStore'
function PlantList() {
const plants = usePlantStore((state) => state.plants)
const waterPlant = usePlantStore((state) => state.waterPlant)
const removePlant = usePlantStore((state) => state.removePlant)
// Three separate subscriptions.
// Clean. Performant. Obvious.
if (plants.length === 0) {
return (
<div className="empty-state">
<p>No plants yet. Add one to start tracking.</p>
</div>
)
// Empty state so the UI doesn't look broken.
// Always tell the user what they can do next.
}
const daysSinceWatered = (timestamp) => {
const msPerDay = 1000 * 60 * 60 * 24
return Math.floor((Date.now() - timestamp) / msPerDay)
}
return (
<section className="plant-list">
<h2>Your Plants</h2>
<ul>
{plants.map((plant) => {
const days = daysSinceWatered(plant.lastWatered)
const thirsty = days >= plant.frequencyDays
// Compute thirsty status on the fly.
// Cheap calculation. Premature optimization would be storing this.
return (
<li
key={plant.id}
className={thirsty ? 'plant thirsty' : 'plant happy'}
>
<div className="plant-meta">
<strong className="plant-name">{plant.name}</strong>
<span className="plant-when">
{days === 0
? 'Watered today'
: `Last watered ${days} ${days === 1 ? 'day' : 'days'} ago`}
</span>
{thirsty && <span className="badge">THIRSTY</span>}
</div>
<div className="plant-actions">
<button
onClick={() => waterPlant(plant.id)}
className="btn-primary"
>
Water
</button>
<button
onClick={() => removePlant(plant.id)}
className="btn-danger"
>
Remove
</button>
</div>
</li>
)
})}
</ul>
</section>
)
}
export default PlantList
Datei 4 – Statistiken
// src/components/StatsPanel.jsx
import usePlantStore from '../store/usePlantStore'
function StatsPanel() {
const plants = usePlantStore((state) => state.plants)
// We subscribe to the plants array because our stats depend on it.
// When plants change, this re-renders with fresh totals.
const total = plants.length
const thirstyCount = plants.filter((plant) => {
const days = (Date.now() - plant.lastWatered) / (1000 * 60 * 60 * 24)
return days >= plant.frequencyDays
}).length
const happyCount = total - thirstyCount
return (
<aside className="stats-panel">
<h2>Quick Stats</h2>
<div className="stat-row">
<span>Total plants</span>
<strong>{total}</strong>
</div>
<div className="stat-row">
<span>Needs water</span>
<strong className="danger">{thirstyCount}</strong>
</div>
<div className="stat-row">
<span>Happy plants</span>
<strong className="success">{happyCount}</strong>
</div>
{thirstyCount > 0 && (
<p className="nudge">
{thirstyCount === 1
? 'One plant is waiting on you.'
: `${thirstyCount} plants are waiting on you.`}
</p>
)}
</aside>
)
}
export default StatsPanel
Datei 5 – App-Shell
// src/App.jsx
import AddPlantForm from './components/AddPlantForm'
import StatsPanel from './components/StatsPanel'
import PlantList from './components/PlantList'
import './App.css'
function App() {
return (
<div className="app">
<header className="app-header">
<h1>Plant Hydration Station</h1>
<p className="tagline">Don't let them down</p>
</header>
<div className="grid">
<AddPlantForm />
<StatsPanel />
</div>
<PlantList />
</div>
)
// Notice something beautiful here.
// No Provider wrapping anything.
// No props passed to any component.
// Every component reaches into the store on its own.
}
export default App
Datei 6 – CSS
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #F5F3FF;
color: #2D3436;
}
.app { max-width: 960px; margin: 0 auto; padding: 24px; }
.app-header {
background: linear-gradient(135deg, #6C5CE7, #A29BFE);
color: white;
padding: 24px 28px;
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 8px 24px rgba(108, 92, 231, 0.15);
}
.app-header h1 { margin: 0; font-size: 28px; }
.tagline { margin: 4px 0 0; opacity: 0.9; font-style: italic; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
.add-plant-form, .stats-panel, .plant-list {
background: white;
padding: 20px 22px;
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.field { display: block; margin-bottom: 14px; }
.field span { display: block; font-size: 13px; color: #636E72; margin-bottom: 6px; }
.field input {
width: 100%;
padding: 10px 12px;
border: 1px solid #DFE6E9;
border-radius: 8px;
font-size: 14px;
}
.freq-input { display: flex; align-items: center; gap: 10px; }
.freq-input input { width: 80px; }
button {
border: none;
padding: 10px 18px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
}
.add-plant-form button[type="submit"] {
background: #6C5CE7;
color: white;
width: 100%;
}
.btn-primary { background: #0984E3; color: white; }
.btn-danger { background: #FFE5E0; color: #E17055; }
.stat-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #F1F2F6; }
.stat-row:last-of-type { border: none; }
.stat-row .danger { color: #E17055; }
.stat-row .success { color: #00B894; }
.nudge { background: #FFF5F0; color: #E17055; padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-top: 10px; }
.plant-list ul { list-style: none; padding: 0; margin: 0; }
.plant { display: flex; justify-content: space-between; align-items: center; padding: 14px 6px; border-bottom: 1px solid #F1F2F6; }
.plant:last-child { border: none; }
.plant.thirsty .plant-name::before { content: "🚨 "; }
.plant-meta { display: flex; flex-direction: column; gap: 3px; }
.plant-name { font-size: 15px; }
.plant-when { font-size: 12px; color: #636E72; }
.badge { background: #FFE5E0; color: #E17055; padding: 2px 8px; border-radius: 4px; font-size: 10px; font-weight: bold; display: inline-block; margin-top: 2px; }
.plant-actions { display: flex; gap: 8px; }
.empty-state { text-align: center; padding: 40px 20px; color: #636E72; }
Führen Sie npm run dev aus, fügen Sie Pflanzen hinzu, aktualisieren Sie die Seite und überprüfen Sie, ob sie erhalten bleiben. Gießen Sie eine Pflanze und beobachten Sie, wie die Zeitstempel zurückgesetzt werden. Der lokale Speicher übernimmt dieselben Daten in ein zweites Tabblatt.
Ihre fertige Anwendung – von nun an können Sie sie nach Belieben gestalten.
Überprüfung mit Browser-Tools
Application / Storage → Local Storage → Der Schlüssel plant-hydration-v1 enthält die gespeicherten JSON-Daten. Das Persist-Middleware schreibt bei Änderungen und lädt die Daten erneut vor dem Anzeigen. Mit devtools-Middleware sowie der Redux DevTools-Erweiterung erscheint jede Aktion zusammen mit einer Zeitstempelanzeige, insbesondere bei größeren Datensätzen.
Häufige Fehler
- Whole-store Hook –
useStore()ohne Selektor lädt bei jeder Änderung neu. Wählen Sie immer einen Selektor aus. - Frische Objekte aus Selectoren – beheben Sie dies mit
useShallowoder indem Sie die Hooks trennen.
thirstyCount aus plants; führen Sie keinen zweiten veralteten Zähler.name – erforderlich; ohne dieses tritt beim Start ein Fehler auf.create-Funktion pro Modul wird gemeinsam genutzt; es existieren Standard-APIs für instanzspezifische Anforderungen.getState, führen Sie Aktionen aus und überprüfen Sie Ergebnisse; so erkennen Sie Regressionen frühzeitig.Wann Zustand das falsche Werkzeug ist
useState– wirklich lokale Benutzeroberflächen (Modale, Hover-Effekte, Vorläuferdaten von Feldern).
TypeScript-Skizze
import { create } from 'zustand'
type Plant = {
id: number
name: string
frequencyDays: number
lastWatered: number
}
type PlantState = {
plants: Plant[]
addPlant: (name: string, frequencyDays: number) => void
waterPlant: (id: number) => void
removePlant: (id: number) => void
thirstyCount: () => number
}
const usePlantStore = create<PlantState>((set, get) => ({
plants: [],
addPlant: (name, frequencyDays) =>
set((state) => ({
plants: [
...state.plants,
{ id: Date.now(), name, frequencyDays, lastWatered: Date.now() },
],
})),
waterPlant: (id) =>
set((state) => ({
plants: state.plants.map((p) =>
p.id === id ? { ...p, lastWatered: Date.now() } : p
),
})),
removePlant: (id) =>
set((state) => ({
plants: state.plants.filter((p) => p.id !== id),
})),
thirstyCount: () =>
get().plants.filter((p) => {
const days = (Date.now() - p.lastWatered) / (1000 * 60 * 60 * 24)
return days >= p.frequencyDays
}).length,
}))
Ein Store-Shape-Typ zusammen mit create<...>; die Inferenz kümmert sich um den Rest.
Großes Bild
Zustand hat die bereits installierte Redux-Infrastruktur nicht überschrieben – Neuimplementierungen sind aufwendig – doch neue React-Anwendungen greifen zunehmend auf leichtere Client-Speicher zurück. Zufriedenheitsumfragen in jüngsten State of React-Umfragen platzieren Zustand ganz oben bei der Frage „Würde ich es wieder verwenden?“. Eine gängige Stack-Kombination für 2026: Zustand für den Client-Zustand, TanStack Query für den Server-Zustand, gelegentlich Jotai-Atome, Context für Theme/Config, Redux Toolkit nur dann, wenn es bereits vorhanden ist, sowie einfaches useState für die lokale UI.
Diese Kombination führt in der Regel zu einer schnelleren Einarbeitung im Vergleich zu auf Redux ausgerichteten Frameworks und bereitet den Entwicklern im Alltag weniger Probleme.
Zustand Bear
Produktionsteams dokumentieren weiterhin die Speicherkonventionen (Namensgebung, Grenzen der Teilmengen, Persistenzschlüssel), denn Freiheit ohne Normen führt zu Chaos. Der Vorteil ist, dass diese Konventionen kurz bleiben: Selektoren sind eng gefasst, Aktionen befinden sich neben dem Zustand, Middleware wird bewusst eingebunden, und der Server-Cache bleibt außerhalb des Client-Speichers. Halten Sie diese Grenzen klar, dann bleibt Zustand die zehn Zeilen lange Lösung für das hundert Zeilen umfassende Redux-Startkit – ohne zu behaupten, jede Anwendung sei für immer nur eine Zähldemo.
Neben dem Pflanzensample gelten die gleichen Muster auch für Warenkörbe, Feature-Flags, Entwürfe von Assistenten sowie UI-Verzierungen. Beginnen Sie mit einer einzigen Store-Datei, fügen Sie Persistenz hinzu, wenn Benutzer es hassen, ihre Arbeit zu verlieren, fügen Sie DevTools hinzu, wenn Fehler subtil werden, führen Sie Slices ein, wenn die Scrollleiste der Datei zu einem Witz wird, und behalten Sie Query für alles bei, was eine Anfrage an eine API sendet. Dieser Fortschrittsweg entspricht der tatsächlichen Entwicklung der meisten erfolgreichen Zustand-Codebasen: klein, klar strukturiert und ablehnend gegenüber überflüssigen Formalitäten, die keine Sicherheit bringen.
Erforschung von Selektoren und Leistung
Disziplin bei den Selektoren ist der Unterschied zwischen einer schnellen Anzeigetafel und einer rätselhaften Langsamkeit. Jede unnötige Neuzeichnung führt zu erneuter Ausführung von JSX, Effekten, die von Props abhängen, sowie zur Neuabstimmung der Kinderelemente. Zustands Listener-Modell ist nur dann kostengünstig, wenn die Selektoren stabile Primitive oder sorgfältig verglichene Strukturen zurückgeben.
Es ist vorzuziehen, Boolesche Werte, Zahlen und Zeichenketten auszuwählen. Wenn eine Aktionsfunktion ausgewählt wird, bleibt sie in der Regel bei Updates stabil, da sie im Store-Objekt gespeichert ist; daher ist es in Ordnung, count und increment in zwei Hooks zu kombinieren. Vermeiden Sie es, ganze Arrays auszuwählen, wenn das Komponente nur items.length benötigt; wählen Sie stattdessen die Länge (oder einen daraus abgeleiteten Booleschen Wert), damit andere Push-Operationen inaktive Widgets nicht aktivieren.
Gleichheit ist wichtig. Der Standardvergleich ist Object.is. Deshalb versagt das Zurückgeben von { a, b } aus einem Selector: Ein neues Objekt erfüllt die Bedingung von Object.is nicht, selbst wenn a und b unverändert geblieben sind. useShallow vergleicht nur eine Ebene der Felder. Bei tiefen Strukturen sollten Sie entweder den Zustand normalisieren, sodass die UI flache Felder liest, oder gezielt einen primitiven Fingerabdruck berechnen.
Listen erfordern besondere Sorgfalt. Die Aufteilung von plants in drei Komponenten ist in Ordnung, sofern jede Komponente nur auf die Array-Referenz zugreifen muss, wenn sich die Zugehörigkeit oder die Identität eines Elements ändert. Wenn ein Panel nur die Namen anzeigt, sollten Sie einen Selektor in Betracht ziehen, der eine sortierte Liste der IDs zurückgibt; dieser bleibt stabil, auch wenn sich unverwandte Felder der Pflanzen ändern.
Persistenzfallen und Versionierung
Persistenz-Middleware wirkt zunächst magisch – bis eine Änderung des Schemas eintritt. Legen Sie stets einen stabilen name für die Speicherschlüssel fest. Wenn sich die Struktur des persistent gespeicherten Zustands ändert, erhöhen Sie die Version und stellen Sie eine migrate-Funktion bereit, damit alte JSON-Dateien die Anwendung beim Laden nicht zum Absturz bringen. Teilweise Persistenz (partialize) schützt Geheimnisse sowie vorübergehende UI-Flags vor dem Speichern im Local Storage – Tokens und einmalige Modalfenster-Statuswerte gehören in der Regel nicht auf die Festplatte.
Achten Sie auf den Zeitpunkt der Hydratierung: Die erste Darstellung des Clients kann vor Abschluss der Neuhydratierung kurzzeitig die Standardwerte anzeigen. Bei SSR oder Frameworks, die auf dem Server rendern, sollten UI-Elemente, die von persistenten Werten abhängen, hinter einem Hydratierungsflag versteckt werden, oder man akzeptiert vorübergehend das Standard-Thema. Dokumentieren Sie den gewählten Ansatz, damit Teamkollegen keine „Fehler“ beheben, die in Wirklichkeit auf Hydratierungsprobleme zurückzuführen sind.
Auch das Verhalten bei Wechsel zwischen Tabs überrascht viele Nutzer. Schreibvorgänge aus dem Local Storage in einem Tab sind über das storage-Event in anderen Tabs sichtbar, doch der Standard-Persistenzpfad von Zustand fügt konkurrierende Änderungen nicht automatisch zusammen. Für gemeinsam genutzte Tabs sollte entweder das Prinzip „Letzter Schreibvorgang gewinnt“ angewandt werden oder eine explizite BroadcastChannel-Synchronisationsschicht über dem Store hinzugefügt werden.
Entwurf von Aktionen, die langweilig bleiben
Actions im guten Zustand sind klein, nach der Absicht des Benutzers benannt und enthalten keine JSX-Code. addPlant, waterPlant und removePlant sind den Ausgaben von setPlants in der Benutzeroberfläche überlegen. Halten Sie die Validierung nah an der Action: Ablehnen Sie leere Namen, begrenzen Sie die Bewässerungsintervalle und ignorieren Sie unbekannte IDs. Frühzeitiges Verlassen einer Action ist klarer, als schlechte Daten über einen Render-Zyklus im Store zu belassen.
Asynchrone Actions sollten explizite loading- und error-Felder setzen, wenn die Benutzeroberfläche den Fortschritt anzeigen muss. Ein einfaches Logging kann diese Flags überspringen. Wenn mehrere asynchrone Aufrufe gleichzeitig stattfinden, sollten Sie eine Request-ID erfassen oder einen Abbruchcontroller verwenden, damit eine ältere Antwort keine neuere überschreiben kann. Dafür ist kein Middleware erforderlich – nur sorgfältige Sequenzierung der set-Aufrufe.
Abgeleitete Hilfsfunktionen können als einfache Funktionen außerhalb des Stores existieren (am einfachsten zu testen) oder als Getter, die innerhalb von Selektoren berechnet werden. Es ist vorzuziehen, reine Funktionen zu verwenden, die sowohl vom Store als auch von der UI importiert werden, damit Jest die Berechnungen zur Bewässerung ohne React überprüfen kann.
Anmerkungen zur Schritt-für-Schritt-Anleitung der Plant-App
Beim Einfügen der sechs Dateien sollten die Importpfade mit dem Vite-Vorlageformat übereinstimmen (../store/... aus components). Falls die Liste nach dem Neuladen leer erscheint, prüfen Sie den Persistenzschlüssel in den DevTools und stellen Sie sicher, dass name: 'plant-hydration-v1' mit Ihren Erwartungen übereinstimmt. Die Bewässerung sollte lastWateredAt (oder ein äquivalentes Feld) aktualisieren, damit der Selektor ohne vollständige Seitenneuladung reagiert.
Das Styling in App.css ist absichtlich schlicht gehalten. Wechseln Sie Schriftarten und Farben frei – das Lernziel ist der Datenfluss, nicht die visuelle Gestaltung. Das Hinzufügen eines vierten Komponenten – beispielsweise einer Schaltfläche „Alle durstigen bewässern“ – ist eine nützliche Übung: Wählen Sie die entsprechenden IDs aus und rufen Sie anschließend eine Aktion auf, die das Bewässern für alle diese IDs in einem einzigen set durchführt. Diese Übung stärkt das Verständnis für das Gruppieren von Aktualisierungen anstelle des wiederholten Aufrufs von set aus der Komponente.
Teamkonventionen, die es wert sind, aufzuschreiben
Klären Sie die Struktur der Dateien (stores/ gegenüber gemeinsam platzierten Feature-Verzeichnissen), die Benennung (useXStore) sowie ob Aktionen direkt APIs aufrufen dürfen oder über eine Query-Mutation gehen müssen, die anschließend den Zustand aktualisiert. Viele Teams verbieten völlig das Hinzufügen von Serverlisten in den Zustand und speichern dort nur vorübergehende Client-Flags. Schreiben Sie diese Regel einmal in die README – dadurch vermeiden Sie, dass die Hälfte des Codebases erneut einen Cache entwickelt.
Klären Sie außerdem die Benennung der DevTools: Das devtools-Middleware akzeptiert einen Store-Name, damit das Erweiterungsfenster auch bei fünf geöffneten Stores lesbar bleibt. Persistenzschlüssel sollten nach Anwendung benannt werden (myapp-theme-v1), um Kollisionen auf gemeinsamen Domains zu vermeiden.
Vergleich des emotionalen Gewichts – nicht nur der Anzahl der Codezeilen
Redux Toolkit hat den klassischen Redux erheblich verkürzt, weshalb der Vergleich „100 Zeilen gegen 10“ teilweise nur rhetorisch ist. Der tiefere Kontrast liegt im konzeptionellen Aufwand: „Slices“ gegen einen einzigen Aufruf von „create“, Reducer gegen Aktionen, die direkt ausgeführt werden, Middleware-Pipelines gegen optionale Wrapper sowie „Provider“-Bäume gegen Modul-Singletten. Ingenieure, die bereits in Begriffen von Reducern denken, können in beiden Ansätzen produktiv arbeiten. Ingenieure, die einen gemeinsamen Client-Zustand ohne starre Anforderungen an State-Maschinen möchten, fertigen Funktionen in der Regel schneller mit Zustand ab.
Nichts davon rechtfertigt das Auslassen von Überprüfungen. Auch ein zehnzeiliger Store kann einen Sicherheitsfehler beinhalten, wenn er personenbezogene Daten ohne Zustimmung speichert, oder einen Leistungsfehler, wenn jede Komponente immer dieselben Daten verwendet. Behandeln Sie den Store wie eine öffentliche Modul-API: stabile Aktionennamen, dokumentierte Schlüssel für die Datenspeicherung sowie Tests für unerwünschte Szenarien (leere Listen, ungültige IDs, fehlgeschlagene Abfragen).
Abschluss des Lernzyklus
Sobald die App funktioniert, stören Sie sie absichtlich: Entfernen Sie einen Selektor, geben Sie ein Objektliteral zurück, speichern Sie ohne Namen oder speichern Sie eine abgeleitete Anzahl. Beobachten Sie die Fehlermuster einmal, damit sie bei Code-Reviews erkennbar sind. Stellen Sie anschließend die disziplinierten Muster wieder her. Dieses kurze Experiment trägt mehr zur langfristigen Merkfähigkeit bei als jede andere, perfektionierte Zähldemo.
Zustand ist der Ansicht, dass der größte Teil des Client-Zustands langweilig ist – Flags, Entwürfe, Warenkörbe usw. – und langweiliger Zustand verdient eine langweilige API. Bewahren Sie die Server-Logik in einer speziell dafür konzipierten Cache-Bibliothek auf, die lokale Benutzeroberfläche in useState, und reservieren Sie die komplexeren Lösungen für die cross-cuttingen Client-Daten, die ursprünglich das Arbeiten mit Props so schwierig machten. Wenn Sie dies konsequent umsetzen, klingt die Behauptung von „zehn Zeilen“ nicht mehr wie Marketing, sondern wie die tatsächliche Struktur der Codebasis.