Home / Articles

React Auth, Data Loading and Errors: Axios, React Router, Redux, TanStack Query

Ten React mistakes with auth, data loading and errors, fixed by Axios interceptors, React Router loaders, Redux middleware, TanStack Query and error boundaries.

Publisher: REACTAPP.TOP15 min read3360 words

This article is published in English.

Sources: Stop Polluting Your Components: The React ‘Middleware’ Guide (Axios, Routers, and State)

On the backend, nobody checks authentication inside every route handler or pastes logging into each controller; that work lives in middleware, mounted once and applied to every request. React codebases often lose this discipline. Components end up handling token injection, 401 redirects, error logging and data fetching themselves, which produces boilerplate, inconsistent behaviour and bugs that only show up when a user clicks things in a particular order. A React app has its own request lifecycle, with API calls, route transitions and state dispatches all flowing through layers you can hook into. This guide covers ten common mistakes across Axios interceptors, React Router loaders, state libraries, error boundaries and Next.js middleware, and shows where each concern should live instead.

Transport concerns: auth and Axios interceptors

Mistake 1: handling 401 responses in every component

Authentication handling is the most frequent source of component clutter. Each fetch function gets its own catch block that checks for a 401, clears storage and redirects:

// ❌ Bad: Repeated in every component
async function fetchDashboard() {
  try {
    const res = await axios.get('/api/dashboard');
    return res.data;
  } catch (error) {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    throw error;
  }
}
async function fetchProfile() {
  try {
    const res = await axios.get('/api/profile');
    return res.data;
  } catch (error) {
    if (error.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    throw error;
  }
}

Beyond the repetition, this is brittle. Moving from local storage to cookies means editing every call site. Adding refresh-token rotation before the redirect means touching every file again. And a single forgotten check leaves the app in a half-logged-out state.

The fix is an Axios response interceptor on a shared client instance, which sees every response before any component does. The version below also attaches the token on the way out, and on a 401 it tries a refresh once (guarded by a _retry flag so it cannot loop), replays the original request with the new token, and only falls back to clearing storage and redirecting if the refresh itself fails:

// api/client.js
import axios from 'axios';
const api = axios.create({
  baseURL: process.env.REACT_APP_API_URL,
});
// Request interceptor: attach token
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});
// Response interceptor: handle global errors
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        // Attempt token refresh
        const refreshToken = localStorage.getItem('refreshToken');
        const { data } = await axios.post('/auth/refresh', { refreshToken });

        localStorage.setItem('token', data.accessToken);
        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;

        return api(originalRequest);
      } catch (refreshError) {
        // Refresh failed, redirect to login
        localStorage.removeItem('token');
        localStorage.removeItem('refreshToken');
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);
export default api;

Components now just import api and call it, with no awareness of 401 handling; one interceptor is now the only place that decides how authentication recovers. Three caveats apply. process.env.REACT_APP_API_URL is the Create React App convention; in a Vite project you would read import.meta.env.VITE_API_URL instead. If several requests fail at once, each triggers its own refresh, which can invalidate rotating refresh tokens; the solution is covered in why concurrent 401s log users out. And keeping tokens in localStorage exposes them to any XSS bug, so httpOnly cookies are worth considering.

Mistake 2: not returning a value from an interceptor

Interceptors form a chain, and each link must hand its value to the next. These two forget to:

// ❌ Bad: Broken chain
api.interceptors.response.use((response) => {
  console.log('Response received:', response.status);
  // Forgot to return response!
});
api.interceptors.request.use((config) => {
  config.headers['X-Request-ID'] = crypto.randomUUID();
  // Forgot to return config!
});

The effect is often described as the request hanging, but in current Axios versions the failure is usually more direct and just as confusing. A response interceptor that returns nothing resolves the caller's promise with undefined, so const { data } = await api.get('/user') throws a destructuring error far from its cause. A request interceptor that returns nothing leaves Axios without a config, and the request fails before it is sent. Either way the bug surfaces somewhere other than where it was written.

