Стан на практиці: маленькі магазини, точні селектори, трекер рослин
Замініть методи prop-drilling та Redux ceremony на create(), selectors, async actions, middleware persist/devtools, slices та додаток hydration plant.
Посібник для початківців з Zustand — бібліотеки стану для React, яка має десятки мільйонів щотижневих завантажень, разом із прикладом догляду за рослинами, створеним від початку до кінця.
У той момент, коли хтось нарешті скаже вам, що існує кращий спосіб
До Zustand: що насправді робить React
Веб-сторінки починаються з HTML-тегів — div, button, h1. Ручна редагування такої маркувальної мови для великого продукту є складною: кожна зміна даних означає пошук елементів та їх переписування. Змінилися дані входу? Потрібно виправити десяток місць. Зріс кошик? Потрібно виправити ще десяток.
React змінює підхід. Компоненти — це функції, які описують інтерфейс на основі поточних даних; бібліотека вирішує, що саме потрібно змінити в DOM. Автори описують, а React оновлює.
Мініатюрний компонент виглядає так:
function WelcomeBanner() {
return <h1>Hello, stranger</h1>
}
Ця функція повертає JSX — синтаксис у форматі HTML, який компілює React, а не справжній HTML.
Короткий глосарій для наступних розділів:
- JavaScript — мова, яка лежить в основі
console.logтаconst x = 5. - npm — інструмент для встановлення пакетів. Команда
npm install zustandзавантажує Zustand у проект. - Hooks — функції, назви яких починаються з
use(useState,useEffect, власні hooks для зберігання даних). Вони є сучасним інтерфейсом для роботи з даними та ефектами в React.
Якщо ці три концепції стануть зрозумілими, решта цього посібника також буде легко засвоїтися.
React — це шеф-кухар. Ви передаєте йому рецепт та інгредієнти.
Що насправді означає „стан“
Стан — це все, що інтерфейс має пам’ятати: чи увійшов користувач чи ні, розмір кошика, чи відкрита бічна панель, URL аватара, текст у полі пошуку. Звичайні змінні не оновлюють екран автоматично. Хуки React зберігають значення та заплановують повторне відрендерування, коли ці значення змінюються.
Найпростіший хук — це 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>
)
}
Локальний стан є корисним, коли один компонент володіє значенням. Проблеми виникають, коли віддалені компоненти мають ділитися однаковою інформацією — формою входу, аватаром у заголовку, привітанням на панелі керування, функцією виходу з налаштувань — не будучи розташованими поруч у структурі компонентів.
Окремі виклики useState не спілкуються між собою. Справжня проблема — обмін даними між віддаленими елементами інтерфейсу.
Окремі стани, які не можуть спілкуватися один з одним. Проблема представлена в одному зображенні.
Проблеми передачі даних через пропси
Перша відповідь React — це «підняття стану вгору»: розміщувати спільні дані у найближчому спільному предку та передавати їх далі як параметри (аргументи функції).
Це працює для невеликих структур. Проблеми виникають, коли user мусить проходити шлях App → Layout → MainContent → Dashboard → Header → ProfilePic. Проміжні шари ніколи не використовують ці дані; вони лише їх передають. Теми, кошики та токени автентифікації додають ще один етап передачі через байдужих предків. Рефакторинги пошкоджують не пов’язані файли.
Context був створений для того, щоб зупинити цю послідовну передачу. Provider обгортає структуру; дочірні елементи викликають useContext. Проблема полягає у тому, що споживачі часто перерендеруються при зміні будь-якої частини значення контексту, що ускладнює роботу з частим оновленням стану.
Redux (2015) запропонував прогнозований зовнішній стан та селектори, які перерисовують лише те, що змінилося — а також ввів багато формальностей, підтримка яких втомила команди.
З’являється ведмідь
Zustand походить від колективу Poimandres Пола Геншеля (який також стоїть за React Three Fiber та Jotai). Це німецьке слово, що означає „стан“. Є маскот у вигляді ведмедя. Проект має десятки тисяч зірок на GitHub та близько двадцяти мільйонів щотижневих завантажень через npm — більше, ніж класичний Redux разом із Redux Toolkit у багатьох останніх тижнях.
Усі основні API — це одна функція create. Передається визначення стану та дій; отримується хук React. Немає Provider. Немає констант типів дій. Немає окремих файлів-редюсерів. Приклад:
import { create } from 'zustand'
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
Будь-який компонент може викликати цей хук. Немає необхідності у послідовних викликах.
Кожен компонент спілкується безпосередньо з магазином даних. Не потрібні послідовні виклики.
Ментальна модель: магазин — це повідомлення у груповому чаті, яке закріплене та доступне для читання та редагування всіма. Redux нагадує судову зали — потрібно подати заяву (дію), чекати рішення редьюсера, переглядаючи інформацію через контрольоване вікно. Redux досягає передбачуваності за рахунок бюрократії; Zustand покладається на дисципліновані оновлення з меншою кількістю документації.
Перший справжній магазин
Створіть проект Vite React та встановіть бібліотеку:
npm create vite@latest my-first-zustand -- --template react
cd my-first-zustand
npm install
npm install zustand
npm run dev
Створіть файл 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.
Використовуйте його:
// 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
Розмістіть <Counter /> будь-де. Цифри змінюються. Зверніть увагу на відсутні елементи: обгортки Provider, ініціалізація контексту, константи дій, редьюсери, connect, mapDispatchToProps, налаштування thunk. Лише один виклик create та хук.
«Чекайте, це справді все?»
Як це працює
create створює звичайний об’єкт для зберігання стану та порожній список слухачів у межах модуля (синглтон відразу після завантаження модуля).
Коли компонент використовує цей хук:
- Zustand виконує селектор (наприклад
(state) => state.count) щодо поточного стану та повертає відповідний фрагмент даних. - Він реєструє компонент разом із селектором як слухача. Під час подальших оновлень він знову виконує кожен селектор та перерисовує лише у разі зміни обраного значення.
У основному шляху виконання немає React Context — це скоріше маленький емітер подій із допомогою React. Оскільки сховище знаходиться поза деревом React, кожен імпорт того самого модуля використовує один екземпляр. Існують звичайні сховища з кількома екземплярами для рідких випадків, коли це необхідно; більшість додатків ніколи не потребують такого рішення.
Весь життєвий цикл: чотири кроки та цикл зворотного зв’язку
Селектори (не пропускайте)
Селектори визначають коли компонент буде перероблений.
Шаблон А — весь магазин даних (зазвичай неправильно):
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.
Шаблон Б — одне поле (зазвичай правильно):
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.
Шаблон В — пастка об’єкта-літерала:
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.
Кожного разу створюється новий об’єкт, тож він здається «новим», навіть якщо поля залишилися незмінними, тому переробка відбувається постійно.
Поганий селектор зліва. Хороший селектор справа. Різниця є значною у великих додатках.
Потрібно кілька полів без постійного створення об’єктів? Використовуйте 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.
Багато проектів у продакшені воліють використовувати кілька обмежених викликів хуків. Основне правило: беріть найменшу частину даних; якщо не впевнені, краще розділити хуки, ніж об’єднувати їх.
Дії: синхронні та асинхронні
Дії знаходяться у тому самому об’єкті, що й стан. Для синхронних оновлень використовується 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 дозволяє отримати поточний стан у межах дії без реєстрації слухача React — це зручно для прийняття рішень:
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' }
},
}))
Асинхронні операції виконуються за допомогою звичайних конструкцій async/await — без додаткових механізмів thunk-мідлверу:
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.
}
},
}))
Саме відсутність зайвого коду для обробки потоку даних є практичною відмінністю від класичних асинхронних налаштувань Redux.
Мідлвер: накопичені функції
Мідлвер обгортає функцію створення сховища. Нижче наведені поширені компоненти мідлверу.
Persist — збереження даних після перезавантаження
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.
}
)
)
Тема (або будь-які інші поля, які ви оберете) залишається після оновлення. У інструментах розробника → Application → Local Storage відображається JSON-файл.
Інструменти розробника — перевірка дій
Незважаючи на брендування Redux, розширення браузера працює, коли сховище обгорнуте функцією 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 — внутрішнє оновлення даних без використання конструкцій spread
Болісні незмінні вкладені спреди:
// 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 змушує оновлення виглядати як змінні, хоча насправді залишаються незмінними:
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.
}),
}))
)
Зовнішні слухачі, обізнані про селектори
Для слухачів, що не належать до React та мають запускатися лише при зміні обраної частини даних, Zustand надає середовище 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.
}
)
Накладання шарів
const useStore = create(
persist(
devtools(
subscribeWithSelector(
immer((set, get) => ({
// your store definition
}))
),
{ name: 'MyStore' }
),
{ name: 'my-store-storage' }
)
)
Один раз створено — потім забуто.
Це „цибулина“ середовищ: кожен шар додає одну функціональність.
Частини даних, які підлаштовуються під масштаб
Один файл підходить, доки не починають конфліктувати функції корзини, автентифікації, теми та сповіщень. Фабрики частин даних зберігають окремі домени під час створення єдиного сховища:
// 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),
}))
Компоненти все одно обирають те, що їм потрібно. При близько п’яти доменах частини даних „платять оренду“; до цього кількості один файл є зрозумілішим.
Тестування без монтування UI
Сховища — це звичайні модулі. Скидати стан, викликати дії, перевіряти результати:
// 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)
})
})
Без движка відображення, без штучного провайдера — тестування на одиницю залишається швидким та надійним.
Проєкт: Станція зрошення рослин
Створіть інструмент для відстеження догляду за рослинами: додавайте рослини з інтервалом поливу, позначайте, що вони политі, виділяйте ті, яким потрібен полив, відображайте статистику та зберігайте дані після перезавантаження. Функції:
- Додавання рослини (назва + інтервал між поливами)
- Список рослин
- Полив одним кліком
- Позначення рослин, яким потрібен полив
- Видалення рослин
- Панель статистики
- Збереження даних після оновлення
Уся програма на одній сторінці. Чотири компоненти, один сховище — щасливі рослини.
Файл 1 — сховище
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
Файл 2 — форма додавання
// 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
Файл 3 — список
// 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
Файл 4 — статистика
// 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
Файл 5 — основа додатку
// 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
Файл 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; }
Запустіть npm run dev, додайте рослини, оновіть сторінку, переконайтеся, що вони залишилися, полийте одну з них та спостерігайте, як оновлюються часові позначки. Локальне зберігання передає ті самі дані у другу вкладку.
Це ваш готовий додаток — ви можете налаштовувати його зовнішній вигляд на свій смак.
Перевірка за допомогою інструментів браузера
Application / Storage → Local Storage → ключ plant-hydration-v1 містить збережені дані у форматі JSON. Middleware для зберігання записує зміни та відновлює дані перед їх відображенням. За допомогою devtools та розширення Redux DevTools кожна дія відображається з інформацією про час виконання, особливо у великих базах даних.
Поширені помилки
- Hook для всього магазину даних —
useStore()без вибору елементів переробляє сторінку після кожної зміни. Завжди використовуйте вибір елементів. - Свіжі об’єкти з селекторів — виправте це за допомогою
useShallowабо розділіть хуки.
thirstyCount на основі plants; не тримайте другий застарілий лічильник.name — обов’язкова; його відсутність спричиняє помилку під час запуску.create на модуль використовується спільно; існують стандартні API для потреб окремих екземплярів.getState, виконання дії, перевірка; виявляйте регресії на ранніх етапах.Коли Zustand — це неправильний інструмент
useState— справді локальний UI (модали, ефекти наведення курсору, чернетки полів).
Ескіз на 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,
}))
Один тип у форматі store-шейп плюс create<...>; інференція покриває все інше.
Ширша картина
Zustand не стер вже існуючу базу Redux — переписування коду є дорогим процесом — але нові React-додатки все частіше використовують легші клієнтські сховища даних за замовчуванням. Опитування задоволеності у нещодавніх дослідженнях State of React поставили Zustand майже на перше місце серед тих, які б користувалися знову. Типова структура для 2026 року: Zustand для клієнтського стану, TanStack Query для серверного стану, іноді Jotai atoms, Context для теми/налаштувань, Redux Toolkit лише у разі вже наявності, а звичайний useState для локального інтерфейсу.
Ця комбінація зазвичай дозволяє швидше розпочати роботу порівняно з фреймворками, орієнтованими на Redux, та менше створює проблем для розробників у повсякденній роботі.
Zustand Bear
Команди з розробки все ще документують конвенції зберігання даних (найменування, межі сегментів, ключі зберігання), тому що свобода без норм призводить до хаосу. Перевагою є те, що ці конвенції залишаються простими: селектори є вузькоспрямованими, дії знаходяться поруч із станом, мідлвейр використовується навмисно, а кеш сервера не втручається у клієнтське зберігання. Якщо підтримувати чіткі межі, Zustand залишається рішенням у десять рядків замість ста-рядкового початкового набору Redux — без необхідності вважати кожен додаток вічною демонстрацією лічильника.
Окрім зразка коду, ці ж принципи застосовуються до кошиків, флагів функцій, чернеток майстра та додаткових елементів інтерфейсу. Почніть з одного файлу магазину, додайте функцію збереження даних, коли користувачам не подобається втрачати роботу, додайте інструменти DevTools, коли баги стають складними для виявлення, впровадьте механізм slices, коли панель прокрутки файлу стає безполезною, та залиште Query для всього, що взаємодіє з API. Такий підхід відповідає способу розвитку найбільш успішних кодових баз Zustand: поступово, чітко та без зайвих формальностей, які не забезпечують безпеки.
Глибше розгляд селекторів та продуктивності
Дисципліна у використанні селекторів — це ключ до швидкого панелі керування та її незрозуміло повільної роботи. Кожна зайва перерендерація призводить до повторного виконання JSX, ефектів, які залежать від параметрів, та процесу узгодження даних дочірніх елементів. Модель слухачів Zustand є ефективною лише тоді, коли селектори повертають стабільні примітивні дані або ретельно порівняні структури.
Краще обирати булеві значення, числа та рядки. Коли обирається функція дії, вона зазвичай залишається стабільною під час оновлень, оскільки знаходиться у об’єкті store, тому поєднання count та increment у двох хуках є прийнятним. Уникайте обрання цілих масивів, якщо компоненту потрібен лише items.length; краще обрати довжину (або похідне булеве значення), щоб операції push в інших місцях не активували неактивні віджети.
Рівність має значення. За замовчуванням використовується порівняння Object.is. Саме тому повернення { a, b } з селектора призводить до невдачі: новий об’єкт не пройде перевірку Object.is, навіть якщо a та b залишилися незмінними. Функція useShallow порівнює поля лише на одному рівні. Для глибоких структур або нормалізуйте стан, щоб UI читав плоскі поля, або навмисно обчислюйте примітивний ідентифікатор.
Списки потребують особливої уваги. Розбиття plants на три компоненти є прийнятним, якщо кожен з них потребує лише посилання на масив у момент зміни членства або ідентичності елемента. Якщо одна панель відображає лише назви, розгляньте можливість використання селектора, який повертає відсортований рядок ідентифікаторів; це забезпечує стабільність при змінах у полях, що не стосуються рослин.
Проблеми постійного зберігання та версіонування
Механізми постійного зберігання здаються чарівними, поки не відбувається зміна схеми. Завжди встановлюйте стабільне name для ключа зберігання. Коли формат постійно зберіганого стану змінюється, підвищуйте версію та надайте функцію migrate, щоб старий JSON не заважав роботі додатку під час завантаження. Часткове зберігання (partialize) дозволяє утримувати секрети та тимчасові позначки інтерфейсу поза Local Storage — токени та одноразові флаги для модалних вікон рідко мають зберігатися на диску.
Зверніть увагу на час гідратації: під час першого відображення клієнта можна тимчасово побачити значення за замовчуванням, доки не завершиться процес гідратації. Для SSR або фреймворків, які відображають контент на сервері, необхідно приховати UI, який залежить від збережених значень, за флагом гідратації, або прийняти тимчасове використання теми за замовчуванням. Документуйте обраний підхід, щоб колеги не намагалися «виправити» помилки, які насправді є наслідком конкуренції під час гідратації.
Поведінка між вкладками також дивує користувачів. Записи у Local Storage з однієї вкладки стають видимими в інших через подію storage, але стандартний шлях зберігання у Zustand не об’єднує автоматично одночасні зміни. Для спільного використання вкладок потрібно або застосувати принцип «останній запис — останній», або додати окремий шар синхронізації BroadcastChannel поверх магазину даних.
Проектування дій, які залишаються простими
Дії у хорошому стані є короткими, названі відповідно до наміру користувача та не містять JSX. addPlant, waterPlant та removePlant краще за setPlants у сенсі оновлення інтерфейсу. Залишайте перевірку близько до самої дії: відхиляйте порожні назви, обмежуйте інтервали поливу, ігноруйте невідомі ID. Раннє повернення з дії є зрозумілішим, ніж дозволяти поганим даним залишатися у сховищі протягом циклу відображення.
Асинхронні дії повинні встановлювати чіткі поля loading та error, коли інтерфейс має відображати статус виконання. Журналування типу „запустити та забути“ може пропускати ці позначки. Коли кілька асинхронних викликів виконуються одночасно, необхідно зафіксувати ID запиту або використати механізм скасування, щоб старіша відповідь не могла перезаписати новішу. Для цього не потрібен мідлвейр — достатньо уважної послідовності виконання команд set.
Допоміжні функції, створені на основі цього коду, можуть існувати як звичайні функції поза магазином даних (що полегшує тестування), або як геттери, обчислювані всередині селекторів. Варто використовувати чисті функції, які імпортуються як магазином даних, так і інтерфейсом користувача, щоб Jest міг перевіряти логіку поливу без використання React.
Примітки до пошагового використання додатку для рослин
Під час вставки шести файлів забезпечте послідовність шляхів імпорту з темплейтом Vite (../store/... з components). Якщо список виглядає порожнім після оновлення, перевірте ключ зберігання в інструментах розробника та переконайтеся, що name: 'plant-hydration-v1' відповідає вашим очікуванням. Полив має оновлювати значення lastWateredAt (або еквівалентне), щоб селектор, який визначає стан рослини, змінювався без повного перезавантаження сторінки.
Стилізація у App.css є навмисно простою. Ви можете вільно змінювати шрифти та кольори; метою навчання є розуміння потоку даних, а не візуальна доробка. Додавання четвертого компонента — наприклад, кнопки «Полити всіх спраглих» — є корисним вправою: виберіть ідентифікатори спраглих елементів, а потім викличте дію, яка буде поливати їх усіх за один раз через set. Ця вправа допомагає закріпити ідею групових оновлень замість постійного використання циклу set у компоненті.
Конвенції команди, які варто записати
Домовтесь щодо структури файлів (stores/ проти окремих папок для функцій), найменування (useXStore) та про те, чи можуть дії безпосередньо викликати API чи мають проходити через зміну Query, яка потім оновлює Zustand. Багато команд забороняють взагалі зберігати списки серверів у Zustand, залишаючи там лише тимчасові прапорці клієнта. Запишіть це правило в README один раз — це запобіжить тому, що половина кодбази буде заново створювати кеш.
Також домовтесь щодо найменування інструментів DevTools: мідлвейр devtools приймає назву сховища, щоб панель розширень залишалася зрозумілою, коли відкрито п’ять сховищ. Ключі для зберігання даних мають мати просторову назву за прикладом myapp-theme-v1, щоб уникнути конфліктів у спільних доменах.
Порівнюємо емоційний вплив, а не лише кількість рядків коду
Redux Toolkit значно скоротив класичний Redux, тож порівняння «100 рядків проти 10» є частково риторичним. Справжня різниця полягає у концептуальній структурі: окремі частини даних проти одного виклику create, редюсери проти дій без змін даних, канали середовища проти необов’язкових обгорток, дерева Provider проти модульних синглтонів. Інженери, які вже мислять у категоріях редюсерів, можуть ефективно працювати в будь-якому з цих підходів. Інженери, які хочуть спільний стан клієнта без обов’язкового використання механізмів стану, зазвичай швидше реалізовують функції за допомогою Zustand.
Це не виправдовує пропуск перевірок. Навіть десятирядковий склад може містити проблеми з безпекою, якщо він зберігає персональні дані без згоди користувача, або проблеми з продуктивністю, якщо кожен компонент виконує однакові операції. Ставтеся до такого складу як до публічного API модуля: стабільні назви дій, задокументовані ключі зберігання та тести для небажаних сценаріїв (порожні списки, недійсні ідентифікатори, невдалі запити).
Закриття циклу навчання
Після того, як додаток для роботи з заводами почне функціонувати, навмисно пошкодіть його: видаліть селектор, поверніть літераль об’єкта, збережіть дані без імені, збережіть похідну кількість. Ознайомтесь один раз із способами збою, щоб їх можна було розпізнати під час перегляду коду. Потім відновіть правильні патерни. Цей короткий експеримент сприяє довгостроковій пам’яті краще, ніж ще одна вдосконалена демонстрація лічильника.
Зустанд вважає, що більшість станів клієнта є нудними — прапорці, чернетки, кошики тощо — і нудні стани заслуговують на нудний API. Зберігайте справжні дані сервера у спеціально створеній бібліотеці кешу, місцевий UI — у useState, а складні клієнтські дані, які спричиняли проблеми під час роботи з пропами, залиште окремо. Дотримуйтесь цього принципу послідовно, і твердження про «десять рядків» перестане звучати як маркетинговий трюк та почне звучати як реальна структура кодової бази.