Главная / Статьи / Состояние на практике: маленькие магазины, точные селекторы, трекер растений

Состояние на практике: маленькие магазины, точные селекторы, трекер растений

Замените методы prop-drilling и Redux на функции create(), селекторы, асинхронные действия, промежуточные компоненты persist/devtools, структуры slices и приложение hydration plant.

5694 слов

Введение в 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, пользовательские хук для хранилищ). Они представляют собой современный способ работы с данными и эффектами в 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

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 создает обычный объект для хранения состояния и пустой список слушателей в области видимости модуля (синглтон сразу после загрузки модуля).

Когда компонент использует этот хук:

  1. Zustand выполняет селектор (например, (state) => state.count) с учетом текущего состояния и возвращает соответствующий фрагмент.
  2. Он регистрирует компонент вместе с селектором как слушателя. При последующих обновлениях он снова выполняет каждый селектор и перерисовывает компонент только тогда, когда изменилось выбранное значение.

В основном потоке не используется 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),
}))

Компоненты по-прежнему выбирают то, что им нужно. При более чем пяти областях ответственности использование фрагментов данных становится необходимым; до этого количества один файл делает код более понятным.

Тестирование без отрисовки интерфейса

Хранилища данных представляют собой обычные модули: их можно сбросить, вызвать действия и проверить результаты:

// 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. Добавление растения (название + интервал между поливами)
  2. Список растений
  3. Полив одним кликом
  4. Отметка растений, нуждающихся в воде
  5. Удаление растений
  6. Панель статистики
  7. Сохранение данных при обновлении страницы

Вся программа на одной странице. Четыре компонента, один хранилище — счастливые растения.

Файл 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-файл. Мидлвэрк Persist записывает изменения и восстанавливает данные перед отрисовкой. С помощью мидлвэра devtools и расширения Redux DevTools каждое действие отображается с возможностью просмотра изменений во времени для более крупных объёмов данных.

