НейроАгент

Обработка isFetching в React Query для нескольких компонентов

Решение проблемы множественных спиннеров при использовании React Query в нескольких компонентах. Оптимизация UX и архитектуры кода.

Как правильно обрабатывать isFetching для одного и того же запроса в нескольких компонентах (React Query)

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

Пример реализации:

Запрос:

javascript
export const useGetClients = (params?: GetClientsRequest) =>
  useQuery({
    queryKey: ['clients', 'list', params],
    queryFn: () => ClientClient.getClientApiInstance().getClients(params),
  });

Компонент таблицы:

javascript
const Wallets = () => {
  const { wallets, isLoading, isFetching } = useGetWallets();

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-wrap items-center justify-between gap-2">
        <DepositFundsButton />
      </div>
      <DataTable
        columns={Columns}
        data={wallets}
        isLoading={isLoading}
        isFetching={isFetching}
      />
    </div>
  );
};

Хук useGetWallets:

javascript
export const useGetWallets = () => {
  const {
    data: accounts,
    isLoading: isAccountsLoading,
    isFetching: isAccountsFetching,
  } = useGetLedgerAccounts();

  const {
    data: clients,
    isLoading: isClientsLoading,
    isFetching: isClientsFetching,
  } = useGetClients({
    clientType: ClientType.Client,
  });

  const accountsWithClientName: AccountWithClientName[] =
    accounts && clients
      ? accounts.map((account) => ({
          ...account,
          context: {
            ...account.context,
            ...(account.context.clientId && {
              clientName: clients.clients.find(
                (client) => client.id === account.context.clientId,
              )?.name,
            }),
          },
        }))
      : [];

  return {
    wallets: accountsWithClientName,
    isLoading: isAccountsLoading || isClientsLoading,
    isFetching: isAccountsFetching || isClientsFetching,
  };
};

Компонент формы:

javascript
export const DepositFundsForm = ({ onClose }: DepositFundsFormProps) => {
  const { data, isFetching: isClientsFetching } = useGetClients({
    clientType: ClientType.Client,
  });

  return (
    <>
      <Form {...methods}>
        <form className="space-y-6 overflow-y-auto px-4">
          <SelectField
            name="clientId"
            loading={isClientsFetching}
            control={control}
            label="Client"
            placeholder="Client"
            options={clientOptions}
            className="min-w-[300px]"
          />
        </form>
      </Form>
      <SheetFooter>
        <SheetClose asChild>
          <Button variant="secondary">Cancel</Button>
        </SheetClose>
        <Button onClick={handleSubmit(onSubmit)} isLoading={isSubmitting}>
          Deposit
        </Button>
      </SheetFooter>
    </>
  );
};

Проблема: при открытии формы отображаются два спиннера — один в таблице и второй в сайдбаре. Это некорректно с точки зрения UX, так как запрос одинаковый и данные уже есть в кеше.

Возможные решения:

  1. Показывать спиннер в таблице только при isAccountsFetching, а не при isAccountsFetching || isClientsFetching
  2. Использовать разные ключи запроса (query key) для таблицы и сайдбара, чтобы у них были независимые состояния
  3. Обернуть компоненты в контекст-провайдер, где данные клиентов будут загружаться один раз и использоваться совместно

Для третьего решения возникают вопросы:

  • Что показывать, пока в провайдере идёт загрузка клиентов — скелетон вместо таблицы?
  • Если форма будет использоваться в других местах, нужно всегда оборачивать её в провайдер, что может быть неудобно

Вопрос: какой подход будет наиболее правильным с точки зрения UX и архитектуры кода?

НейроАгент

Рекомендую использовать подход с контекст-провайдером для централизованного управления состоянием запросов клиентов, так как он обеспечивает лучшее разделение ответственности и предотвращает дублирование спиннеров при одновременном отображении данных из кеша.

Содержание


Анализ проблемы

Ваша проблема типична при работе с React Query в сложных компонентных структурах. Основная причина появления множественных спиннеров — независимая обработка состояний в каждом компоненте, даже при использовании одного и того же запроса.

javascript
// В Wallets компоненте
const { isFetching: isClientsFetching } = useGetClients({...});

// В DepositFundsForm компоненте  
const { isFetching: isClientsFetching } = useGetClients({...});

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


Сравнение подходов

1. Локальная корректировка состояний

Плюсы:

  • Простая реализация
  • Не требует изменения архитектуры

Минусы:

  • Не решает проблему корневым образом
  • Требует ручного управления в каждом компоненте
  • Нарушает принцип DRY (Don’t Repeat Yourself)
javascript
// В Wallets компоненте
const { wallets, isLoading, isAccountsFetching } = useGetWallets();
// Показываем спиннер только при загрузке счетов, а не клиентов

2. Разные ключи запроса

Плюсы:

  • Полная независимость состояний

Минусы:

  • Нарушает принцип единого источника данных
  • Двойной запрос к API при одновременном использовании
  • Уничтожает кеш между компонентами
javascript
// Не рекомендуется!
useQuery({ queryKey: ['clients', 'table', params] });
useQuery({ queryKey: ['clients', 'form', params] });

3. Контекст-провайдер

Плюсы:

  • Централизованное управление состоянием
  • Единый источник правды
  • Оптимизация UX при работе с кешем
  • Четкое разделение ответственности

Минусы:

  • Требует дополнительной инфраструктуры
  • Усложняет дерево компонентов

Оптимальное решение с контекстом

Контекст-провайдер — наиболее элегантное решение, так как он позволяет:

  1. Выполнить запрос один раз и поделиться результатами
  2. Централизованно управлять состоянием загрузки
  3. Показывать скелетоны вместо спиннеров при первоначальной загрузке
  4. Оптимизировать UX при работе с кешем