Always return what you received, and reject explicitly in error handlers so the caller's catch still runs:

// ✅ Good: Complete chain
api.interceptors.request.use(
  (config) => {
    config.headers['X-Request-ID'] = crypto.randomUUID();
    return config; // Critical
  },
  (error) => Promise.reject(error)
);
api.interceptors.response.use(
  (response) => {
    // Log without breaking the chain
    console.log(`[${response.config.url}] ${response.status}`);
    return response; // Critical
  },
  (error) => {
    // Always reject so the component catch block fires
    return Promise.reject(error);
  }
);

The analogy to Express holds: forgetting next() stalls an Express request, and forgetting return breaks an interceptor chain.

Mistake 3: attaching auth headers by hand

Copying an Authorization header into every call treats a cross-cutting concern as a local one. Sooner or later a call omits it, formats it differently or sends Bearer null. The snippet below also shows how easy it is to get wrong: in the post call the header object is passed as the request body and the payload as the config, because Axios expects post(url, data, config):

// ❌ Bad: Manual headers everywhere
api.get('/user', { headers: { Authorization: `Bearer ${token}` } });
api.post('/order', { headers: { Authorization: `Bearer ${token}` } }, payload);
api.get('/history', { headers: { Authorization: `Bearer ${token}` } });

A request interceptor removes the whole class of error by adding the header in one place, only when a token exists:

// ✅ Good: Global token injection
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('access_token');

  if (token && config.headers) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});

If you use TanStack Query, its caches offer global hooks for side effects too. A QueryCache onError can redirect on 401 for any query, and a MutationCache onError can show a toast for any failed mutation:

// ✅ Good: QueryClient global handlers
const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      if (error.response?.status === 401) {
        window.location.href = '/login';
      }
    }
  }),
  mutationCache: new MutationCache({
    onError: (error) => {
      toast.error(error.message);
    }
  })
});

The underlying principle is that authentication is a transport concern, not a component concern, so it should be handled at the transport layer.

Data loading: React Router loaders and TanStack Query caches

Mistake 4: fetching in useEffect when a route loader would do

The familiar pattern is mount, run an effect, show a spinner, receive data, render:

// ❌ Bad: Component-level fetching
function Dashboard() {
  const [data, setData] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
    api.get('/api/dashboard')
      .then(res => setData(res.data))
      .finally(() => setIsLoading(false));
  }, []);
  if (isLoading) return <Spinner />;
  return <DashboardContent data={data} />;
}

It produces request waterfalls: the router renders the component, only then does the effect start the request, and only after it returns does state update. Navigating back and forth refetches each time. Older React versions also warned about setting state after unmount; React 18 removed that warning, but the underlying race between a slow response and a newer one is still real.

React Router's data APIs (available with createBrowserRouter since v6.4) let a route declare a loader that fetches before the route element renders, and an errorElement that renders if the loader throws:

// ✅ Good: Route-level data loading
// router.jsx
import { createBrowserRouter } from 'react-router-dom';
import { Dashboard, dashboardLoader } from './Dashboard';
const router = createBrowserRouter([
  {
    path: '/dashboard',
    element: <Dashboard />,
    loader: dashboardLoader, // Runs before render
    errorElement: <DashboardError />
  }
]);
// Dashboard.jsx
import { useLoaderData } from 'react-router-dom';
export async function dashboardLoader() {
  const response = await api.get('/api/dashboard');
  if (response.status !== 200) {
    throw new Response('Failed to load dashboard', { status: response.status });
  }
  return response.data;
}
export function Dashboard() {
  const data = useLoaderData(); // Already available, no loading state needed

  return <DashboardContent data={data} />;
}

When Dashboard renders, useLoaderData() already has the data, so there is no component-level loading state or effect boilerplate. Two details are worth knowing. Navigation itself waits for the loader, so you still want a global pending indicator driven by useNavigation. And because Axios rejects non-2xx responses by default, the response.status !== 200 check in this loader never sees an error status; the rejection propagates to errorElement on its own.

