Управление состоянием: Redux, Zustand, Recoil

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

В типичном приложении с графиками состояние включает:

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

Пример структуры состояния:

{
  charts: {
    sales: [...],
    users: [...],
    revenue: [...]
  },

  filters: {
    country: 'USA',
    year: 2025
  },

  ui: {
    darkMode: true,
    animations: false
  }
}

При использовании Nivo любые изменения таких данных вызывают перерисовку компонентов визуализации:

<ResponsiveLine data={data} />

Если data изменится — график обновится автоматически.


Redux и Nivo

Роль Redux в приложениях с визуализациями

Redux предоставляет централизованное хранилище состояния, где все данные приложения находятся в одном объекте store.

Redux особенно полезен в следующих сценариях:

  • большое количество графиков;
  • общие фильтры между страницами;
  • синхронизация нескольких визуализаций;
  • сложные асинхронные загрузки;
  • аналитические панели;
  • time-travel debugging;
  • предсказуемые обновления данных.

Установка Redux Toolkit

Современный Redux практически всегда используется через Redux Toolkit.

npm install @reduxjs/toolkit react-redux

Создание store

import { configureStore } from '@reduxjs/toolkit'
import chartsReducer from './chartsSlice'

export const store = configureStore({
  reducer: {
    charts: chartsReducer
  }
})

Подключение:

import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './store'
import App from './App'

ReactDOM.createRoot(document.getElementById('root')).render(
  <Provider store={store}>
    <App />
  </Provider>
)

Slice для данных графиков

import { createSlice } from '@reduxjs/toolkit'

const initialState = {
  salesData: [],
  loading: false,
  error: null
}

const chartsSlice = createSlice({
  name: 'charts',

  initialState,

  reducers: {
    setSalesData(state, action) {
      state.salesData = action.payload
    },

    setLoading(state, action) {
      state.loading = action.payload
    },

    setError(state, action) {
      state.error = action.payload
    }
  }
})

export const {
  setSalesData,
  setLoading,
  setError
} = chartsSlice.actions

export default chartsSlice.reducer

Использование Redux в Nivo

import { useSelector } from 'react-redux'
import { ResponsiveBar } from '@nivo/bar'

export default function SalesChart() {
  const data = useSelector(
    state => state.charts.salesData
  )

  return (
    <ResponsiveBar
      data={data}
      keys={['sales']}
      indexBy="month"
    />
  )
}

При изменении salesData график автоматически обновится.


Асинхронная загрузка данных

Redux Toolkit содержит встроенную поддержку async logic через createAsyncThunk.

Создание thunk

import {
  createSlice,
  createAsyncThunk
} from '@reduxjs/toolkit'

export const fetchSalesData =
  createAsyncThunk(
    'charts/fetchSalesData',
    async () => {
      const response = await fetch('/api/sales')
      return response.json()
    }
  )

const chartsSlice = createSlice({
  name: 'charts',

  initialState: {
    salesData: [],
    loading: false
  },

  extraReducers: builder => {
    builder
      .addCase(fetchSalesData.pending, state => {
        state.loading = true
      })

      .addCase(fetchSalesData.fulfilled, (state, action) => {
        state.loading = false
        state.salesData = action.payload
      })
  }
})

Загрузка данных в компоненте

import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import { fetchSalesData } from './chartsSlice'

export default function Dashboard() {
  const dispatch = useDispatch()

  useEffect(() => {
    dispatch(fetchSalesData())
  }, [])

  return <Charts />
}

Мемоизация данных

Большие наборы данных могут вызывать дорогостоящие рендеры.

Redux хорошо сочетается с reselect.

Установка

npm install reselect

Создание selector

import { createSelector } from 'reselect'

const salesData = state => state.charts.salesData

export const transformedData =
  createSelector(
    [salesData],
    data => {
      return data.map(item => ({
        ...item,
        profit: item.sales - item.cost
      }))
    }
  )

Использование memoized selector

const data = useSelector(transformedData)

Трансформация выполнится только при изменении исходных данных.


Проблема лишних перерисовок

Nivo может повторно рендерить SVG или Canvas-графики даже при минимальных изменениях объекта.

Плохой пример:

<ResponsivePie
  data={[...data]}
/>

Каждый рендер создаёт новый массив.

Правильный вариант:

const stableData = useMemo(
  () => data,
  [data]
)

<ResponsivePie data={stableData} />

Нормализация данных

Redux Toolkit включает createEntityAdapter.

Это полезно при работе с большими аналитическими системами.

Пример

import {
  createEntityAdapter,
  createSlice
} from '@reduxjs/toolkit'

const adapter = createEntityAdapter()