javascript
// ClientProvider.jsx
export const ClientProvider = ({ children }) => {
  const { data: clients, isLoading, isFetching } = useGetClients({
    clientType: ClientType.Client
  });

  const value = {
    clients,
    isLoading,
    isFetching
  };

  return (
    <ClientContext.Provider value={value}>
      {children}
    </ClientContext.Provider>
  );
};

Реализация контекст-провайдера

Шаг 1: Создание контекста

javascript
// context/ClientContext.js
import { createContext, useContext } from 'react';

export const ClientContext = createContext(null);

export const useClientContext = () => {
  const context = useContext(ClientContext);
  if (!context) {
    throw new Error('useClientContext must be used within ClientProvider');
  }
  return context;
};

Шаг 2: Провайдер с React Query

javascript
// providers/ClientProvider.jsx
import { useGetClients } from '@/hooks/useGetClients';
import { ClientContext } from '@/context/ClientContext';
import { ClientType } from '@/types';

export const ClientProvider = ({ children }) => {
  const { data: clients, isLoading, isFetching } = useGetClients({
    clientType: ClientType.Client
  });

  return (
    <ClientContext.Provider value={{ 
      clients, 
      isLoading, 
      isFetching 
    }}>
      {children}
    </ClientContext.Provider>
  );
};

Шаг 3: Обновление компонентов

javascript
// Wallets компонент
const Wallets = () => {
  const { wallets, isLoading } = useGetWallets(); // isFetching больше не нужен
  const { isFetching: isClientsFetching } = useClientContext();

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-wrap items-center justify-between gap-2">
        <DepositFundsButton />
      </div>
      <DataTable
        columns={Columns}
        data={wallets}
        isLoading={isLoading}
        // Показываем спиннер только при загрузке счетов
        isFetching={isLoading} // isFetching больше не используется
      />
    </div>
  );
};

// DepositFundsForm компонент
export const DepositFundsForm = ({ onClose }) => {
  const { data: clients, isFetching } = useClientContext();
  const { handleSubmit, control } = useForm();

  return (
    <>
      <Form {...methods}>
        <form className="space-y-6 overflow-y-auto px-4">
          <SelectField
            name="clientId"
            loading={isFetching}
            control={control}
            label="Client"
            placeholder="Client"
            options={clientOptions}
            className="min-w-[300px]"
          />
        </form>
      </Form>
      {/* ... */}
    </>
  );
};

Шаг 4: Обертка приложения

javascript
// App.jsx
import { ClientProvider } from '@/providers/ClientProvider';

function App() {
  return (
    <ClientProvider>
      <YourApp />
    </ClientProvider>
  );
}

Альтернативные подходы

1. Умный компонент-обертка

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

javascript
// components/ClientDataLoader.jsx
export const ClientDataLoader = ({ children, fallback }) => {
  const { isLoading, isFetching } = useGetClients({
    clientType: ClientType.Client
  });

  if (isLoading) {
    return fallback || <Skeleton />;
  }

  return children;
};

2. Пользовательский хук с мемоизацией

javascript
// hooks/useSharedClients.js
export const useSharedClients = () => {
  const { data: clients, isLoading, isFetching } = useGetClients({
    clientType: ClientType.Client
  });

  return useMemo(() => ({
    clients,
    isLoading,
    isFetching
  }), [clients, isLoading, isFetching]);
};

Рекомендации по архитектуре

1. Иерархия провайдеров

App
├── ClientProvider
│   ├── Wallets (таблица)
│   └── DepositFundsForm (форма)
├── AnotherComponent
│   └── AnotherClientComponent

2. Обработка кеша

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

javascript
const { isFetching, data } = useClientContext();

// Показываем скелетон при первоначальной загрузке
if (isLoading) {
  return <Skeleton />;
}

// Показываем спиннер только при обновлении данных из сети
if (isFetching && data) {
  return <TableWithRefreshingIndicator />;
}

// Показываем данные
return <DataTable data={data} />;

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

Для больших объемов данных используйте:

javascript
const { data, isLoading, isFetching } = useQuery({
  queryKey: ['clients'],
  queryFn: fetchClients,
  staleTime: 5 * 60 * 1000, // 5 минут
  cacheTime: 10 * 60 * 1000 // 10 минут
});

4. Обработка ошибок

Добавьте обработку ошибок в провайдер:

javascript
export const ClientProvider = ({ children }) => {
  const { data, isLoading, isFetching, error } = useGetClients({
    clientType: ClientType.Client
  });

  if (error) {
    return <ErrorFallback error={error} />;
  }

  return (
    <ClientContext.Provider value={{ 
      data, 
      isLoading, 
      isFetching,
      error
    }}>
      {children}
    </ClientContext.Provider>
  );
};

Заключение

  1. Контекст-провайдер является наиболее правильным решением для управления общими запросами в React Query, обеспечивая централизованное управление состоянием и предотвращая дублирование спиннеров.

  2. Показывайте скелетоны вместо спиннеров при первоначальной загрузке для лучшего UX, но используйте индикаторы обновления только при реальной перезагрузке данных из сети.

  3. Оборачивайте логику загрузки в провайдеры на уровне модулей или функциональных областей, а не на уровне всего приложения, чтобы избежать избыточной сложности.

  4. Используйте staleTime и cacheTime для оптимизации производительности и уменьшения частоты повторных запросов.

  5. Обрабатывайте ошибки централизованно в провайдерах, чтобы обеспечить единообразную обработку сбоев во всех компонентах.

Такой подход не только решает текущую проблему с множественными спиннерами, но и создает масштабируемую архитектуру для управления общими данными в React Query приложениях.