Стан у практыцы: маленькія магазіны, рэтельныя выбірачы, прыстрой для стежэння за растамі
Заменіце методы prop-drilling і Redux на create(), селектары, асінхронныя дзеяння, мідлвэры persist/devtools, слаўкі і аплікацыю у формате hydration plant.
Экскурсія для пачаткунава па Zustand — бібліятэцыі стану для React, якая атрымляе дзесяткі мільйонаў заванаў на тыдзень, а таксама прыклад дагляду за растамі, створаны з нуля.
Момент, калі хтось нарэшце скажае вам, што існуе кращы спосаб.
Да Zustand: што на самай працы робіць React
Веб-старонкі пачынаюцца з тагоў HTML — div, button, h1. Ручная правка такога маркапу для большага продукту ўскладнена: кожная змяна дадзеных значыць пошук элементаў і ўпісванне іх занова. Змянілася форма для заходжання? Патчаваць дзесятак месцаў. Расте кошык? Патчаваць ўсё тое ж дзесятак.
React перадаў гэты падход. Компаненты — это функцыі, якія описваюць UI на адной з сучасных дадзеных; бібліятэця вырашае, што патчаваць у 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 — гэта шафар. Вы даеце яму рэцепт і інгредыянты.
Што на самай працоў значыць “state”
Стан — это все, што інтэрфейс павінен запам’ятваць: чы ўжо адмаўкнулася або ні, размер кошыка, чы відкрываецца бокавая панель, 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 быў створаны калекtyваў Пола Хеншэла Poimandres (які таксама стоіць за React Three Fiber і Jotai). Назва на нямецкай мове значыць “стан”. Є таксама маскот у вобразе медведзя. Проект мае дзесяткі тысяч зірак у GitHub і адпраўляе працыга 20 мільйонаў разоў на тыдзень через 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.
}
)
)
Тэматыка (או будзь-якія іншыя поля, якія вы выберазе) вяртаецца пасля апдэйту. У рычызнах разработчыка → Аплікацыя → 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 — глыбокія апдэйты без викорыстання nested spreads
Болючыя незменныя вкладныя спрэды:
// Without immer, updating a deeply nested field:
updateCity: (city) => set((state) => ({
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city,
},
},
},
}))
Заўжды лепш, калі апцэнты вяртаюцца здаецца зменнымі, хоць на самай справе застаюцца незменнымі:
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. Мідлвэр, які відпавядае за зберагчыць даны, запісвае іх пасля кожных змян і восстанавлівае яны перад адрасаванням інфармацыі на экран. За дапамогою devtools і расширэння Redux DevTools кожная дзеянне праказваецца з інформацыяй пра час адбывання, ў большых сховішчах.
Частыя памылкі
- Whole-store hook —
useStore()без селектара перерысавае інтерфейс пасля кожной змены. Заўжды выбірайце селектар. - Франкі об’екты з селектараў — выправіце гэта за дапамогою
useShallowабо раздзеліце хуки.
thirstyCount на адказе з plants; не трэба зберагчы другі, застарелы лямдар.name — неабяжна; яго адсутнасць спрычыніць кашу пад запускам.create на модуль ўжо є спільная; існуюць стандартныя API для задач, якія трэба рашыць па інстанцы.getState, act, assert; так будзе можна рана з’явіць абнормальнасці.Калі 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,
}))
Адна вялічынь типу для хранення дадзенняў плюс create<...>; застатковыя задачы выконваюцца аўтаматычна.
Шырэй прагляд
Зустанд не стаў заменай ужо інсталаваных рашэнняў Redux — перапісва даных є даскладным процесам — але новыя праекты на React все частэй выбіраюць легкія кліентскія сховышча даных. Рэзультаты анкетаванняў па стану React у апошніх рэйдзах паказваюць, што Зустанд знаходзіцца сярод першых у категорыі «быў бы выкарыстана знова». Тыповая структура для 2026 года: Зустанд для кліентскага стану, TanStack Query для серверскага стану, іноды Jotai atoms, Context для тэмы/налашоўкаў, Redux Toolkit толькі калі ён вялікі ўжо, а просты useState — для локальнага інтерфейсу.
Такая комбінацыя зазвычай дапамагае шырэй распрацавацца быстрей, чым схемы, заснованыя на Redux, і менш стварае проблем для разработчыка ў павсякдзеннай работе.
Zustand Bear
Команды разработчыка ўсё яшчэ документуююць стандарты хранення данных (называнне, межы фрагментавання, ключы для зберагчэння), таму што свабода без норм прыводзіць да хаосу. Праўыла гэтых стандартоў застаюць простымі: селектары є вузкімі, дзеяння знаходзяцца празаўсёды ўжо разам з станом, мідлвэр выбіраецца спецыяльна, а кеш сервера не падключаецца да кліентскага хранення. Якшто падтрымваць чыстыя межы, то Zustand застаецца адпаведнем рашэнням у дзесять ліній у працоўнасупрацэўніку Redux з сотнямі ліній — без падазроў, што кожны прыклад застосунку будзе вечна толькі дэманстрацыяй лічыльніка.
Парадоксальна, але правда — тыя ж прынцыпы дапраўдзяюцца і для караваноў, фіч-флагоў, прачыртаванняў візарда і элементаў UI. Пачніце з адного файла магазіна, дадзіце падтрымку для зберагання дакументацыі, калі корыстувальнікі не хочуць трапляцца ў ситуацыі, калі ўсё працягнутая робота зникае, дадзіце DevTools, калі багі стануць менш зрозумелымі, уведзіце сістэму slices, калі падпункт прасування файла стане безнадзейным, і застаўце Query для всіх задач, якія трэба адправляць і практычна вярнуць з API. Гэты прыем падходзіць да таго, як на самай працэўнае кодавыя базы Zustand растуць: паступова, чэтка і без зайвых формальнасцей, якія не прыносяць рэальнай безпекі.
Глыбэй у селектары і выконаванасць
Дисципліна ў выборе селектараў — гэта тое, што разлічвае быстры юніт-панель ад таго, які стае незрозумела повольным. Кожна непатрэбная перераскладка занова запускае JSX, эфекты, якія залежнаць ад пропсаў, і процес супароўнення дзецячых элементаў. Модэль слухачаў Zustand є дешавым толькі тады, калі селектары вяртаюць стабільныя прымітывы або рэшты, якія былі астатнічна параванены.
Лепшае выбір — логічных значэнняў, цяжоў і строк. Калі выбрана функцыя дзеяння, яна зазвычай застаёцца стабільной праз усе апдэйты, таму што знаходзіцца ў об’екте store, тэму чыста падходзіць спароўванне count і increment у двух хукі. Оядзеўце выбіраць цэлыя масівы, якщо компоненту патрэбна толькі items.length; у такім случыку выберыце дужоўку (або пахоўдзячы логічнае значэння), каб іншыя прабныя дзеяння не актываўалі некіроўваныя віджэты.
Равнасць мае значэнне. Застаўны спосаб порэвання — Object.is. Самэ гэтая прычына, чаму вярненне { a, b } з селектара не работае: новы об’ект не паспявае перацэніцца за дапамогою Object.is, нават калі a і b застаюцца незменнымі. Функцыя useShallow порэваняе поля на аднам роўні. Для глыбокіх структураў трэба альбо нормалізаваць стан, каб UI чытаў плоскія поля, альбо намеравана выраховваць прымітывны ідентыфікатор.
Співы карточака патрабуюць спецыяльнага адзінства. Разбіўка plants на тры складнікі ёсць прыемна, якщо кожны з іх патрабуе толькі аб’екта масівы, калі змянюецца прыналежнасць або ідэнтыфікатор элемента. Якщо адзіны панель паказвае толькі назвы, розгляньце варыянт з селекторам, які вяртае сортаваны рэчыцу з ідэнтыфікаторамі; такой падход застаецца стабільным, калі змянююцца несувязаныя полья дакумента.
Проблемы стойкага зберагача і версіюванне
Медія-процесоры стойкага зберагача выглядаюць чароўнымі, пакуль не выйдзе змена схемы. Заўжды задавайце стабільны name для ключа зберагача. Калі змянюецца структура стойкага стану, падняйце версію і задайце функцыю migrate, каб стары JSON не спануўваў працэс аплікацыі пад час завантажэння. Частковае зберагачаўанне (partialize) не дазволяе секретам і тымчасовым пазнакам UI застацца ў Local Storage — токены і ексклюзыўныя флагі для модалных вікна рэдка калі патрабуюцься на дыску.
Зважайце на час выканання процеса гідратацыі: пад час першага адразу выканання кліента можна на короткі час побачыць стандартныя значэння, пакуль не завершыцца процес гідратацыі. Для SSR або фрэймворкаў, якія рэндаруюць на сервере, паказвайце UI, які залежыць ад захаваных значэнняў, толькі пасля таго, як будзе атрымана сигнал гідратацыі, або прыймайце тымчасава выкораненне стандартнага тэмы. Дакументавайце выбраны падход, каб колегі не “лечылі” багі, якія на самай працэ ўступаюць у супернікаванне пад час гідратацыі.
Паведанне межаў карточак таксама створыць неспадзянку. Запісы з Local Storage адной карточкі стаюць виднымі ў іншых через здарэнне storage, але стандартны шлях захавання дадзеных у Zustand не з’едынае автаматычна адночасныя змены. Для карточак, якія викорыстоўваюцца саавтарскі, або прыймайце прынцып “пасляледняя змена — гэта правильная”, або дадайце явны слой сінхронізацыі BroadcastChannel на верху стоўкі дадзеных.
Проектаванне дзеянняў, якія застаюцца простымі
Дзеянні з хорашым станам ўскладнены, названы па намеру пользователя і не маюць JSX. addPlant, waterPlant і removePlant кращыя за setPlants з точкі зору адображэння ў інтэрфейсе. Автапераканалізацыю трэба выконваць пры самай дзеянні: адхіліць порожнія назвы, обмежыць інтэвалы поливу, ігнораваць невядомыя ID. Ранняе завершэнне дзеянні яснае, чым працаванне з некоректнымі дадзенням у магазіне пры наступнай перрадзе.
Асінхронныя дзеянні должны задаць чысткія поля loading і error, калі інтэрфейс павінен адображаць статус выканання. Журналіраванне у формате «запуск і забыць» можа прымінуць гэтыя пазнакі. Калі калькуюцца колькасць асінхронных запытанняў, трэба зафіксаваць ID запытання або выкарыстаць контролер абароткі, каб старэйшы адпаведны рэсурс не могў перазапісаць новейшы. Для гэтага не трэба мідлвэра — достатнья толькі акуратная парада вызваў set.
Дапаможныя функцыі, якія выведзены з іншых, можаць існаваць як звычныя функцыі параду ўнутрышняй часткі складу (так лёгкая ў тэставанні) або як гетеры, вычысляваныя ўнутрышняй селектараў. Лепей выбіраць чыстыя функцыі, якія імпортуюцца як складам, так і UI, каб Jest могаў пераканацца ў правільнасці вычыслення данных пра зрошэнне без викорыстання React.
Запісы па шляху адкрыцья додатку для растоў
Падчас калкавання шасці файлаў стараюцца, каб шляхі імпорта былі адпаведныя шаблону Vite (../store/... з components). Якщо пасля аднаўлення список выглядае порожнім, пераканайцеся ў значэнні ключа persist у DevTools і паверыце, што 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, а застосавайце такі падход толькі да тых кліентскіх данаёнаў, якія спачатку зробілі працу з пропамі такой складнай. Дзеяўчы так адносова, тады твэрджэнне пра “десяць ліній” перестане звучаць як маркетынг і пачне звучаць як структура кодавой базы.