const chartsSlice = createSlice({
  name: 'charts',

  initialState: adapter.getInitialState(),

  reducers: {
    addMany: adapter.addMany
  }
})

Zustand и Nivo

Особенности Zustand

Zustand — минималистичная библиотека управления состоянием.

Главные особенности:

  • отсутствие boilerplate;
  • простая архитектура;
  • высокая производительность;
  • granular updates;
  • минимальное количество кода;
  • отсутствие Provider.

Zustand особенно хорошо подходит для:

  • dashboards;
  • realtime charts;
  • streaming data;
  • WebSocket-графиков;
  • lightweight SPA.

Установка Zustand

npm install zustand

Создание store

import { create } from 'zustand'

export const useChartStore = create(set => ({
  salesData: [],

  setSalesData: data =>
    set({
      salesData: data
    })
}))

Использование в Nivo

import { ResponsiveLine } from '@nivo/line'
import { useChartStore } from './store'

export default function SalesChart() {
  const data = useChartStore(
    state => state.salesData
  )

  return (
    <ResponsiveLine
      data={data}
    />
  )
}

Обновление состояния

const setSalesData =
  useChartStore(
    state => state.setSalesData
  )

fetch('/api/data')
  .then(r => r.json())
  .then(data => {
    setSalesData(data)
  })

Granular subscriptions

Одно из главных преимуществ Zustand — точечные подписки.

const filters = useChartStore(
  state => state.filters
)

Компонент обновится только при изменении filters.


Селекторы Zustand

const salesData = useChartStore(
  state => state.salesData
)

Можно комбинировать:

const chartState = useChartStore(
  state => ({
    data: state.salesData,
    filters: state.filters
  })
)

Middleware в Zustand

persist

npm install zustand/middleware
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

const useStore = create(
  persist(
    set => ({
      darkMode: false,

      toggleTheme: () =>
        set(state => ({
          darkMode: !state.darkMode
        }))
    }),

    {
      name: 'dashboard-storage'
    }
  )
)

Хранение фильтров графиков

const useStore = create(set => ({
  filters: {
    year: 2025,
    country: 'USA'
  },

  setFilter: (key, value) =>
    set(state => ({
      filters: {
        ...state.filters,
        [key]: value
      }
    }))
}))

Связанные графики

Zustand идеально подходит для coordinated views.

const selectedCountry =
  useStore(state => state.selectedCountry)

Изменение состояния автоматически синхронизирует несколько графиков.


Realtime обновления

Zustand часто используют с WebSocket.

socket.onmess age = event => {
  const data = JSON.parse(event.data)

  useChartStore.setState({
    salesData: data
  })
}

Nivo мгновенно обновит визуализацию.


Оптимизация производительности Zustand

shallow comparison

import { shallow } from 'zustand/shallow'

const state = useStore(
  state => ({
    filters: state.filters,
    theme: state.theme
  }),
  shallow
)

Recoil и Nivo

Архитектура Recoil

Recoil основан на атомарной модели состояния.

Основные сущности:

  • atom;
  • selector;
  • derived state;
  • dependency graph.

Recoil особенно полезен при:

  • сложных зависимостях между графиками;
  • derived visualizations;
  • data pipelines;
  • аналитических интерфейсах;
  • deeply nested state.

Установка Recoil

npm install recoil

Инициализация

import { RecoilRoot } from 'recoil'

root.render(
  <RecoilRoot>
    <App />
  </RecoilRoot>
)

Создание atom

import { atom } from 'recoil'

export const salesState = atom({
  key: 'salesState',

  default: []
})

Использование atom

import { useRecoilValue } from 'recoil'
import { salesState } from './state'

const data = useRecoilValue(salesState)

<ResponsiveLine data={data} />

Изменение atom

import { useSetRecoilState } from 'recoil'

const setSales =
  useSetRecoilState(salesState)

fetch('/api/data')
  .then(r => r.json())
  .then(setSales)

Derived state через selector

import {
  selector
} from 'recoil'

export const filteredSalesState =
  selector({
    key: 'filteredSalesState',

    get: ({ get }) => {
      const data = get(salesState)

      return data.filter(
        item => item.value > 100
      )
    }
  })

Использование selector

const data = useRecoilValue(
  filteredSalesState
)

Связанные графики

Recoil автоматически отслеживает зависимости.

export const countryFilterState =
  atom({
    key: 'countryFilter',
    default: 'USA'
  })

export const chartDataState =
  selector({
    key: 'chartData',

    get: ({ get }) => {
      const country =
        get(countryFilterState)

      const sales =
        get(salesState)

      return sales.filter(
        item => item.country === country
      )
    }
  })

Асинхронные selectors

