Как правильно обрабатывать isFetching для одного и того же запроса в нескольких компонентах (React Query)
Проблема: при использовании одного и того же запроса в нескольких компонентах отображаются множественные спиннеры, что ухудшает пользовательский опыт.
Пример реализации:
Запрос:
export const useGetClients = (params?: GetClientsRequest) =>
useQuery({
queryKey: ['clients', 'list', params],
queryFn: () => ClientClient.getClientApiInstance().getClients(params),
});
Компонент таблицы:
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:
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,
};
};
Компонент формы:
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, так как запрос одинаковый и данные уже есть в кеше.
Возможные решения:
- Показывать спиннер в таблице только при isAccountsFetching, а не при isAccountsFetching || isClientsFetching
- Использовать разные ключи запроса (query key) для таблицы и сайдбара, чтобы у них были независимые состояния
- Обернуть компоненты в контекст-провайдер, где данные клиентов будут загружаться один раз и использоваться совместно
Для третьего решения возникают вопросы:
- Что показывать, пока в провайдере идёт загрузка клиентов — скелетон вместо таблицы?
- Если форма будет использоваться в других местах, нужно всегда оборачивать её в провайдер, что может быть неудобно
Вопрос: какой подход будет наиболее правильным с точки зрения UX и архитектуры кода?
Рекомендую использовать подход с контекст-провайдером для централизованного управления состоянием запросов клиентов, так как он обеспечивает лучшее разделение ответственности и предотвращает дублирование спиннеров при одновременном отображении данных из кеша.
Содержание
- Анализ проблемы
- Сравнение подходов
- Оптимальное решение с контекстом
- Реализация контекст-провайдера
- Альтернативные подходы
- Рекомендации по архитектуре
Анализ проблемы
Ваша проблема типична при работе с React Query в сложных компонентных структурах. Основная причина появления множественных спиннеров — независимая обработка состояний в каждом компоненте, даже при использовании одного и того же запроса.
// В Wallets компоненте
const { isFetching: isClientsFetching } = useGetClients({...});
// В DepositFundsForm компоненте
const { isFetching: isClientsFetching } = useGetClients({...});
Каждый компонент отслеживает состояние загрузки независимо, что приводит к визуальному дублированию индикаторов загрузки несмотря на то, что запрос выполняется один раз и данные уже находятся в кеше.
Сравнение подходов
1. Локальная корректировка состояний
Плюсы:
- Простая реализация
- Не требует изменения архитектуры
Минусы:
- Не решает проблему корневым образом
- Требует ручного управления в каждом компоненте
- Нарушает принцип DRY (Don’t Repeat Yourself)
// В Wallets компоненте
const { wallets, isLoading, isAccountsFetching } = useGetWallets();
// Показываем спиннер только при загрузке счетов, а не клиентов
2. Разные ключи запроса
Плюсы:
- Полная независимость состояний
Минусы:
- Нарушает принцип единого источника данных
- Двойной запрос к API при одновременном использовании
- Уничтожает кеш между компонентами
// Не рекомендуется!
useQuery({ queryKey: ['clients', 'table', params] });
useQuery({ queryKey: ['clients', 'form', params] });
3. Контекст-провайдер
Плюсы:
- Централизованное управление состоянием
- Единый источник правды
- Оптимизация UX при работе с кешем
- Четкое разделение ответственности
Минусы:
- Требует дополнительной инфраструктуры
- Усложняет дерево компонентов
Оптимальное решение с контекстом
Контекст-провайдер — наиболее элегантное решение, так как он позволяет:
- Выполнить запрос один раз и поделиться результатами
- Централизованно управлять состоянием загрузки
- Показывать скелетоны вместо спиннеров при первоначальной загрузке
- Оптимизировать UX при работе с кешем
// 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: Создание контекста
// 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
// 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: Обновление компонентов
// 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: Обертка приложения
// App.jsx
import { ClientProvider } from '@/providers/ClientProvider';
function App() {
return (
<ClientProvider>
<YourApp />
</ClientProvider>
);
}
Альтернативные подходы
1. Умный компонент-обертка
Для компонентов, которые используются в разных частях приложения, можно создать обертку:
// components/ClientDataLoader.jsx
export const ClientDataLoader = ({ children, fallback }) => {
const { isLoading, isFetching } = useGetClients({
clientType: ClientType.Client
});
if (isLoading) {
return fallback || <Skeleton />;
}
return children;
};
2. Пользовательский хук с мемоизацией
// 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. Обработка кеша
При работе с кешем важно правильно обрабатывать состояния:
const { isFetching, data } = useClientContext();
// Показываем скелетон при первоначальной загрузке
if (isLoading) {
return <Skeleton />;
}
// Показываем спиннер только при обновлении данных из сети
if (isFetching && data) {
return <TableWithRefreshingIndicator />;
}
// Показываем данные
return <DataTable data={data} />;
3. Оптимизация производительности
Для больших объемов данных используйте:
const { data, isLoading, isFetching } = useQuery({
queryKey: ['clients'],
queryFn: fetchClients,
staleTime: 5 * 60 * 1000, // 5 минут
cacheTime: 10 * 60 * 1000 // 10 минут
});
4. Обработка ошибок
Добавьте обработку ошибок в провайдер:
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>
);
};
Заключение
-
Контекст-провайдер является наиболее правильным решением для управления общими запросами в React Query, обеспечивая централизованное управление состоянием и предотвращая дублирование спиннеров.
-
Показывайте скелетоны вместо спиннеров при первоначальной загрузке для лучшего UX, но используйте индикаторы обновления только при реальной перезагрузке данных из сети.
-
Оборачивайте логику загрузки в провайдеры на уровне модулей или функциональных областей, а не на уровне всего приложения, чтобы избежать избыточной сложности.
-
Используйте staleTime и cacheTime для оптимизации производительности и уменьшения частоты повторных запросов.
-
Обрабатывайте ошибки централизованно в провайдерах, чтобы обеспечить единообразную обработку сбоев во всех компонентах.
Такой подход не только решает текущую проблему с множественными спиннерами, но и создает масштабируемую архитектуру для управления общими данными в React Query приложениях.