How to properly handle isFetching for the same request in multiple components (React Query)
Problem: When using the same request in multiple components, multiple spinners are displayed, which degrades the user experience.
Implementation example:
Query:
export const useGetClients = (params?: GetClientsRequest) =>
useQuery({
queryKey: ['clients', 'list', params],
queryFn: () => ClientClient.getClientApiInstance().getClients(params),
});
Table component:
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 hook:
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,
};
};
Form component:
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>
</>
);
};
Problem: When opening the form, two spinners appear - one in the table and one in the sidebar. This is incorrect from a UX perspective since it’s the same request and the data is already in the cache.
Possible solutions:
- Show spinner in the table only for isAccountsFetching, not for isAccountsFetching || isClientsFetching
- Use different query keys for the table and sidebar so they have independent states
- Wrap components in a context provider where client data will be loaded once and used jointly
For the third solution, questions arise:
- What to show while the provider is loading clients - a skeleton instead of the table?
- If the form will be used in other places, it will always need to be wrapped in a provider, which might be inconvenient
Question: Which approach would be most correct from both UX and code architecture perspectives?
I recommend using a context provider approach for centralized management of client request states, as it provides better separation of responsibilities and prevents duplicate spinners when simultaneously displaying data from cache.
Contents
- Problem Analysis
- Approach Comparison
- Optimal Solution with Context
- Context Provider Implementation
- Alternative Approaches
- Architecture Recommendations
Problem Analysis
Your issue is typical when working with React Query in complex component structures. The main reason for multiple spinners appearing is the independent state handling in each component, even when using the same request.
// In Wallets component
const { isFetching: isClientsFetching } = useGetClients({...});
// In DepositFundsForm component
const { isFetching: isClientsFetching } = useGetClients({...});
Each component independently tracks the loading state, which leads to visual duplication of loading indicators despite the fact that the request is executed once and the data is already in the cache.
Approach Comparison
1. Local State Adjustment
Pros:
- Simple implementation
- Doesn’t require architectural changes
Cons:
- Doesn’t solve the problem fundamentally
- Requires manual management in each component
- Violates the DRY (Don’t Repeat Yourself) principle
// In Wallets component
const { wallets, isLoading, isAccountsFetching } = useGetWallets();
// Show spinner only when loading accounts, not clients
2. Different Query Keys
Pros:
- Complete state independence
Cons:
- Violates the single source of truth principle
- Double API request when used simultaneously
- Destroys cache between components
// Not recommended!
useQuery({ queryKey: ['clients', 'table', params] });
useQuery({ queryKey: ['clients', 'form', params] });
3. Context Provider
Pros:
- Centralized state management
- Single source of truth
- UX optimization when working with cache
- Clear separation of responsibilities
Cons:
- Requires additional infrastructure
- Complicates the component tree
Optimal Solution with Context
A context provider is the most elegant solution as it allows:
- Execute the request once and share the results
- Centrally manage the loading state
- Show skeletons instead of spinners during initial loading
- Optimize UX when working with cache
// 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>
);
};
Context Provider Implementation
Step 1: Create the Context
// 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;
};
Step 2: Provider with 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>
);
};
Step 3: Update Components
// Wallets component
const Wallets = () => {
const { wallets, isLoading } = useGetWallets(); // isFetching no longer needed
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}
// Show spinner only when loading accounts
isFetching={isLoading} // isFetching no longer used
/>
</div>
);
};
// DepositFundsForm component
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>
{/* ... */}
</>
);
};
Step 4: Application Wrapper
// App.jsx
import { ClientProvider } from '@/providers/ClientProvider';
function App() {
return (
<ClientProvider>
<YourApp />
</ClientProvider>
);
}
Alternative Approaches
1. Smart Wrapper Component
For components used in different parts of the application, you can create a wrapper:
// components/ClientDataLoader.jsx
export const ClientDataLoader = ({ children, fallback }) => {
const { isLoading, isFetching } = useGetClients({
clientType: ClientType.Client
});
if (isLoading) {
return fallback || <Skeleton />;
}
return children;
};
2. Custom Hook with Memoization
// hooks/useSharedClients.js
export const useSharedClients = () => {
const { data: clients, isLoading, isFetching } = useGetClients({
clientType: ClientType.Client
});
return useMemo(() => ({
clients,
isLoading,
isFetching
}), [clients, isLoading, isFetching]);
};
Architecture Recommendations
1. Provider Hierarchy
App
├── ClientProvider
│ ├── Wallets (table)
│ └── DepositFundsForm (form)
├── AnotherComponent
│ └── AnotherClientComponent
2. Cache Handling
When working with cache, it’s important to properly handle states:
const { isFetching, data } = useClientContext();
// Show skeleton during initial loading
if (isLoading) {
return <Skeleton />;
}
// Show spinner only when updating data from network
if (isFetching && data) {
return <TableWithRefreshingIndicator />;
}
// Show data
return <DataTable data={data} />;
3. Performance Optimization
For large data volumes, use:
const { data, isLoading, isFetching } = useQuery({
queryKey: ['clients'],
queryFn: fetchClients,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000 // 10 minutes
});
4. Error Handling
Add error handling to the provider:
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>
);
};
Conclusion
-
Context provider is the most appropriate solution for managing shared requests in React Query, providing centralized state management and preventing duplicate spinners.
-
Show skeletons instead of spinners during initial loading for better UX, but use refresh indicators only when actually reloading data from the network.
-
Wrap loading logic in providers at the module or functional area level, rather than at the entire application level, to avoid excessive complexity.
-
Use staleTime and cacheTime to optimize performance and reduce the frequency of repeated requests.
-
Handle errors centrally in providers to ensure consistent failure handling across all components.
This approach not only solves the current problem with multiple spinners but also creates a scalable architecture for managing shared data in React Query applications.