export const asyncChartData =
  selector({
    key: 'asyncChartData',

    get: async () => {
      const response =
        await fetch('/api/chart')

      return response.json()
    }
  })

Suspense и Nivo

Recoil тесно интегрируется с Suspense.

<Suspense fallback={<Loader />}>
  <Chart />
</Suspense>

Работа с несколькими графиками

export const dashboardState =
  atom({
    key: 'dashboard',

    default: {
      sales: [],
      revenue: [],
      users: []
    }
  })

Selector Family

Полезно при динамических графиках.

import { selectorFamily } from 'recoil'

export const chartState =
  selectorFamily({
    key: 'chartState',

    get: chartId => ({ get }) => {
      const dashboard =
        get(dashboardState)

      return dashboard[chartId]
    }
  })

Сравнение Redux, Zustand и Recoil

Характеристика Redux Zustand Recoil
Boilerplate Высокий Минимальный Средний
Производительность Высокая Очень высокая Высокая
Learning curve Средний Низкий Средний
Derived state Через selectors Вручную Встроено
Devtools Отличные Хорошие Ограниченные
Async logic Отличная Простая Встроенная
Масштабируемость Очень высокая Высокая Высокая
Re-render optimization Хорошая Отличная Отличная

Выбор подхода

Redux подходит для:

  • enterprise dashboards;
  • сложной бизнес-логики;
  • больших команд;
  • строгой архитектуры;
  • сложных async workflows.

Zustand подходит для:

  • realtime dashboards;
  • небольших и средних проектов;
  • быстрых MVP;
  • WebSocket-визуализаций;
  • минималистичной архитектуры.

Recoil подходит для:

  • сложных зависимостей состояния;
  • derived visualizations;
  • data-flow интерфейсов;
  • аналитических систем;
  • deeply reactive UI.

Оптимизация Nivo при работе со state management

Использование memo

export default memo(SalesChart)

useMemo для datasets

const processedData = useMemo(() => {
  return transform(data)
}, [data])

useCallback для обработчиков

const handleClick = useCallback(node => {
  console.log(node)
}, [])

Избежание inline objects

Плохой вариант:

<ResponsiveBar
  margin={{
    top: 50,
    right: 20
  }}
/>

Лучше:

const margin = {
  top: 50,
  right: 20
}

Canvas vs SVG

Nivo поддерживает:

  • SVG;
  • Canvas.

Для больших потоков данных предпочтителен Canvas:

<ResponsiveLineCanvas />

Виртуализация больших наборов данных

const visibleData =
  data.slice(start, end)

Debouncing обновлений

import debounce from 'lodash/debounce'

const updateChart =
  debounce(data => {
    setSalesData(data)
  }, 300)

Общая архитектура dashboard

API
 ↓
State Manager
 ↓
Selectors
 ↓
Nivo Components
 ↓
SVG / Canvas

Разделение состояния

Рекомендуется разделять:

  • server state;
  • UI state;
  • chart state;
  • filters state;
  • cached state.

Интеграция с React Query

TanStack Query часто комбинируется с Zustand или Redux.

npm install @tanstack/react-query

Пример совместного использования

const { data } = useQuery({
  queryKey: ['sales'],
  queryFn: fetchSales
})

const filters =
  useStore(state => state.filters)

const filteredData =
  useMemo(() => {
    return applyFilters(data, filters)
  }, [data, filters])

Архитектура enterprise analytics

Типичная структура:

src/
 ├── store/
 ├── selectors/
 ├── charts/
 ├── dashboard/
 ├── hooks/
 ├── services/
 └── api/

Паттерн container/presentation

Container

function SalesContainer() {
  const data = useSelector(selectSales)

  return <SalesChart data={data} />
}

Presentation

function SalesChart({ data }) {
  return (
    <ResponsiveBar data={data} />
  )
}

Кэширование вычислений

Большие графики могут содержать:

  • агрегации;
  • grouping;
  • interpolation;
  • rolling averages;
  • statistical transforms.

Подобные вычисления желательно мемоизировать.


Lazy loading графиков

const SalesChart =
  lazy(() => import('./SalesChart'))

Error boundaries

<ErrorBoundary>
  <Chart />
</ErrorBoundary>

Типизация состояния с TypeScript

interface ChartState {
  salesData: Sales[]
  loading: boolean
}

Типизация selectors

const salesData = useSelector(
  (state: RootState) =>
    state.charts.salesData
)

Zustand + TypeScript

interface Store {
  salesData: Sales[]

  setSalesData:
    (data: Sales[]) => void
}

Recoil + TypeScript

const salesState = atom<Sales[]>({
  key: 'sales',
  default: []
})