Loading States in React: isLoading vs isFetching
Understanding the difference between isLoading and isFetching, and how to design better loading UX with skeletons and stale data
Introduction
Loading states are critical for good UX, but not all loading is the same. Understanding the difference between initial loading and background fetching helps you build interfaces that feel fast and responsive rather than janky and disruptive.
The Core Distinction
isLoading — First-Time Loading
isLoading means "am I fetching data for the first time in this component?"
When isLoading is true:
- No data exists yet
- Show a skeleton or full-page loader
- User is waiting for initial content
isFetching — Background Refetching
isFetching means "is a request currently in flight?"
When isFetching is true (but isLoading is false):
- Data already exists from a previous fetch
- Show existing data (possibly grayed out)
- Indicate background activity subtly
Visual Comparison
// ❌ Treating all loading the same - bad UX
function UserList() {
const { data, isLoading, isFetching } = useQuery('users', fetchUsers);
if (isLoading || isFetching) {
return <Spinner />; // Shows spinner even on refetch!
}
return <UserTable users={data} />;
}
// ✅ Differentiating loading states - great UX
function UserList() {
const { data, isLoading, isFetching } = useQuery('users', fetchUsers);
// First load: show skeleton
if (isLoading) {
return <UserTableSkeleton />;
}
return (
<div className={isFetching ? 'opacity-60' : ''}>
{isFetching && <RefetchIndicator />}
<UserTable users={data} />
</div>
);
}
Why a Single status Flag Isn't Enough
A single status field can't capture the nuance because isFetching and isSuccess can be true at the same time:
// Status-based approach (limited)
const state = {
status: 'success', // 'idle' | 'loading' | 'success' | 'error'
data: [...],
isFetching: true // Background refetch in progress
};
// status = 'success' but we're still fetching!
// A single status can't represent this state
Implementation Patterns
RTK Query
import { useGetUsersQuery } from './api';
function UserDashboard() {
const { data, isLoading, isFetching, error } = useGetUsersQuery();
// Initial load
if (isLoading) {
return <DashboardSkeleton />;
}
// Error state
if (error) {
return <ErrorDisplay error={error} />;
}
return (
<div>
{/* Subtle background refresh indicator */}
{isFetching && <RefreshBadge />}
{/* Existing data shown during refetch */}
<UserTable users={data} isLoading={isFetching} />
</div>
);
}
React Query / TanStack Query
import { useQuery } from '@tanstack/react-query';
function TodoList() {
const { data, isLoading, isFetching, isError } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 30000 // Consider data fresh for 30s
});
if (isLoading) return <TodoSkeleton count={5} />;
if (isError) return <ErrorState />;
return (
<>
{isFetching && <StaleDataIndicator />}
<ul className={isFetching ? 'pointer-events-none opacity-70' : ''}>
{data.map(todo => <TodoItem key={todo.id} todo={todo} />)}
</ul>
</>
);
}
Custom Hook Pattern
function useLoadingState() {
const [state, setState] = useState({
status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
data: null,
error: null,
isFetching: false
});
const isLoading = state.status === 'loading';
const isSuccess = state.status === 'success';
const isError = state.status === 'error';
// Derived: is this the first load?
const isInitialLoad = isLoading && !state.data;
// Derived: background refresh?
const isRefreshing = state.isFetching && isSuccess;
return {
...state,
isLoading,
isSuccess,
isError,
isInitialLoad,
isRefreshing
};
}
UX Patterns for Each State
Initial Load (isLoading = true, no data)
// ✅ Skeleton screens
function UserProfileSkeleton() {
return (
<div className="animate-pulse">
<div className="h-8 w-48 bg-gray-200 rounded mb-4" />
<div className="h-4 w-32 bg-gray-200 rounded mb-2" />
<div className="h-4 w-64 bg-gray-200 rounded" />
</div>
);
}
// ✅ Progressive loading
function ProgressiveLoader() {
return (
<div>
<Header /> {/* Load immediately */}
<Suspense fallback={<ContentSkeleton />}>
<SlowContent />
</Suspense>
</div>
);
}
Background Refresh (isFetching = true, data exists)
// ✅ Subtle indicator
function RefreshBadge() {
return (
<div className="fixed top-0 left-1/2 -translate-x-1/2
bg-blue-500 text-white px-3 py-1 rounded-b
text-sm animate-pulse">
Refreshing...
</div>
);
}
// ✅ Stale data overlay
function StaleOverlay({ children, isStale }) {
return (
<div className="relative">
{children}
{isStale && (
<div className="absolute inset-0 bg-white/50
flex items-center justify-center">
<Spinner size="sm" />
</div>
)}
</div>
);
}
// ✅ Disable interactions during refresh
<button
disabled={isFetching}
className={isFetching ? 'opacity-50 cursor-not-allowed' : ''}
>
Save
</button>
Error State
// ✅ Error with retry
function ErrorState({ error, onRetry }) {
return (
<div className="text-center py-8">
<p className="text-red-500 mb-4">{error.message}</p>
<button onClick={onRetry} className="btn-primary">
Try Again
</button>
</div>
);
}
Empty State
// ✅ Helpful empty state
function EmptyState({ type }) {
return (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">
No {type} yet. Create your first one!
</p>
</div>
);
}
State Machine Approach
const loadingStates = {
idle: {
onFetch: 'loading'
},
loading: {
onSuccess: 'success',
onError: 'error'
},
success: {
onRefetch: 'refreshing',
onFetch: 'loading'
},
refreshing: {
onSuccess: 'success',
onError: 'error_refresh'
},
error: {
onRetry: 'loading'
},
error_refresh: {
onRetry: 'refreshing'
}
};
function loadingReducer(state, event) {
return loadingStates[state]?.[event] || state;
}
Best Practices
Do's
// ✅ Show skeletons for initial load
if (isLoading) return <Skeleton />;
// ✅ Keep showing data during refetch
if (isFetching) showStaleIndicator();
// ✅ Disable destructive actions during fetch
<DeleteButton disabled={isFetching} />
// ✅ Use staleTime to reduce unnecessary fetches
{ staleTime: 30000 }
// ✅ Provide retry on error
<ErrorDisplay onRetry={refetch} />
Don'ts
// ❌ Don't show full-page spinner on refetch
if (isFetching) return <Spinner />;
// ❌ Don't hide data during background refresh
if (isFetching) return null;
// ❌ Don't use a single boolean for all loading
const loading = isLoading || isFetching; // Too broad!
// ❌ Don't flash loading states for cached data
// Use staleTime and cache-first strategies
// ❌ Don't forget empty and error states
// Handle all four: loading, empty, error, success
Summary Table
| State | Data Exists? | What to Show | User Can Interact? |
|---|---|---|---|
isLoading | No | Skeleton / spinner | No |
isFetching (first) | No | Skeleton / spinner | No |
isFetching (refresh) | Yes | Stale data + indicator | Limited |
isSuccess | Yes | Fresh data | Yes |
isError | Maybe | Error + retry | Retry only |
| Empty | Yes (empty) | Empty state message | Yes |
Conclusion
The difference between isLoading and isFetching is the difference between a polished app and a janky one:
isLoading— First load, show skeleton, user waitsisFetching— Background refresh, show stale data, user continues working
Design your loading UX around these two distinct states, and your app will feel faster and more professional.
#react #ux #loading-states #performance #web-development #frontend