Strona główna / Artykuły / Stanie w praktyce: małe sklepy, precyzyjni wybieracze, tracker roślin

Stanie w praktyce: małe sklepy, precyzyjni wybieracze, tracker roślin

Zastąp prop-drilling i ceremonię Redux funkcjami create(), selektorami, operacjami asynchronicznymi, middleware persist/devtools, kawałkami danych (slices) oraz aplikacją typu hydration plant.

5694 słów

Tour przyjazny początkującym po Zustandzie — bibliotece stanu React z dziesiątkami milionów pobierania tygodniowo — wraz z przykładem opieki nad roślinami stworzonym od startu do końca.

W momencie, gdy ktoś w końcu powie ci, że istnieje lepszy sposób

Zanim pojawił się Zustand: co tak naprawdę robi React

Strony internetowe zaczynają się od tagów HTML — div, button, h1. Ręczne edytowanie takiej struktury dla dużego produktu jest uciążliwe: każda zmiana danych oznacza poszukiwanie elementów i ich ponowne pisanie. Zmieniono formularz logowania? Trzeba poprawić tuzin miejsc. Koszyk się powiększa? Poprawiać kolejne tuzin miejsc.

React zmienia ten model. Komponenty to funkcje, które opisują interfejs użytkownika na podstawie aktualnych danych; biblioteka decyduje, co należy poprawić w DOM. Autorzy opisują, a React aktualizuje.

Niewielki komponent wygląda tak:

function WelcomeBanner() {
  return <h1>Hello, stranger</h1>
}

Ta funkcja zwraca JSX — składnię w kształcie HTML, którą React kompiluje, a nie dosłowny HTML.

Szybki glosariusz do późniejszych sekcji:

  • JavaScript — język stojący za console.log i const x = 5.
  • npm — narzędzie do instalacji pakietów. npm install zustand pobiera Zustand do projektu.
  • Hooks — funkcje, których nazwy zaczynają się od use (useState, useEffect, niestandardowe hooks do przechowywania danych). Są one nowoczesną ścieżką dostępu do danych i efektów w React.

Jeśli te trzy koncepcje zostaną dobrze zrozumiane, reszta tego przewodnika będzie łatwa do przyjęcia.

React jest kucharzem. Ty podajesz mu przepis i składniki.

Czym naprawdę jest „state”

Stan to cokolwiek, co interfejs musi pamiętać: czy użytkownik jest zalogowany, wielkość koszyka, czy boczna lista jest otwarta, URL awatara, tekst w polu wyszukiwania. Zwykłe zmienne nie odświeżają automatycznie ekranu. Hooki React przechowują wartości i planują ponowną renderizację, gdy te wartości ulegną zmianie.

Najprostszym hookiem jest 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>
  )
}

Stan lokalny sprawdza się, gdy jedna komponenta jest właścicielem danej wartości. Problemy pojawiają się, gdy odległe komponenty muszą dzielić tę samą informację — formularz logowania, awatar w nagłówku, powitanie na panelu sterowania, funkcja wylogowania w ustawieniach — bez znajdowania się obok siebie w drzewie komponentów.

Odrębne wywołania useState nie komunikują się ze sobą. Prawdziwym problemem jest dzielenie się danymi pomiędzy odległymi elementami interfejsu.

Odrębne stany, które nie mogą ze sobą rozmawiać. Problem przedstawiony w jednym obrazku.

Cierpienie z powodu przenoszenia właściwości

Pierwszą odpowiedzią React jest „przeniesienie stanu na wyższy poziom”: umieszczenie wspólnych danych u najbliższego wspólnego przodka i przekazywanie ich dalej jako atrybuty (argumenty funkcji).

To działa w przypadku krótkich drzew strukturalnych. Problem pojawia się, gdy user musi przejść ścieżkę App → Layout → MainContent → Dashboard → Header → ProfilePic. Warstwy pośrednie nigdy nie używają tych danych; jedynie je przekazują dalej. Tematy, koszyk zakupów oraz tokeny autoryzacyjne dodatkowo wprowadzają kolejne etapy przekazywania danych przez obojętne elementy struktury. Refaktoryzacje mogą uszkodzić pliki, które nie są ze sobą powiązane.