For secondary data that should not block the page, the loader can await the critical request and hand back the slow one as an unresolved promise using defer:

export async function dashboardLoader() {
  const criticalData = await api.get('/api/summary');
  const slowData = api.get('/api/analytics'); // Promise, not awaited

  return defer({
    summary: criticalData.data,
    analytics: slowData.then(r => r.data)
  });
}

The page renders with the summary while analytics streams in, typically through Await and Suspense. In React Router v7, defer is no longer required and loaders can return promises directly, so check the documentation for the version you are on.

Mistake 5: hand-written thunks for every request

Redux Thunk was a big step forward when it arrived, but writing one per API call today means maintaining action type constants, action creators, reducer cases and status flags for work that modern tools handle declaratively:

// ❌ Bad: Thunk boilerplate for simple fetching
const FETCH_USER_REQUEST = 'FETCH_USER_REQUEST';
const FETCH_USER_SUCCESS = 'FETCH_USER_SUCCESS';
const FETCH_USER_FAILURE = 'FETCH_USER_FAILURE';
const fetchUser = (userId) => async (dispatch) => {
  dispatch({ type: FETCH_USER_REQUEST });
  try {
    const res = await api.get(`/users/${userId}`);
    dispatch({ type: FETCH_USER_SUCCESS, payload: res.data });
  } catch (error) {
    dispatch({ type: FETCH_USER_FAILURE, payload: error.message });
  }
};
// Reducer boilerplate...
// Component mapStateToProps...

That structure was reasonable when nothing better existed. For standard CRUD it is now unnecessary.

RTK Query and TanStack Query provide caching, request deduplication, background refetching and loading states out of the box. With RTK Query you describe a base query, including a prepareHeaders hook that attaches the token, and declare endpoints; hooks such as useGetUserQuery and useUpdateUserMutation are generated for you:

// ✅ Good: RTK Query handles the middleware
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const userApi = createApi({
  reducerPath: 'userApi',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers) => {
      const token = localStorage.getItem('token');
      if (token) headers.set('authorization', `Bearer ${token}`);
      return headers;
    }
  }),
  endpoints: (builder) => ({
    getUser: builder.query({
      query: (id) => `/users/${id}`,
    }),
    updateUser: builder.mutation({
      query: ({ id, ...body }) => ({
        url: `/users/${id}`,
        method: 'PUT',
        body,
      }),
    }),
  }),
});
export const { useGetUserQuery, useUpdateUserMutation } = userApi;

Behind those hooks, RTK Query caches responses per endpoint and argument, counts how many mounted components use each entry and discards entries nobody uses any more. Note that it does not normalise entities the way a hand-built entity adapter does; cached results are stored as returned. Either way, you write the business logic and the library handles the plumbing.

Redux middleware and UI safety nets

Mistake 6: mutating state inside Redux middleware

Custom Redux middleware is a good home for logging, analytics and action transformation. It is the wrong place to change state. Middleware runs between dispatch and the reducer, so writing to state there bypasses Redux's change tracking, breaks time-travel debugging and confuses DevTools:

// ❌ Bad: State mutation in middleware
const analyticsMiddleware = store => next => action => {
  if (action.type === 'ADD_TO_CART') {
    // Directly mutating state! Never do this.
    store.getState().cart.lastAddedAt = Date.now();
  }
  return next(action);
};

The mutation also fails to re-render anything, because Redux and its bindings detect changes by comparing references. Changing a field on the existing object keeps the reference identical, so subscribers see no update. Redux Toolkit's development-mode immutability check will flag this kind of mutation, which is one more reason to use it.

Restrict middleware to reading, logging, transforming or forwarding actions, and keep every state change in reducers. Here the middleware tracks the action and attaches a timestamp as meta, and the reducer performs an immutable update using it:

