React Query и загрузка данных для графиков

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

Связка React Query и Nivo решает несколько задач одновременно:

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

Установка зависимостей

npm install @tanstack/react-query
npm install @nivo/bar
npm install @nivo/line
npm install @nivo/pie

Для работы React Query необходимо подключить QueryClientProvider.


Настройка Query Client

import React from "react";
import ReactDOM from "react-dom/client";

import {
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";

import App from "./App";

const queryClient = new QueryClient();

ReactDOM.createRoot(document.getElementById("root")).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

QueryClient отвечает за:

  • кэширование запросов;
  • повторные загрузки;
  • дедупликацию;
  • обновление данных;
  • хранение состояния запросов.

Базовая загрузка данных для графика

Создание функции запроса

async function fetchSales() {
  const response = await fetch("/api/sales");

  if (!response.ok) {
    throw new Error("Ошибка загрузки");
  }

  return response.json();
}

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

import { useQuery } from "@tanstack/react-query";

function SalesChart() {
  const {
    data,
    isLoading,
    error,
  } = useQuery({
    queryKey: ["sales"],
    queryFn: fetchSales,
  });

  if (isLoading) {
    return <div>Загрузка...</div>;
  }

  if (error) {
    return <div>Ошибка загрузки данных</div>;
  }

  return (
    <pre>
      {JSON.stringify(data, null, 2)}
    </pre>
  );
}

Передача данных в Nivo

Предположим, API возвращает:

[
  {
    "month": "Jan",
    "profit": 120,
    "loss": 40
  },
  {
    "month": "Feb",
    "profit": 180,
    "loss": 70
  }
]

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

import { ResponsiveBar } from "@nivo/bar";

function SalesChart() {
  const {
    data,
    isLoading,
    error,
  } = useQuery({
    queryKey: ["sales"],
    queryFn: fetchSales,
  });

  if (isLoading) {
    return <div>Загрузка...</div>;
  }

  if (error) {
    return <div>Ошибка</div>;
  }

  return (
    <div style={{ height: 500 }}>
      <ResponsiveBar
        data={data}
        keys={["profit", "loss"]}
        indexBy="month"
        margin={{
          top: 50,
          right: 130,
          bottom: 50,
          left: 60,
        }}
        padding={0.3}
        colors={{ scheme: "nivo" }}
        axisBottom={{
          legend: "Месяц",
          legendPosition: "middle",
          legendOffset: 32,
        }}
        axisLeft={{
          legend: "Сумма",
          legendPosition: "middle",
          legendOffset: -40,
        }}
      />
    </div>
  );
}

Обработка состояния загрузки

При работе с графиками важно учитывать:

  • отсутствие данных;
  • медленную сеть;
  • повторные запросы;
  • пустые ответы API.

Улучшенная обработка состояний

function SalesChart() {
  const {
    data,
    isLoading,
    isFetching,
    error,
  } = useQuery({
    queryKey: ["sales"],
    queryFn: fetchSales,
  });

  if (isLoading) {
    return <Spinner />;
  }

  if (error) {
    return <ErrorMessage />;
  }

  if (!data?.length) {
    return <EmptyState />;
  }

  return (
    <>
      {isFetching && (
        <div>Обновление...</div>
      )}

      <div style={{ height: 400 }}>
        <ResponsiveBar
          data={data}
          keys={["profit"]}
          indexBy="month"
        />
      </div>
    </>
  );
}

Разница между isLoading и isFetching

Состояние Описание
isLoading Первый запрос
isFetching Любой активный запрос
isRefetching Повторная загрузка

Автоматическое обновление графиков

React Query поддерживает периодическое обновление данных.

const query = useQuery({
  queryKey: ["stats"],
  queryFn: fetchStats,
  refetchInterval: 5000,
});

Теперь график обновляется каждые 5 секунд.


Реалтайм-графики

Обновление Line Chart

import { ResponsiveLine } from "@nivo/line";

function RealtimeChart() {
  const { data } = useQuery({
    queryKey: ["metrics"],
    queryFn: fetchMetrics,
    refetchInterval: 2000,
  });

  return (
    <div style={{ height: 500 }}>
      <ResponsiveLine
        data={data}
        margin={{
          top: 50,
          right: 110,
          bottom: 50,
          left: 60,
        }}
        xScale={{
          type: "point",
        }}
        yScale={{
          type: "linear",
          min: "auto",
          max: "auto",
        }}
        curve="monotoneX"
        enablePoints={false}
        useMesh={true}
      />
    </div>
  );
}

Трансформация данных

API редко возвращает данные в формате, подходящем для Nivo.

Исходный ответ API

[
  {
    "date": "2025-01-01",
    "value": 120
  },
  {
    "date": "2025-01-02",
    "value": 180
  }
]

Формат для ResponsiveLine

const chartData = [
  {
    id: "Продажи",
    data: [
      {
        x: "2025-01-01",
        y: 120,
      },
      {
        x: "2025-01-02",
        y: 180,
      },
    ],
  },
];

Трансформация внутри queryFn

async function fetchChartData() {
  const response = await fetch("/api/chart");

  const result = await response.json();

  return [
    {
      id: "Продажи",
      data: result.map(item => ({
        x: item.date,
        y: item.value,
      })),
    },
  ];
}

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

React Query позволяет трансформировать данные через select.

const query = useQuery({
  queryKey: ["chart"],
  queryFn: fetchChart,
  select: (data) => {
    return [
      {
        id: "Revenue",
        data: data.map(item => ({
          x: item.date,
          y: item.total,
        })),
      },
    ];
  },
});

Преимущества:

  • разделение API и UI;
  • чистый компонент;
  • повторное использование логики;
  • мемоизация результата.

Кэширование графиков

По умолчанию React Query кэширует данные.

useQuery({
  queryKey: ["analytics"],
  queryFn: fetchAnalytics,
  staleTime: 60000,
});

staleTime

Определяет время актуальности кэша.

staleTime: 60000

Данные считаются свежими 60 секунд.


Управление временем жизни кэша

useQuery({
  queryKey: ["stats"],
  queryFn: fetchStats,
  gcTime: 300000,
});

gcTime

Время хранения неиспользуемого кэша.


Параллельная загрузка нескольких графиков

Иногда страница содержит несколько диаграмм.

const usersQuery = useQuery({
  queryKey: ["users"],
  queryFn: fetchUsers,
});

const salesQuery = useQuery({
  queryKey: ["sales"],
  queryFn: fetchSales,
});

const ordersQuery = useQuery({
  queryKey: ["orders"],
  queryFn: fetchOrders,
});

useQueries

Для большого количества запросов удобнее использовать useQueries.

import { useQueries } from "@tanstack/react-query";

const results = useQueries({
  queries: [
    {
      queryKey: ["sales"],
      queryFn: fetchSales,
    },
    {
      queryKey: ["users"],
      queryFn: fetchUsers,
    },
  ],
});

Серверная пагинация графиков

Большие временные диапазоны часто загружаются частями.

const query = useQuery({
  queryKey: ["history", page],
  queryFn: () => fetchHistory(page),
});

Динамические queryKey

function RevenueChart({ year }) {
  const query = useQuery({
    queryKey: ["revenue", year],
    queryFn: () => fetchRevenue(year),
  });
}

При изменении year React Query автоматически выполнит новый запрос.


Фильтрация данных

График по выбранному периоду

function Dashboard({ range }) {
  const query = useQuery({
    queryKey: ["dashboard", range],
    queryFn: () => fetchDashboard(range),
  });
}

Предзагрузка данных

Prefetch Query

import { useQueryClient } from "@tanstack/react-query";

function DashboardLink() {
  const queryClient = useQueryClient();

  const preload = () => {
    queryClient.prefetchQuery({
      queryKey: ["analytics"],
      queryFn: fetchAnalytics,
    });
  };

  return (
    <button onMouseEn ter={preload}>
      Dashboard
    </button>
  );
}

Инвалидация графиков

После изменения данных необходимо обновить кэш.

import { useMutation } from "@tanstack/react-query";

const mutation = useMutation({
  mutationFn: createSale,
  onSuccess: () => {
    queryClient.invalidateQueries({
      queryKey: ["sales"],
    });
  },
});

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

React Query не зависит от конкретного HTTP-клиента.

npm install axios

Пример

import axios from "axios";

async function fetchStats() {
  const response = await axios.get("/api/stats");

  return response.data;
}

Отмена запросов

React Query поддерживает AbortController.

async function fetchData({ signal }) {
  const response = await fetch(
    "/api/chart",
    { signal }
  );

  return response.json();
}

Обработка ошибок API

const query = useQuery({
  queryKey: ["chart"],
  queryFn: fetchChart,
  retry: 3,
  retryDelay: 1000,
});

Настройки retry

Параметр Описание
retry Количество попыток
retryDelay Задержка между попытками

Кастомный хук для графиков

Создание useSalesChart

function useSalesChart() {
  return useQuery({
    queryKey: ["sales"],
    queryFn: fetchSales,
    select: (data) => {
      return data.map(item => ({
        month: item.month,
        revenue: item.revenue,
      }));
    },
  });
}

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

function SalesChart() {
  const { data } = useSalesChart();

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

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

Сложные API могут содержать вложенные структуры.

Исходный ответ

{
  "meta": {},
  "payload": {
    "items": [
      {
        "day": "Mon",
        "count": 12
      }
    ]
  }
}

Преобразование

select: (response) => {
  return response.payload.items.map(
    item => ({
      day: item.day,
      count: item.count,
    })
  );
}

Скелетоны вместо спиннеров

Для графиков лучше использовать skeleton UI.

function ChartSkeleton() {
  return (
    <div
      style={{
        height: 400,
        background: "#f5f5f5",
      }}
    />
  );
}

Ленивые графики

Тяжёлые графики можно загружать динамически.

import { lazy, Suspense } from "react";

const AnalyticsChart = lazy(() =>
  import("./AnalyticsChart")
);

function Page() {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <AnalyticsChart />
    </Suspense>
  );
}

Оптимизация ререндеров

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

import { memo } from "react";

const Chart = memo(function Chart({
  data,
}) {
  return (
    <ResponsiveBar
      data={data}
      keys={["value"]}
      indexBy="label"
    />
  );
});

Стабильные ссылки на данные

Nivo активно реагирует на изменение ссылок объектов.

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

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

Хороший вариант:

<ResponsiveBar
  data={data}
/>

useMemo для трансформаций

const normalizedData = useMemo(() => {
  return data.map(item => ({
    x: item.date,
    y: item.value,
  }));
}, [data]);

SSR и React Query

При использовании:

  • Next.js
  • Remix
  • RSC

React Query позволяет гидрировать кэш.

Dehydrate

import {
  dehydrate,
  QueryClient,
} from "@tanstack/react-query";

Suspense Mode

const query = useSuspenseQuery({
  queryKey: ["chart"],
  queryFn: fetchChart,
});

Загрузка при видимости элемента

const query = useQuery({
  queryKey: ["heavy-chart"],
  queryFn: fetchChart,
  enabled: isVisible,
});

dependent queries

Запрос графика может зависеть от другого запроса.

const userQuery = useQuery({
  queryKey: ["user"],
  queryFn: fetchUser,
});

const analyticsQuery = useQuery({
  queryKey: ["analytics", userQuery.data?.id],
  queryFn: () =>
    fetchAnalytics(userQuery.data.id),
  enabled: !!userQuery.data,
});

Infinite Query для бесконечных графиков

import {
  useInfiniteQuery,
} from "@tanstack/react-query";

const query = useInfiniteQuery({
  queryKey: ["events"],
  queryFn: fetchEvents,
  getNextPageParam: (lastPage) => {
    return lastPage.nextCursor;
  },
});

Объединение страниц в единый график

const chartData = query.data.pages.flatMap(
  page => page.items
);

Devtools

npm install @tanstack/react-query-devtools

Подключение

import {
  ReactQueryDevtools,
} from "@tanstack/react-query-devtools";

<ReactQueryDevtools initialIsOpen={false} />

Devtools позволяют:

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

Типичные ошибки при работе с Nivo и React Query

Мутация данных

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

data.push(newItem);

Хороший пример:

const newData = [...data, newItem];

Нестабильные queryKey

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

queryKey: ["chart", {}]

Хороший вариант:

queryKey: ["chart", year]

Тяжёлые вычисления внутри render

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

const result = hugeTransform(data);

Лучше:

const result = useMemo(() => {
  return hugeTransform(data);
}, [data]);

Архитектура dashboard-приложений

Распространённая структура:

src/
├── api/
├── hooks/
├── charts/
├── dashboard/
├── queries/
└── utils/

Возможное разделение

Каталог Назначение
api HTTP-запросы
hooks React Query hooks
charts Nivo-компоненты
queries queryKey и конфигурации
utils трансформации данных

Пример полноценной архитектуры

api/sales.js

export async function fetchSales() {
  const response = await fetch(
    "/api/sales"
  );

  return response.json();
}

hooks/useSales.js

export function useSales() {
  return useQuery({
    queryKey: ["sales"],
    queryFn: fetchSales,
  });
}

charts/SalesChart.jsx

export function SalesChart() {
  const { data } = useSales();

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

Оптимизация больших наборов данных

При работе с тысячами точек:

  • уменьшать количество данных;
  • использовать агрегацию;
  • отключать анимации;
  • отключать точки;
  • применять debounce;
  • загружать данные частями.

Отключение анимаций

<ResponsiveLine
  animate={false}
/>

Debounce обновлений

const [range, setRange] = useState("");

const debouncedRange = useDebounce(
  range,
  500
);

const query = useQuery({
  queryKey: ["chart", debouncedRange],
  queryFn: () =>
    fetchChart(debouncedRange),
});

Работа с временными рядами

Nivo поддерживает временные шкалы.

xScale={{
  type: "time",
  format: "%Y-%m-%d",
  precision: "day",
}}

Форматирование дат

axisBottom={{
  format: "%b %d",
  tickValues: "every 2 days",
}}

Агрегация данных

Иногда сервер возвращает слишком детализированные данные.

Исходный набор

[
  {
    "timestamp": "10:01",
    "value": 10
  },
  {
    "timestamp": "10:02",
    "value": 11
  }
]

Агрегация по часам

function aggregate(data) {
  const map = {};

  data.forEach(item => {
    const hour =
      item.timestamp.slice(0, 2);

    if (!map[hour]) {
      map[hour] = 0;
    }

    map[hour] += item.value;
  });

  return Object.entries(map).map(
    ([hour, value]) => ({
      hour,
      value,
    })
  );
}