Context miał na celu zatrzymanie tego procesu przekazywania danych. Provider otacza całe drzewo; dzieci wywołują funkcję useContext. Problem polega na tym, że elementy korzystające z kontekstu często są ponownie renderowane przy każdej zmianie wartości kontekstu, co utrudnia częste aktualizacje stanu.

Redux (2015) zaproponował przewidywalny stan zewnętrzny oraz selektory, które ponownie renderują tylko to, co się zmieniło — a także wprowadził wiele formalności, których utrzymywanie stało się u zespołów męczące.

Pojawia się niedźwiedź

Zustand pochodzi z kolektywu Poimandres Paula Henschela (który stoi również za React Three Fiber i Jotai). Nazwa pochodzi z języka niemieckiego i oznacza „stan”. Motywem przewodnim jest niedźwiedź. Projekt ma dziesiątki tysięcy gwiazdek na GitHubie oraz około dwudziestu milionów tygodniowych pobierania z npm — więcej niż klasyczny Redux w połączeniu z Redux Toolkit w wielu ostatnich tygodniach.

Cała podstawa API to jedna funkcja, create. Podaje się definicję stanu oraz akcji; otrzymuje się hooka React. Brak Providera. Brak stałych typów akcji. Brak oddzielnych plików reduktorów. Przykład:

import { create } from 'zustand'
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}))

Każdy komponent może wywołać ten hook. Brak konieczności przekazywania informacji dalej.

Każdy komponent komunikuje się bezpośrednio ze storem. Nie ma potrzeby żadnych pośredników.

Model mentalny: sklep to zamrożona wiadomość w grupowej rozmowie, którą wszyscy mogą czytać i edytować. Redux przypomina bardziej sąd – składasz wniosek (akcję), czekasz na orzeczenie reduktora, czytasz wyniki przez kontrolowane okno. Redux kupił przewidywalność kosztem biurokracji; Zustand polega na zdyscyplinowanych aktualizacjach przy mniejszej ilości papierkowej roboty.

Pierwszy prawdziwy sklep

Zbuduj aplikację Vite React i zainstaluj bibliotekę:

npm create vite@latest my-first-zustand -- --template react
cd my-first-zustand
npm install
npm install zustand
npm run dev

Stwórz plik 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.

Korzystaj z niego:

// 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

Zainstaluj <Counter /> w dowolnym miejscu. Liczniki się zmieniają. Zauważ, czego brakuje: otulaczy Provider, inicjalizacji kontekstu, stałych akcji, reduktorów, connect, mapDispatchToProps, konfiguracji thunk. Tylko jedna funkcja create i hOOK.

„Czekaj, to naprawdę wszystko?”

Pod maską

create tworzy zwykły obiekt do przechowywania stanu oraz pustą listę słuchaczy w zakresie modułu (jednostkę typu singleton zaraz po załadowaniu modułu).

Gdy komponent używa tego hooka:

  1. Zustand uruchamia selektor (na przykład (state) => state.count) na bieżącym stanie i zwraca odpowiedni fragment danych.
  2. Zarejestruje komponent wraz z selektorem jako słuchacza. Podczas późniejszych aktualizacji ponownie uruchamia każdy selektor i przerysowuje komponent tylko wtedy, gdy zmieniła się wybrana wartość.

W głównej ścieżce nie ma React Context – to raczej mały emiter zdarzeń z elementami React. Ponieważ magazyn danych znajduje się poza drzewem React, każde importowanie tego samego modułu dzieli się jedną instancją. Istnieją również magazyny danych wieloinstancyjne, ale są one potrzebne tylko w rzadkich przypadkach; większość aplikacji nie wymaga takiego rozwiązania.

Cały cykl życia: cztery kroki i pętla sprzężenia zwrotnego

Selektory (nie przegapaj tego)

Selektory decydują o kiedy komponent zostanie ponownie wyrenderowany.

Wzorzec A — cały sklep (zwykle błędnie):

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.

Wzorzec B — jedno pole (zwykle poprawnie):

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.