// ✅ Good: Middleware only intercepts and forwards
const analyticsMiddleware = store => next => action => {
  // Log the action before it reaches the reducer
  analytics.track(action.type, {
    payload: action.payload,
    timestamp: Date.now(),
    userId: store.getState().auth.userId,
  });

  // Optionally transform the action
  if (action.type === 'ADD_TO_CART') {
    action = {
      ...action,
      meta: { addedAt: Date.now() }
    };
  }

  return next(action); // Forward to reducer
};
// Reducer handles the state update
function cartReducer(state = initialState, action) {
  switch (action.type) {
    case 'ADD_TO_CART':
      return {
        ...state,
        items: [...state.items, action.payload],
        lastAddedAt: action.meta.addedAt, // Proper immutable update
      };
    default:
      return state;
  }
}

Note the coupling: the reducer reads action.meta.addedAt, so it depends on the middleware being installed. Generating the timestamp in the action creator (or a prepare callback in Redux Toolkit) avoids that hidden dependency. A handy mental image: middleware is the mailroom that can read envelopes and add stamps, but it never opens your bank statement to change the balance.

Mistake 7: treating error boundaries as an afterthought

Many apps have a single boundary around the root, or none. Error boundaries work as middleware for the component tree: they catch rendering errors in their children and stop one failure from taking down the whole interface. Without them, a malformed API response, a missing property or a failing third-party script can blank the entire page:

// ❌ Bad: No error boundaries
function App() {
  return (
    <div>
      <Header />
      <Sidebar />
      <Dashboard /> {/* If this crashes, everything dies */}
      <Footer />
    </div>
  );
}

Place boundaries at the layout and feature level so a crash in the analytics widget does not remove navigation. A boundary is a class component that sets error state in getDerivedStateFromError, reports in componentDidCatch and renders a fallback; nesting several gives each region its own recovery:

// ✅ Good: Strategic boundary placement
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }
static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }
  componentDidCatch(error, errorInfo) {
    logErrorToService(error, errorInfo);
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback || <DefaultErrorUI error={this.state.error} />;
    }
    return this.props.children;
  }
}
function App() {
  return (
    <ErrorBoundary fallback={<GlobalErrorPage />}>
      <Header />
      <div className="layout">
        <ErrorBoundary fallback={<SidebarError />}>
          <Sidebar />
        </ErrorBoundary>
        <main>
          <ErrorBoundary fallback={<WidgetError />}>
            <AnalyticsWidget />
          </ErrorBoundary>
          <ErrorBoundary fallback={<WidgetError />}>
            <RecentActivity />
          </ErrorBoundary>
        </main>
      </div>
    </ErrorBoundary>
  );
}

Keep their limits in mind: boundaries catch errors thrown while rendering, in lifecycle methods and in constructors below them, but not errors in event handlers, in asynchronous code such as timers or unawaited promises, or in the boundary itself. Those need ordinary try/catch or global handlers. If you use a framework with Server Components, combine client boundaries with its route-level error files (for example error.js in the Next.js App Router), catching server errors per route and client errors per component.

Performance and platform boundaries

Mistake 8: heavy work inside interceptors

Interceptors are asynchronous in the promise sense, but their code still runs on the main thread. Expensive decryption, transformation or parsing there blocks rendering:

// ❌ Bad: Heavy work in interceptor
api.interceptors.response.use((response) => {
  // This blocks rendering for large payloads
  response.data = heavyDecryptAndTransform(response.data);
  return response;
});

A 200 millisecond transformation is 200 milliseconds in which the page ignores input, which on a phone feels like a frozen app. Move heavy processing into a Web Worker, or postpone anything non-essential. Here export responses are sent to a worker and the interceptor awaits the result:

