Asher Cohen
Back to posts

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

StateData Exists?What to ShowUser Can Interact?
isLoadingNoSkeleton / spinnerNo
isFetching (first)NoSkeleton / spinnerNo
isFetching (refresh)YesStale data + indicatorLimited
isSuccessYesFresh dataYes
isErrorMaybeError + retryRetry only
EmptyYes (empty)Empty state messageYes

Conclusion

The difference between isLoading and isFetching is the difference between a polished app and a janky one:

  • isLoading — First load, show skeleton, user waits
  • isFetching — 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