Частые ошибки

  1. Hook для всего хранилища — использование useStore() без селектора приводит к перерисовке при каждом изменении. Всегда используйте селектор.
  2. Свежие объекты из селекторов — решите проблему с помощью useShallow или разделите хуки.
  • Хранение производных значений — вычисляйте thirstyCount на основе данных из plants; не храните второй устаревший счётчик.
  • Отсутствие поля name — обязательно; его отсутствие приводит к ошибке при запуске.
  • Один мега-хранилище навсегда — делайте разделение при увеличении количества доменов.
  • Использование Redux вместо Zustand — избегайте использования перечислений типов действий и огромных функций-редьюсеров, если нет реальной необходимости.
  • Забывание о синглтонах — одна функция create на модуль используется общим образом; существуют стандартные API для работы с отдельными экземплярами.
  • Пропуск тестов хранилища — используйте getState, выполняйте действия и проверяйте результаты; так можно заметить регрессии на ранней стадии.
  • Когда Zustand — не подходящий инструмент

    • useState — истинно локальный интерфейс (модалки, эффекты наведения, предварительные версии полей).
  • TanStack Query / SWR — серверная кэширование, повторное загрузка данных, удаление дубликатов, оптимистичные удаленные обновления. Используйте Query для управления состоянием на сервере вместе с Zustand для клиентского состояния.
  • Context — редкие обновления (токены темы) распространяются по всей поддереву.
  • Keep Redux — существующие кодовые базы на Redux, значительные инвестиции в инструменты для работы с временными путями, или действительно сложные процессы, где структура уже окупает себя. Мигрируйте тогда, когда экономия превысит затраты на миграцию.
  • Набросок на 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-приложения всё чаще предпочитают более легкие клиентские хранилища данных. Опросы удовлетворённости, проведённые в рамках серии исследований State of React, показывают, что Зустанд находится среди лидеров по показателю «использовал бы снова». Типичная стек-технология на 2026 год: Зустанд для хранения клиентского состояния, TanStack Query для серверного состояния, иногда атомы Jotai, Context для тем и настроек, Redux Toolkit только в случае уже его наличия, а простой useState — для локального интерфейса.

    Такая комбинация обычно позволяет быстрее начать работу по сравнению со структурами, основанными на Redux, и меньше создаёт проблем у разработчиков в повседневной работе.

    Zustand Bear

    Команды по разработке по-прежнему задокументировали стандарты хранения данных (называние элементов, границы сегментов, ключи сохранения), поскольку свобода без норм приводит к хаосу. Преимущество заключается в том, что эти стандарты остаются простыми: селекторы узкие, действия находятся рядом со состоянием, мидлвэр используется целенаправленно, а серверная кеш-система не вмешивается в клиентское хранилище. Если соблюдать четкие границы, Zustand остается решением из десяти строк вместо сотни строк в стартовом наборе Redux — без необходимости постоянно притворяться, что каждое приложение — это демонстрационный пример счётчика.

    Помимо образца кода, те же принципы применимы к корзинам покупок, флагам функций, черновикам мастер-панелей и декоративным элементам интерфейса. Начните с одного файла магазина, добавьте функцию сохранения данных, когда у пользователей возникает страх потерять работу, включите инструменты DevTools при появлении тонких багов, введите механизм разделения данных, когда полоса прокрутки файла становится бесполезной, и сохраните функцию 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, с точки зрения отображения информации в интерфейсе. Валидацию следует выполнять непосредственно внутри действия: отклонять пустые имена, ограничивать интервалы полива и игнорировать неизвестные идентификаторы. Ранний возврат из действия понятнее, чем оставление некорректных данных в хранилище до следующего цикла отображения.

    Асинхронные действия должны устанавливать явно определенные поля loading и error, когда интерфейс должен отражать статус выполнения. Логирование в режиме «отправил и забыл» может пропустить эти флаги. Когда происходит конкуренция между несколькими асинхронными запросами, необходимо сохранять идентификатор запроса или использовать механизм отмены, чтобы более старый ответ не мог перезаписать более новый. Для этого не требуется мидлвэр — достаточно тщательного управления порядком выполнения команд set.

    Вспомогательные функции могут существовать в виде обычных функций вне хранилища (это упрощает тестирование) или в виде геттеров, вычисляемых внутри селекторов. Желательно использовать чистые функции, импортируемые как хранилищем, так и интерфейсом, чтобы Jest мог проверять логику полива без использования React.

    Заметки к пошаговому руководству по приложению для ухода за растениями

    При вставке шести файлов сохраняйте пути импортов в соответствии с шаблоном Vite (../store/... из components). Если список остается пустым после обновления, проверьте ключ сохранения в DevTools и убедитесь, что name: 'plant-hydration-v1' совпадает с ожидаемым значением. Полив должен обновлять поле lastWateredAt (или аналогичное), чтобы селектор, отвечающий за уровень влажности, менялся без полной перезагрузки страницы.

    Стилизация в App.css преднамеренно простая. Свободно меняйте шрифты и цвета; главная цель обучения — понимание потока данных, а не визуальная отделка. Добавление четвертого компонента — например, кнопки «Полить всех жаждущих» — является полезным упражнением: выберите элементы с идентификаторами «жаждущие», затем вызовите действие, которое выполнит поливание для всех этих элементов за один вызов set. Это упражнение способствует использованию групповых обновлений вместо циклических вызовов set из компонента.

    Конвенции команды, стоит записать

    Договоритесь о структуре файлов (stores/ против совместных папок для функций), номенклатуре (useXStore) и о том, могут ли действия напрямую вызывать API или должны проходить через операцию Query, которая затем обновляет Zustand. Многие команды полностью запрещают хранить списки серверов в Zustand, оставляя там только временные флаги клиента. Запишите этое правило в README — это предотвратит необходимость повторного создания кэша в половине кодовой базы.

    Также договоритесь о номенклатуре инструментов разработчика: промежуточный компонент devtools принимает имя хранилища, чтобы панель расширений оставалась читаемой при открытии пяти хранилищ. Ключи для сохранения данных должны иметь пространство имен приложения (myapp-theme-v1) для избежания коллизий в общих доменах.

    Сравнение эмоциональной значимости, а не только количества строк кода

    Redux Toolkit значительно упростил классический Redux, поэтому сравнение «100 строк против 10» отчасти риторическое. Более существенное различие кроется в концептуальной структуре: отдельные части состояния против одного вызова функции создания, редьюсеры против операций непосредственной модификации состояния, цепочки мидлвэра против необязательных оберток, деревья Provider против модульных синглтонов. Инженеры, уже привыкшие работать с редьюсерами, могут эффективно работать в любой из этих сред. Инженеры, желающие использовать общее состояние клиента без строгого следования принципам машин состояния, обычно быстрее реализуют функции с помощью Zustand.

    Ничто из этого не оправдывает пропуск проверок. Даже десятистрочный код хранилища может содержать ошибки безопасности, если он сохраняет персональные данные без согласия пользователя, или ошибки производительности, если каждый компонент выполняет один и тот же запрос. Рассматривайте хранилище как публичный API модуля: стабильные имена действий, задокументированные ключи хранения данных и тесты на крайние сценарии (пустые списки, недействительные идентификаторы, неудачные запросы).

    Как только приложение для работы с растениями заработает, намеренно нарушите его работу: удалите селектор, верните объект-литерал, сохраните данные без указания имени, храните производную величину. Ознакомьтесь с возможными способами сбоев хотя бы один раз, чтобы их можно было распознать во время ревью кода. Затем восстановите стандартизированные паттерны. Такой краткий эксперимент лучше закрепляет знания в долгосрочной памяти, чем ещё одна отладанная демонстрация счётчика.

    Зустанд считает, что большая часть состояния клиента представляет собой обычные элементы — флаги, черновики, корзины и т. д., и такое скучное состояние заслуживает скучного API. Храните истинные данные сервера в специально созданной библиотеке кэширования, локальный интерфейс — в useState, а сложные клиентские данные, из-за которых раньше возникали проблемы с передачей данных, оставьте для отдельного решения. Если делать это последовательно, утверждение о «десяти строках кода» перестанет звучать как маркетинговый троп и станет описанием реальной структуры кодовой базы.