// ✅ Good: Async offloading for heavy work
api.interceptors.response.use(async (response) => {
  if (response.config.url.includes('/export')) {
    // Move heavy work off main thread
    const worker = new Worker(new URL('./transform.worker.js', import.meta.url));

    const transformed = await new Promise((resolve, reject) => {
      worker.onmessage = (e) => resolve(e.data);
      worker.onerror = reject;
      worker.postMessage(response.data);
    });

    worker.terminate();
    response.data = transformed;
  }

  return response;
});

Creating a new worker per response has a startup cost, and postMessage copies data with structured cloning, so for frequent calls reuse a long-lived worker and consider transferable buffers. For encryption or compression, the Web Crypto API inside a worker, or moving the work to the server, keeps the UI responsive. For more on this trade-off, see when to move CPU work into a Web Worker.

Mistake 9: treating Next.js middleware like application code

Next.js middleware has traditionally run on the Edge runtime, a V8 isolate rather than full Node.js. It cannot see React context or client state, and a regular database connection pool is not available there. Even so, it is common to see heavy validation, database queries or stateful logic in middleware.ts:

// ❌ Bad: Trying to run React logic in Edge Middleware
// middleware.ts
import { NextResponse } from 'next/server';
export async function middleware(request) {
  // This won't work: no access to React context
  const user = await db.user.findUnique(...); // No DB connection in Edge!

  // This is risky: heavy computation in Edge
  const result = complexAlgorithm(request.body);

  return NextResponse.next();
}

Keep it to lightweight, request-level work: rewrites, cheap cookie presence checks, A/B bucketing, bot filtering and geography-based routing. This example redirects unauthenticated visitors away from /dashboard, forwards an experiment header to the app, and uses matcher to run only on the paths that need it:

// ✅ Good: Lightweight edge middleware
import { NextResponse } from 'next/server';
export function middleware(request) {
  const token = request.cookies.get('token')?.value;

  // Simple auth check: presence only
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // A/B testing: lightweight header injection
  const experiment = request.cookies.get('exp')?.value || 'control';
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-experiment', experiment);

  return NextResponse.next({
    request: { headers: requestHeaders }
  });
}
export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*']
};

Real session validation belongs in route handlers or Server Components with full server access; middleware is the doorman, not the back office. Runtime support has been changing: recent Next.js releases added a Node.js runtime option for middleware and have revised the file convention, so confirm the current behaviour in the Next.js docs for your version.

Mistake 10: over-engineering a small app

Not every project needs a stack of interceptors, custom Redux middleware and workers. Three-page portfolio sites with elaborate interceptor chains, boundaries around static text and a full query layer for one contact form are easy to find:

// ❌ Bad: Over-engineered simple app
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: Infinity,
      cacheTime: Infinity,
      retry: 3,
      retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
      // ... 20 more options for a static site
    }
  }
});

That is cargo culting, not architecture: each layer adds mental overhead, bundle weight and indirection when debugging. An app with a couple of routes and a single request has no use for a caching strategy. The configuration is also internally inconsistent: with staleTime: Infinity, data is never considered stale, so the focus and reconnect refetch flags do nothing. And cacheTime was renamed gcTime in TanStack Query v5.

Scale the machinery with the app:

  • Static or simple sites: call fetch directly, without interceptors.
  • Small apps with authentication: one request interceptor for tokens and one response interceptor for 401s.
  • Medium apps with routing: add router loaders and layout-level error boundaries.
  • Large apps with complex state: bring in RTK Query or TanStack Query and custom middleware.

Knowing when not to add an abstraction is part of the job.

Middleware thinking for React apps

Middleware is not only a backend idea. In React it is the layer that keeps components focused on rendering while shared concerns are handled once.

  • Put authentication and error translation in the transport layer, on a single client instance, and always return or reject from interceptors.
  • Load route data in loaders or a query cache instead of component effects.
  • Keep Redux middleware side-effect only and let reducers own state changes.
  • Use several error boundaries, and remember what they cannot catch.
  • Keep heavy work off the main thread and keep edge middleware thin.
  • Add each layer only when the application's size justifies it.