← React/Zustand: управление состоянием#272 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

Zustand: управление состоянием

Зачем нужно глобальное управление состоянием

По мере роста приложения возникает несколько проблем:

  • Prop drilling — передача данных через множество уровней компонентов
  • Синхронизация — одни и те же данные нужны в разных ветках дерева
  • Производительность — Context перерисовывает всех потребителей
  • React Context хорошо справляется с редко изменяемыми данными (тема, язык, авторизация), но для часто обновляемого состояния нужны специализированные инструменты.

    Redux: мощь и сложность

    Redux — классическое решение, но требует много шаблонного кода:

    // Redux: action types, action creators, reducers, store, connect...
    const ADD_TODO = 'ADD_TODO'
    const addTodo = (text) => ({ type: ADD_TODO, payload: text })
    
    function todosReducer(state = [], action) {
      switch (action.type) {
        case ADD_TODO: return [...state, { text: action.payload }]
        default: return state
      }
    }
    
    const store = createStore(todosReducer)
    // ... ещё connect, mapStateToProps, mapDispatchToProps

    Для небольших и средних приложений это избыточно.

    Zustand: простота и производительность

    Zustand (нем. "состояние") — минималистичная библиотека с отличной производительностью. Весь стор — один объект с состоянием и действиями:

    import { create } from 'zustand'
    
    const useCounterStore = create((set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
      decrement: () => set((state) => ({ count: state.count - 1 })),
      reset: () => set({ count: 0 }),
    }))
    
    // В компоненте — как обычный хук
    function Counter() {
      const count = useCounterStore((state) => state.count)
      const increment = useCounterStore((state) => state.increment)
    
      return <button onClick={increment}>{count}</button>
    }

    Ключевое: компонент подписывается только на нужные поля через селектор — остальные изменения его не перерисовывают!

    Сложный стор с Zustand

    const useCartStore = create((set, get) => ({
      items: [],
    
      addItem: (product) => set((state) => ({
        items: [...state.items, { ...product, quantity: 1 }]
      })),
    
      removeItem: (id) => set((state) => ({
        items: state.items.filter(item => item.id !== id)
      })),
    
      updateQuantity: (id, qty) => set((state) => ({
        items: state.items.map(item =>
          item.id === id ? { ...item, quantity: qty } : item
        )
      })),
    
      // Вычисляемое значение через get()
      getTotal: () => {
        const { items } = get()
        return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
      },
    
      clear: () => set({ items: [] }),
    }))
    
    // Компонент читает только нужные данные
    function CartTotal() {
      const getTotal = useCartStore((state) => state.getTotal)
      return <span>Итого: {getTotal()} ₽</span>
    }

    Middleware: persist и devtools

    import { create } from 'zustand'
    import { persist, devtools } from 'zustand/middleware'
    
    const useStore = create(
      devtools(          // подключает Redux DevTools
        persist(         // сохраняет в localStorage автоматически
          (set) => ({
            theme: 'light',
            setTheme: (theme) => set({ theme }),
          }),
          { name: 'app-storage' }  // ключ в localStorage
        )
      )
    )

    Сравнение инструментов

    | | Context | useReducer+Context | Zustand | Redux Toolkit |

    |---|---|---|---|---|

    | Настройка | Минимальная | Средняя | Минимальная | Много |

    | Производительность | Перерисует всех | Перерисует всех | Точечные подписки | Точечные подписки |

    | DevTools | Нет | Нет | Есть | Отличные |

    | Размер бандла | 0кб | 0кб | ~1кб | ~15кб |

    | Когда использовать | Статичные данные | Простая логика | Большинство случаев | Большие команды |

    Рекомендация: начинайте с useState/Context, переходите на Zustand когда нужна производительность или общее состояние между несвязанными компонентами.

    Примеры

    Реализация Zustand-подобного хранилища с нуля: замыкание с подписчиками, точечные обновления, селекторы

    // Реализуем Zustand-like create() с нуля.
    // Это покажет, как Zustand достигает точечных обновлений без Context.
    
    function create(storeCreator) {
      let state
      const listeners = new Set()
    
      // set() — передаётся в storeCreator, позволяет обновлять состояние
      const set = (updater) => {
        const prevState = state
        const updates = typeof updater === 'function' ? updater(state) : updater
        state = { ...state, ...updates }
    
        // Уведомляем подписчиков только если состояние изменилось
        if (state !== prevState) {
          listeners.forEach(listener => listener(state, prevState))
        }
      }
    
      // get() — позволяет читать текущее состояние из actions
      const get = () => state
    
      // Инициализируем стор
      state = storeCreator(set, get)
    
      // Возвращаем хук-функцию (в React это был бы useStore)
      function useStore(selector) {
        // Без React: просто читаем значение через селектор
        return selector ? selector(state) : state
      }
    
      // Подписка на изменения (в React это делает хук автоматически)
      useStore.subscribe = (listener, selector) => {
        const wrappedListener = (newState, prevState) => {
          // Если передан селектор — проверяем только выбранное поле
          if (selector) {
            const newSelected = selector(newState)
            const prevSelected = selector(prevState)
            if (newSelected === prevSelected) return  // точечная подписка!
          }
          listener(newState)
        }
        listeners.add(wrappedListener)
        return () => listeners.delete(wrappedListener)
      }
    
      useStore.getState = get
      useStore.setState = set
    
      return useStore
    }
    
    // --- Создаём стор для корзины ---
    
    const useCartStore = create((set, get) => ({
      items: [],
    
      addItem: (product) => set((state) => ({
        items: [...state.items, { ...product, quantity: 1 }]
      })),
    
      removeItem: (id) => set((state) => ({
        items: state.items.filter(item => item.id !== id)
      })),
    
      updateQuantity: (id, qty) => set((state) => ({
        items: state.items.map(item =>
          item.id === id ? { ...item, quantity: qty } : item
        )
      })),
    
      getTotal: () => {
        const { items } = get()
        return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
      },
    
      clear: () => set({ items: [] })
    }))
    
    // --- Точечные подписки (ключевая фишка Zustand) ---
    
    console.log('=== Точечные подписки ===')
    
    let totalRenders = 0
    let itemsRenders = 0
    
    // Компонент 1: подписан только на items
    const unsubItems = useCartStore.subscribe(
      (state) => { itemsRenders++; console.log(`  [CartList] обновление, товаров: ${state.items.length}`) },
      (state) => state.items  // селектор
    )
    
    // Компонент 2: подписан только на total через функцию
    const unsubTotal = useCartStore.subscribe(
      (state) => {
        totalRenders++
        const total = useCartStore.getState().getTotal()
        console.log(`  [CartTotal] обновление, итого: ${total} ₽`)
      },
      (state) => state.items  // перерисовываемся только при изменении items
    )
    
    // --- Действия ---
    
    const { addItem, removeItem, updateQuantity, clear } = useCartStore.getState()
    
    console.log('
    Добавляем товары:')
    addItem({ id: 1, name: 'Молоко', price: 89 })
    addItem({ id: 2, name: 'Хлеб', price: 45 })
    addItem({ id: 3, name: 'Масло', price: 120 })
    
    console.log('
    Изменяем количество:')
    updateQuantity(1, 2)  // 2 литра молока
    
    console.log('
    Удаляем товар:')
    removeItem(2)
    
    const total = useCartStore.getState().getTotal()
    console.log(`
    Итого: ${total} ₽`)  // 89*2 + 120 = 298
    
    unsubItems()
    unsubTotal()

    Zustand: управление состоянием

    Зачем нужно глобальное управление состоянием

    По мере роста приложения возникает несколько проблем:

  • Prop drilling — передача данных через множество уровней компонентов
  • Синхронизация — одни и те же данные нужны в разных ветках дерева
  • Производительность — Context перерисовывает всех потребителей
  • React Context хорошо справляется с редко изменяемыми данными (тема, язык, авторизация), но для часто обновляемого состояния нужны специализированные инструменты.

    Redux: мощь и сложность

    Redux — классическое решение, но требует много шаблонного кода:

    // Redux: action types, action creators, reducers, store, connect...
    const ADD_TODO = 'ADD_TODO'
    const addTodo = (text) => ({ type: ADD_TODO, payload: text })
    
    function todosReducer(state = [], action) {
      switch (action.type) {
        case ADD_TODO: return [...state, { text: action.payload }]
        default: return state
      }
    }
    
    const store = createStore(todosReducer)
    // ... ещё connect, mapStateToProps, mapDispatchToProps

    Для небольших и средних приложений это избыточно.

    Zustand: простота и производительность

    Zustand (нем. "состояние") — минималистичная библиотека с отличной производительностью. Весь стор — один объект с состоянием и действиями:

    import { create } from 'zustand'
    
    const useCounterStore = create((set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
      decrement: () => set((state) => ({ count: state.count - 1 })),
      reset: () => set({ count: 0 }),
    }))
    
    // В компоненте — как обычный хук
    function Counter() {
      const count = useCounterStore((state) => state.count)
      const increment = useCounterStore((state) => state.increment)
    
      return <button onClick={increment}>{count}</button>
    }

    Ключевое: компонент подписывается только на нужные поля через селектор — остальные изменения его не перерисовывают!

    Сложный стор с Zustand

    const useCartStore = create((set, get) => ({
      items: [],
    
      addItem: (product) => set((state) => ({
        items: [...state.items, { ...product, quantity: 1 }]
      })),
    
      removeItem: (id) => set((state) => ({
        items: state.items.filter(item => item.id !== id)
      })),
    
      updateQuantity: (id, qty) => set((state) => ({
        items: state.items.map(item =>
          item.id === id ? { ...item, quantity: qty } : item
        )
      })),
    
      // Вычисляемое значение через get()
      getTotal: () => {
        const { items } = get()
        return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
      },
    
      clear: () => set({ items: [] }),
    }))
    
    // Компонент читает только нужные данные
    function CartTotal() {
      const getTotal = useCartStore((state) => state.getTotal)
      return <span>Итого: {getTotal()} ₽</span>
    }

    Middleware: persist и devtools

    import { create } from 'zustand'
    import { persist, devtools } from 'zustand/middleware'
    
    const useStore = create(
      devtools(          // подключает Redux DevTools
        persist(         // сохраняет в localStorage автоматически
          (set) => ({
            theme: 'light',
            setTheme: (theme) => set({ theme }),
          }),
          { name: 'app-storage' }  // ключ в localStorage
        )
      )
    )

    Сравнение инструментов

    | | Context | useReducer+Context | Zustand | Redux Toolkit |

    |---|---|---|---|---|

    | Настройка | Минимальная | Средняя | Минимальная | Много |

    | Производительность | Перерисует всех | Перерисует всех | Точечные подписки | Точечные подписки |

    | DevTools | Нет | Нет | Есть | Отличные |

    | Размер бандла | 0кб | 0кб | ~1кб | ~15кб |

    | Когда использовать | Статичные данные | Простая логика | Большинство случаев | Большие команды |

    Рекомендация: начинайте с useState/Context, переходите на Zustand когда нужна производительность или общее состояние между несвязанными компонентами.

    Примеры

    Реализация Zustand-подобного хранилища с нуля: замыкание с подписчиками, точечные обновления, селекторы

    // Реализуем Zustand-like create() с нуля.
    // Это покажет, как Zustand достигает точечных обновлений без Context.
    
    function create(storeCreator) {
      let state
      const listeners = new Set()
    
      // set() — передаётся в storeCreator, позволяет обновлять состояние
      const set = (updater) => {
        const prevState = state
        const updates = typeof updater === 'function' ? updater(state) : updater
        state = { ...state, ...updates }
    
        // Уведомляем подписчиков только если состояние изменилось
        if (state !== prevState) {
          listeners.forEach(listener => listener(state, prevState))
        }
      }
    
      // get() — позволяет читать текущее состояние из actions
      const get = () => state
    
      // Инициализируем стор
      state = storeCreator(set, get)
    
      // Возвращаем хук-функцию (в React это был бы useStore)
      function useStore(selector) {
        // Без React: просто читаем значение через селектор
        return selector ? selector(state) : state
      }
    
      // Подписка на изменения (в React это делает хук автоматически)
      useStore.subscribe = (listener, selector) => {
        const wrappedListener = (newState, prevState) => {
          // Если передан селектор — проверяем только выбранное поле
          if (selector) {
            const newSelected = selector(newState)
            const prevSelected = selector(prevState)
            if (newSelected === prevSelected) return  // точечная подписка!
          }
          listener(newState)
        }
        listeners.add(wrappedListener)
        return () => listeners.delete(wrappedListener)
      }
    
      useStore.getState = get
      useStore.setState = set
    
      return useStore
    }
    
    // --- Создаём стор для корзины ---
    
    const useCartStore = create((set, get) => ({
      items: [],
    
      addItem: (product) => set((state) => ({
        items: [...state.items, { ...product, quantity: 1 }]
      })),
    
      removeItem: (id) => set((state) => ({
        items: state.items.filter(item => item.id !== id)
      })),
    
      updateQuantity: (id, qty) => set((state) => ({
        items: state.items.map(item =>
          item.id === id ? { ...item, quantity: qty } : item
        )
      })),
    
      getTotal: () => {
        const { items } = get()
        return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
      },
    
      clear: () => set({ items: [] })
    }))
    
    // --- Точечные подписки (ключевая фишка Zustand) ---
    
    console.log('=== Точечные подписки ===')
    
    let totalRenders = 0
    let itemsRenders = 0
    
    // Компонент 1: подписан только на items
    const unsubItems = useCartStore.subscribe(
      (state) => { itemsRenders++; console.log(`  [CartList] обновление, товаров: ${state.items.length}`) },
      (state) => state.items  // селектор
    )
    
    // Компонент 2: подписан только на total через функцию
    const unsubTotal = useCartStore.subscribe(
      (state) => {
        totalRenders++
        const total = useCartStore.getState().getTotal()
        console.log(`  [CartTotal] обновление, итого: ${total} ₽`)
      },
      (state) => state.items  // перерисовываемся только при изменении items
    )
    
    // --- Действия ---
    
    const { addItem, removeItem, updateQuantity, clear } = useCartStore.getState()
    
    console.log('
    Добавляем товары:')
    addItem({ id: 1, name: 'Молоко', price: 89 })
    addItem({ id: 2, name: 'Хлеб', price: 45 })
    addItem({ id: 3, name: 'Масло', price: 120 })
    
    console.log('
    Изменяем количество:')
    updateQuantity(1, 2)  // 2 литра молока
    
    console.log('
    Удаляем товар:')
    removeItem(2)
    
    const total = useCartStore.getState().getTotal()
    console.log(`
    Итого: ${total} ₽`)  // 89*2 + 120 = 298
    
    unsubItems()
    unsubTotal()

    Задание

    Реализуй подъём состояния (lifting state up). Есть два дочерних компонента TemperatureInput: один для Цельсия, другой для Фаренгейта. При вводе в один — другой автоматически обновляется. Храни температуру в компоненте App и передавай вниз через пропсы.

    Подсказка

    Формула C→F: celsius * 9/5 + 32. Формула F→C: (fahrenheit - 32) * 5/9. TemperatureInput получает value и onChange через пропсы. onChange для Цельсия: setCelsius. onChange для Фаренгейта: handleFahrenheitChange. Это классический паттерн "поднятия состояния".

    Загружаем среду выполнения...
    Загружаем AI-помощника...