Wzorzec C — pułapka literalnego obiektu:

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.

Każdy nowy obiekt przy każdej wywołaniu wygląda „nowo”, nawet gdy pola pozostają niezmienione, więc renderowanie odbywa się ciągle.

Zły selektor po lewej. Dobry selektor po prawej. Różnica jest ogromna w dużych aplikacjach.

Potrzebujesz kilku pól bez konieczności tworzenia nowych obiektów? Użyj 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.

Wiele baz kodu produkcyjnego woli zamiast tego kilka wąskich wywołań hooków. Zasada ogólna: bierz najmniejszy fragment; gdy nie jesteś pewien, lepiej podzielić hooki niż je łączyć.

Działania: synchroniczne i asynchroniczne

Działania znajdują się w tym samym obiekcie co stan. Aktualizacje synchroniczne używają 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 odczytuje aktualny stan w ramach akcji bez rejestracji słuchacza React — przydatne do podejmowania decyzji:

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' }
  },
}))

Praca asynchroniczna odbywa się za pomocą zwykłych struktur async/await — bez żadnych dodatkowych procedur middleware’u:

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.
    }
  },
}))

To brak standardowego kodu pomocniczego stanowi praktyczną różnicę w porównaniu z klasycznymi konfiguracjami async w Redux.

Middleware: zintegrowane funkcje

Middleware otacza twórcę sklepu danych. Poniżej przedstawiono najczęściej używane elementy.

Persist — przechowywanie danych po ponownym załadowaniu

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.
    }
  )
)

Temat (lub inne wybrane pola) są przywracane po odświeżeniu. Narzędzia deweloperskie → Application → Local Storage pokazują plik JSON.

Narzędzia deweloperskie — inspekcja akcji

Mimo nazwy Redux, rozszerzenie przeglądarki funkcjonuje poprawnie, gdy sklep danych jest otoczony modułem devtools:

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 — aktualizacje wewnątrz innych aktualizacji bez użycia spread

Bólowe, niezmienne, nawarstwione struktury:

// Without immer, updating a deeply nested field:
updateCity: (city) => set((state) => ({
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      address: {
        ...state.user.profile.address,
        city,
      },
    },
  },
}))

Immer sprawia, że aktualizacje wyglądają na zmienne, mimo że w rzeczywistości pozostają niezmienne:

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.
      }),
  }))
)

Słuchacze zrozumiałe dla selektorów

Dla słuchaczy spoza React, które powinny zostać uruchomione tylko wtedy, gdy zmieni się wybrana część danych, Zustand dostarcza middleware subscribeWithSelector:

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.
  }
)

Nawarstwianie

const useStore = create(
  persist(
    devtools(
      subscribeWithSelector(
        immer((set, get) => ({
          // your store definition
        }))
      ),
      { name: 'MyStore' }
    ),
    { name: 'my-store-storage' }
  )
)

Raz brzydko, potem zapomniane.

Cebula middleware – każda warstwa dodaje jedną funkcjonalność.

Części danych, które się skalują

Jeden plik jest w porządku, dopóki nie zderzą się funkcje związane z koszykiem zakupów, autoryzacją, tematem i powiadomieniami. Fabryki części danych utrzymują oddzielne domeny przy tworzeniu jednego magazynu danych:

// 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),
}))

Komponenty nadal wybierają to, czego potrzebują. Po około pięciu domenach części danych wymagają dodatkowych zasobów; przed tym momentem jeden plik jest bardziej przejrzysty.

Testowanie bez uruchamiania interfejsu

Magazyny danych to zwykłe moduły – można je sformatować, wywołać akcje i sprawdzić wyniki:

// 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)
  })
})

Brak renderera, brak sztucznego dostawcy — testy jednostkowe pozostają szybkie i dokładne.

Projekt: Stacja nawadniania roślin

Zbuduj narzędzie do śledzenia pielęgnacji roślin: dodawaj rośliny z częstotliwością podlewania, oznaczaj te, które zostały podlane, wyróżniaj te z opóźnieniem, pokazuj statystyki oraz przechowuj dane po ponownym załadowaniu. Funkcje:

  1. Dodanie rośliny (nazwa + liczba dni między podlewaniem)
  2. Lista roślin
  3. Podlewanie jednym kliknięciem
  4. Oznaczanie roślin potrzebujących wody
  5. Usuwanie roślin
  6. Panel statystyk
  7. Zachowanie danych po odświeżeniu

Celowa aplikacja na jednej stronie. Cztery komponenty, jeden magazyn danych — szczęśliwe rośliny.

Plik 1 — magazyn danych

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

Plik 2 — formularz dodawania

// 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

Plik 3 — lista roślin

// 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

Plik 4 — statystyki

// 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

Plik 5 — struktura aplikacji

// 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

Plik 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; }

Zainstaluj npm run dev, dodaj rośliny, odśwież stronę, upewnij się, że pozostają, podlej jedną z nich i obserwuj, jak resetują się daty. Pamięć lokalna przenosi te same dane do drugiej karty.

To Twoja gotowa aplikacja – możesz ją teraz stylizować według własnych preferencji.

Weryfikacja za pomocą narzędzi przeglądarki

Aplikacja / Pamięć → Pamięć lokalna → klucz plant-hydration-v1 przechowuje zapisane dane w formacie JSON. Middleware Persist zapisuje zmiany i ponownie je przywraca przed renderowaniem. Dzięki middleware devtools w połączeniu z rozszerzeniem Redux DevTools każda akcja jest widoczna wraz z informacjami o czasie wykonania, szczególnie przy większych ilościach danych.

Częste błędy

  1. Hook dla całej pamięci — użycie useStore() bez selektora powoduje ponowne renderowanie przy każdej zmianie. Zawsze należy używać selektora.
  2. Czyste obiekty z selektorów — problem można rozwiązać za pomocą useShallow lub podziałem hooków.
  • Zachowywanie wartości pochodnych — oblicz thirstyCount na podstawie plants; nie przechowuj drugiego, przestarzałego licznika.
  • Brak pola name — obowiązkowe; jego pominięcie powoduje błąd przy uruchamianiu.
  • Jeden mega-magazyn na zawsze — dziel go, gdy liczba domen rośnie.
  • Ceremonia Redux w Zustand — pomijaj enumy typów działań oraz ogromne funkcje switch reducer, chyba że pojawi się rzeczywista potrzeba.
  • Zapominanie o singletonach — jeden create na moduł jest współdzielony; istnieją również standardowe API do obsługi potrzeb indywidualnych instancji.
  • Pomijanie testów magazynu — użyj getState, wykonaj akcję, sprawdź wynik; w ten sposób szybko wykryjesz regresje.
  • Kiedy Zustand to niewłaściwy narzędzie

    • useState — prawdziwie lokalna interfejs użytkownika (modały, efekty hover, wstępne wersje pól).
  • TanStack Query / SWR — pamięć cache serwera, ponowne pobieranie danych, usuwanie duplikatów, optymistyczne aktualizacje zdalne. Query do zarządzania stanem serwera w połączeniu z Zustand do stanu klienta.
  • Context — rzadkie aktualizacje (tokeny tematów) rozpowszechniane wzdłuż poddrzewa.
  • Keep Redux — istniejące bazy kodu oparte na Redux, duże inwestycje w narzędzia do pracy z czasem, lub naprawdę złożone procesy, w których struktura już sama się opłaca. Migruj wtedy, gdy oszczędności przewyższą koszt migracji.
  • Szkic w TypeScript

    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,
    }))
    

    Jeden typ reprezentujący strukturę sklepu plus create<...>; resztę zajmuje automatyczna dedukcja.

    Szeroki obraz

    Zustand nie usunął istniejącej bazy zainstalowanych rozwiązań Redux — przepisywanie kodu jest kosztowne — ale nowe aplikacje React coraz częściej wybierają lżejsze rozwiązania do przechowywania danych na stronie klienta. Ankiety satysfakcji z ostatnich edycji State of React umieszczają Zustand na czele listy rozwiązań, których użytkownicy chcieliby ponownie użyć. Typowa konfiguracja z 2026 roku to: Zustand do przechowywania stanu na stronie klienta, TanStack Query do stanu serwerowego, czasami elementy Jotai, Context do tematyki i ustawień, Redux Toolkit tylko wtedy, gdy już jest obecny, oraz zwykły useState do obsługi lokalnej interfejsu użytkownika.

    Taka kombinacja zazwyczaj umożliwia szybsze wdrożenie niż rozwiązania oparte na Redux i mniej sprawia problemów programistom w codziennej pracy.

    Zustand Bear

    Zespoły produkcyjne nadal dokumentują konwencje przechowywania danych (nazywanie, granice fragmentów, klucze trwałości), ponieważ swoboda bez norm powoduje chaos. Zaletą jest to, że te konwencje pozostają proste: selektory są wąsko skonfigurowane, akcje znajdują się obok stanu, middleware jest używany celowo, a pamięć serwera nie ingeruje w przechowywanie danych na stronie klienta. Dzięki zachowaniu tych jasnych granic Zustand pozostaje rozwiązaniem składającym się z dziesięciu linijek w odpowiedzi na setki linijek w starter kit Redux – bez potrzeby udawania, że każde aplikacja jest wiecznie tylko prostym przykładem licznika.

    Poza przykładem pliku z danymi aplikacji, te same wzorce można zastosować do koszyków zakupowych, flag funkcjonalnych, wstępnych projektów oraz elementów graficznych interfejsu. Zacznij od jednego pliku sklepu, dodaj mechanizm przechowywania danych, gdy użytkownicy nie chcą tracić swoich prac, dodaj narzędzia DevTools, gdy błędy stają się trudne do wykrycia, wprowadź funkcję slice’ów, gdy pasek przewijania pliku staje się bezużyteczny, a Query zachowaj do wszystkiego, co wymaga komunikacji z API. Taka kolejność kroków odpowiada sposobowi rozwoju większości udanych baz kodowych Zustand: stopniowo, w sposób jasny i bez zbędnych formalności, które nie zapewniają bezpieczeństwa.

    Głębsze zagłębienie się w selektory i wydajność

    Dyscyplina przy tworzeniu selektorów decyduje o różnicy pomiędzy szybkim panelem sterowania a tym, który zachowuje się niespodziewanie wolno. Każde niepotrzebne ponowne renderowanie powoduje ponowną eksploatację kodu JSX, wykonywanie efektów zależnych od właściwości oraz ponowną synchronizację elementów potomnych. Model słuchaczów w Zustand jest wydajny tylko wtedy, gdy selektory zwracają stabilne wartości lub starannie porównywane struktury.

    Zaleca się wybieranie wartości logicznych, liczb i ciągów znaków. Gdy wybrana zostanie funkcja akcji, zazwyczaj pozostaje stabilna podczas aktualizacji, ponieważ znajduje się w obiekcie store, więc łączenie count i increment w dwóch hookach jest w porządku. Unikaj wybierania całych tablic, jeśli komponent potrzebuje tylko items.length; zamiast tego wybierz długość (lub pochodną wartość logiczną), aby operacje push w innych miejscach nie uruchamiały nieużywanych komponentów.

    Równość ma znaczenie. Domyślną funkcją porównywania jest Object.is. Dlatego zwracanie { a, b } z selektora nie działa: nowy obiekt nie spełnia warunków Object.is, nawet gdy a i b pozostają niezmienione. Funkcja useShallow porównuje pola na jednym poziomie. W przypadku złożonych struktur należy albo znormalizować stan tak, aby interfejs wyświetlał pola w formie płaskiej, albo celowo obliczyć prosty identyfikator wartości.

    Listy wymagają szczególnej troski. Mapowanie plants na trzy komponenty jest w porządku, jeśli każdy z nich potrzebuje jedynie odniesienia do tablicy w momencie zmiany przynależności lub tożsamości elementu. Jeśli jeden panel pokazuje tylko nazwy, rozważ użycie selektora, który zwraca posortowaną listę identyfikatorów; pozostaje on stabilny, gdy zmieniają się niepowiązane pola dotyczące roślin.

    Pułapki związane z przechowywaniem danych i wersjonowanie

    Middleware do przechowywania danych wydaje się magiczny, dopóki nie nastąpi zmiana schematu. Zawsze ustaw stabilną wartość name dla klucza przechowywania. Gdy zmienia się struktura przechowywanego stanu, zwiększ wersję i dostarcz funkcję migrate, aby stary JSON nie powodował awarii aplikacji podczas ładowania. Częściowe przechowywanie (partialize) zapobiega umieszczaniu poufnych danych oraz tymczasowych flag interfejsu w Local Storage – tokeny i jednorazowe informacje dotyczące modali rzadko powinny znajdować się na dysku.

    Należy zwracać uwagę na moment nawadniania: pierwsze renderowanie klienta może przez chwilę pokazywać wartości domyślne, zanim zakończy się proces nawadniania. W przypadku SSR lub frameworków, które rysują treść na serwerze, należy ukryć interfejs użytkownika zależny od wartości utrwalonych za pomocą flagi nawadniania lub zaakceptować chwilowe pokazanie domyślnego tematu. Należy udokumentować wybrany podejście, aby współpracownicy nie „naprawiali” błędów powstałych w wyniku szybkich zmian, które w rzeczywistości są skutkiem procesu nawadniania.

    Zachowanie między kartami również może zaskoczyć. Zapisy w Local Storage z jednej karty są widoczne w innych poprzez zdarzenie storage, ale domyślna ścieżka utrwalania danych w Zustand nie łączy automatycznie jednoczesnych edycji. W przypadku kart służących do współpracy należy albo przyjąć zasadę „ostatni zapis zwycięża”, albo dodać dodatkową warstwę synchronizacji typu BroadcastChannel nad bazą danych.

    Projektowanie działań, które pozostają nudne

    Działania o dobrym stanie są krótkie, nazwane zgodnie z intencją użytkownika i nie zawierają JSX. addPlant, waterPlant oraz removePlant są lepsze od setPlants pod względem przejrzystości interfejsu użytkownika. Walidację należy umieścić blisko samego działania: odrzucać puste nazwy, ograniczać interwały podlewania oraz ignorować nieznane identyfikatory. Wczesne zakończenie działania jest jaśniejsze niż pozostawianie błędnych danych w pamięci aplikacji na cały cykl renderowania.

    Działania asynchroniczne powinny ustawiać wyraźne pola loading i error, gdy interfejs musi odzwierciedlać postęp. Logowanie typu „wystrzel i zapomnij” może pominąć te flagi. Gdy kilka wywołań asynchronicznych zachodzi jednocześnie, należy zapisywać identyfikator żądania lub używać kontrolera anulowania, aby starsza odpowiedź nie mogła przepisać nowszej. Wszystko to nie wymaga middleware – wystarczy staranne uporządkowanie sekwencji ustawień.

    Pomocniki pochodne mogą istnieć jako zwykłe funkcje poza sklepem (najłatwiej je testować) lub jako funkcje pobierające dane obliczane wewnątrz selektorów. Wolimy funkcje czyste, importowane zarówno przez sklep, jak i interfejs użytkownika, aby Jest mógł sprawdzić logikę podlewania bez użycia Reacta.

    Notatki dotyczące przeglądania aplikacji do uprawy roślin

    Podczas wklejania sześciu plików zachowaj spójne ścieżki importów z szablonem Vite (../store/... z components). Jeśli lista wygląda na pustą po odświeżeniu, sprawdź klucz persist w narzędziach deweloperskich i upewnij się, że name: 'plant-hydration-v1' odpowiada twoim oczekiwaniom. Podlewanie powinno aktualizować lastWateredAt (lub odpowiednik), dzięki czemu selektor dotyczący suszy zmieni się bez konieczności pełnego ładowania strony.

    Stylizacja w pliku App.css jest celowo prosta. Można swobodnie zmieniać czcionki i kolory; celem nauki jest przepływ danych, a nie wygląd wizualny. Dodanie czwartego komponentu — na przykład przycisku „nawadniać wszystkie spragnione” — to przydatne ćwiczenie: należy wybrać odpowiednie identyfikatory, a następnie wywołać funkcję, która przeprowadzi nawadnianie dla tych identyfikatorów w ramach jednego set. To ćwiczenie wzmacnia zasadę grupowania aktualizacji zamiast wykonywania pętli set wewnątrz komponentu.

    Zasady zespołu, które warto zapisać

    Zgodnijcie się co do struktury plików (stores/ kontra foldery z funkcjami umieszczone razem), nazywania (useXStore) oraz tego, czy operacje mogą bezpośrednio wywoływać API, czy muszą przechodzić przez mutację Query, która następnie aktualizuje Zustand. Wiele zespołów całkowicie zabrania przechowywania list serwerów w Zustand, zachowując tam jedynie tymczasowe flagi klienta. Zapiszcie tę zasadę raz w README – zapobiegnie to temu, by połowa kodu ponownie tworzyła drugi cache.

    Poznaczcie również zasadę nazywania w DevTools: middleware devtools przyjmuje nazwę sklepu, dzięki czemu panel rozszerzeń pozostaje czytelny, gdy otwarte są pięć sklepów. Klucze persist powinny być naznaczane przestrzenią nazw aplikacji (myapp-theme-v1), aby uniknąć kolizji na wspólnych domenach.

    Porównywanie wpływu emocjonalnego, a nie tylko liczby linii kodu

    Redux Toolkit znacznie skrócił klasyczny Redux, więc porównanie „100 linii kontra 10” jest częściowo retoryczne. Głębszy kontrast dotyczy poziomu złożoności koncepcyjnej: oddzielne fragmenty aplikacji kontra jedna funkcja create, reduktory kontra operacje wykonywane bezpośrednio w komponentach, łańcuchy middleware kontra opcjonalne otulacze, drzewa Provider kontra singletoni modułów. Inżynierowie, którzy już myślą w kategoriach reduktorów, mogą efektywnie pracować w obu podejściach. Ci, którzy chcą wspólnego stanu klienta bez przywiązania do mechanizmów typu state-machine, zazwyczaj szybciej realizują funkcjonalności w Zustand.

    To wszystko nie usprawiedliwia pomijania przeglądów kodu. Sklep składający się z dziesięciu linii może nadal zawierać błędy bezpieczeństwa, jeśli przechowuje dane osobowe bez zgody użytkownika, lub błędy wydajnościowe, jeśli każdy komponent musi pobierać te same dane. Traktuj takie sklepy jak publiczną API modułów: używaj stabilnych nazw akcji, dokumentuj klucze służące do przechowywania danych oraz pisz testy dla trudnych przypadków (puste listy, nieważne identyfikatory, nieudane pobierania danych).

    Zamknięcie pętli uczenia się

    Gdy aplikacja do zarządzania roślinami zacznie działać, celowo ją uszkodź: usuń selektor, zwróć literal obiektu, przechowuj dane bez nazwy, zapisz wyliczoną liczbę. Przyjrzyj się raz trybom awarii, aby można je było rozpoznać podczas przeglądania kodu. Następnie przywróć ustalone wzorce. Ten krótki eksperyment przyczynia się bardziej do długoterminowego zapamiętywania niż kolejna udoskonalona demonstracja licznika.

    Zustand zakłada, że większość stanów klienta jest nudna — flagi, projekty, koszyki, informacje o przeglądarce — a nudny stan zasługuje na nudną API. Przechowuj prawdziwe dane serwera w specjalnie stworzonej bibliotece cache, lokalną interfejs użytkownika w useState, a specjalne rozwiązania przeznaczaj na te aspekty klienta, które sprawiają problemy podczas przekazywania danych. Robiąc to konsekwentnie, twierdzenie o „dziesięciu liniach kodu” przestaje brzmieć jak slogan marketingowy i zaczyna odzwierciedlać rzeczywisty kształt bazy kodu.

    Literatura pokrewna

  • Context, Zustand czy Redux Toolkit: wybór w zależności od złożoności przejść — Przestań wybierać biblioteki do zarządzania stanem na podstawie wielkości aplikacji. Context rozdziela zależności drzewa, Zustand usprawnia subskrypcje, a Redux Toolkit modeluje wyraźne przejścia.