Skip to main content

Frontend-Backend Integration Patterns in JavaScript

Published: May 24, 2026 Updated: August 30, 2026 Larry Qu 9 min read

The gap between “it works in development” and “it handles real conditions in production” is largely a frontend-backend integration problem. Network requests fail, tokens expire, servers return unexpected shapes, and users navigate away mid-request. Getting this right means choosing the right data fetching abstraction, designing a consistent error handling strategy, and managing loading states in a way that doesn’t make your UI feel fragile.

Choosing Your HTTP Abstraction

Three options dominate modern React projects: the Fetch API, Axios, and a dedicated server state library like React Query or Redux RTK Query. They solve different problems.

Fetch API is built into browsers and good for simple, one-off requests. It doesn’t throw on 4xx/5xx responses, requires manual JSON parsing, and has no built-in request cancellation or retry logic. It’s fine for small apps but becomes boilerplate-heavy as complexity grows.

Axios is a thin wrapper that adds automatic JSON parsing, response status throwing, and an interceptor system that makes adding auth tokens and handling 401s much cleaner. It’s a good choice when you need a configurable HTTP client but don’t want to commit to a full server-state library.

React Query / RTK Query are server state managers. They treat API responses as cache entries with lifecycles — fetching, caching, invalidating, and refetching automatically. They eliminate the useState + useEffect + loading + error boilerplate entirely and handle background refetching, stale-while-revalidate, and optimistic updates. These are the right choice for most data-driven React applications.

Axios: Configuration and Interceptors

The key to using Axios well is creating a configured instance rather than calling axios.get() directly everywhere. This gives you a single place to manage base URLs, timeouts, auth headers, and error recovery.

Create a central API client:

// src/lib/apiClient.js
import axios from 'axios';
import { getToken, refreshToken, clearSession } from './auth';

const apiClient = axios.create({
  baseURL: process.env.REACT_APP_API_URL ?? '/api',
  timeout: 15000,
  headers: { 'Content-Type': 'application/json' }
});

// Attach the auth token to every outgoing request
apiClient.interceptors.request.use((config) => {
  const token = getToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Handle token expiry transparently — retry the original request after refresh
let isRefreshing = false;
let failedQueue = [];

const processQueue = (error, token = null) => {
  failedQueue.forEach(({ resolve, reject }) =>
    error ? reject(error) : resolve(token)
  );
  failedQueue = [];
};

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      if (isRefreshing) {
        // Queue this request until the refresh completes
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then((token) => {
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return apiClient(originalRequest);
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const newToken = await refreshToken();
        processQueue(null, newToken);
        originalRequest.headers.Authorization = `Bearer ${newToken}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        clearSession();           // Token refresh failed — force re-login
        window.location.href = '/login';
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

export default apiClient;

The queue pattern is important: if multiple requests fire simultaneously while a token refresh is in progress, they all wait for the single refresh to complete rather than each attempting their own refresh.

React Query: Server State Done Right

React Query (TanStack Query) treats server data differently from local UI state. Server data is remote, asynchronous, potentially shared across components, and can become stale. React Query manages the full lifecycle: loading, error, success, and background re-validation.

Install and configure the provider at your app root:

// src/main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,      // Data stays fresh for 5 minutes
      retry: 2,                        // Retry failed requests twice
      refetchOnWindowFocus: false,     // Disable aggressive refetching
    },
  },
});

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
      <ReactQueryDevtools />
    </QueryClientProvider>
  );
}

Define queries as hooks, co-locating the fetching logic with the component that uses it:

// src/hooks/useUsers.js
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../lib/apiClient';

// Keys as constants prevent typos and make invalidation explicit
export const userKeys = {
  all: ['users'],
  list: (filters) => ['users', 'list', filters],
  detail: (id) => ['users', 'detail', id],
};

export function useUsers(filters = {}) {
  return useQuery({
    queryKey: userKeys.list(filters),
    queryFn: () => apiClient.get('/users', { params: filters }).then(r => r.data),
  });
}

export function useUser(id) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => apiClient.get(`/users/${id}`).then(r => r.data),
    enabled: !!id,   // Don't run until we have an ID
  });
}

export function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (userData) => apiClient.post('/users', userData).then(r => r.data),
    onSuccess: () => {
      // Invalidate the list so it refetches with the new user included
      queryClient.invalidateQueries({ queryKey: userKeys.all });
    },
  });
}

Consuming these in a component is clean and handles all states:

// src/components/UserList.jsx
import { useUsers, useCreateUser } from '../hooks/useUsers';

export function UserList() {
  const { data: users, isLoading, isError, error } = useUsers();
  const createUser = useCreateUser();

  if (isLoading) return <Spinner />;
  if (isError) return <ErrorMessage message={error.message} />;

  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
      <button
        onClick={() => createUser.mutate({ name: 'New User', email: '[email protected]' })}
        disabled={createUser.isPending}
      >
        {createUser.isPending ? 'Creating...' : 'Add User'}
      </button>
    </div>
  );
}

Compare this to the equivalent useState + useEffect approach — you’d need four pieces of state (data, loading, error, submitting), manual cleanup on unmount, and no automatic cache invalidation. React Query eliminates all of that.

Redux RTK Query: When You’re Already Using Redux

If your app already uses Redux Toolkit, RTK Query is the better choice over React Query. It integrates directly into the Redux store, shares the same DevTools, and lets you invalidate cached data using the same tag system you use for optimistic updates.

Define your API service:

// src/store/api/usersApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const usersApi = createApi({
  reducerPath: 'usersApi',
  baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers, { getState }) => {
      // Pull the token from the Redux auth slice
      const token = getState().auth.token;
      if (token) headers.set('Authorization', `Bearer ${token}`);
      return headers;
    },
  }),
  tagTypes: ['User'],
  endpoints: (builder) => ({
    getUsers: builder.query({
      query: (filters) => ({ url: '/users', params: filters }),
      providesTags: (result) =>
        result
          ? [...result.map(({ id }) => ({ type: 'User', id })), 'User']
          : ['User'],
    }),
    createUser: builder.mutation({
      query: (body) => ({ url: '/users', method: 'POST', body }),
      invalidatesTags: ['User'],   // Automatically refetch user lists after creation
    }),
    updateUser: builder.mutation({
      query: ({ id, ...body }) => ({ url: `/users/${id}`, method: 'PUT', body }),
      invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
    }),
  }),
});

export const { useGetUsersQuery, useCreateUserMutation, useUpdateUserMutation } = usersApi;

The tag invalidation system is the key feature: when createUser succeeds, it automatically invalidates all User cache entries, triggering background refetches wherever useGetUsersQuery is mounted.

Error Handling Strategy

Unhandled errors in async React code tend to either crash silently or produce confusing UI states. A consistent strategy has three layers: API-level normalization, component-level display, and React Error Boundaries for unexpected render errors.

Normalize API errors so all components deal with the same shape:

// src/lib/errorUtils.js
export class ApiError extends Error {
  constructor(message, status, details = null) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
    this.details = details;
  }

  isUnauthorized() { return this.status === 401; }
  isForbidden()    { return this.status === 403; }
  isNotFound()     { return this.status === 404; }
  isServerError()  { return this.status >= 500; }
}

// Add to your Axios response interceptor
apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response) {
      const { status, data } = error.response;
      const message = data?.message ?? data?.error ?? `Request failed with status ${status}`;
      return Promise.reject(new ApiError(message, status, data?.details ?? null));
    }
    if (error.request) {
      return Promise.reject(new ApiError('Network error — check your connection', 0));
    }
    return Promise.reject(error);
  }
);

Use an Error Boundary to catch rendering failures and async errors that bubble up:

// src/components/ErrorBoundary.jsx
import { Component } from 'react';

export class ErrorBoundary extends Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    // Send to Sentry, Datadog, or your error tracker
    console.error('ErrorBoundary caught:', error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? (
        <div role="alert">
          <h2>Something went wrong</h2>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            Try again
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

Wrap route-level components in an Error Boundary so a failure in one section doesn’t crash the entire app.

Structuring API Services

Mixing API calls directly into components makes them hard to test and refactor. Keep the HTTP layer in a separate service module:

// src/services/userService.js
import apiClient from '../lib/apiClient';

export const userService = {
  list: (params) => apiClient.get('/users', { params }).then(r => r.data),
  get:  (id)     => apiClient.get(`/users/${id}`).then(r => r.data),
  create: (data) => apiClient.post('/users', data).then(r => r.data),
  update: (id, data) => apiClient.put(`/users/${id}`, data).then(r => r.data),
  remove: (id)   => apiClient.delete(`/users/${id}`).then(r => r.data),
};

Services expose plain async functions — no React, no Redux. This means you can test them with a mock HTTP client without rendering anything.

Performance: Avoiding Unnecessary Requests

Two common sources of excess requests: components that refetch on every render, and search inputs that fire a request on every keystroke.

Debounce search:

import { useState, useEffect } from 'react';

function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

function UserSearch() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query);

  const { data: results } = useUsers({ search: debouncedQuery });

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search users..." />
      {results?.map(user => <UserCard key={user.id} user={user} />)}
    </div>
  );
}

For pagination, React Query’s keepPreviousData option prevents layout shifts when navigating pages — the old data stays visible until the new page loads:

const [page, setPage] = useState(1);
const { data, isFetching } = useQuery({
  queryKey: ['users', page],
  queryFn: () => userService.list({ page, limit: 20 }),
  placeholderData: (prev) => prev,   // TanStack Query v5 equivalent of keepPreviousData
});

Summary

Frontend-backend integration quality determines how resilient your UI feels under real conditions. The key decisions:

  • Use Axios with a configured instance for interceptor-based token management and error normalization
  • Prefer React Query or RTK Query over manual useState + useEffect for server state
  • Normalize API errors into a consistent class so components don’t need to handle raw HTTP errors
  • Wrap subtrees in Error Boundaries to contain unexpected failures
  • Keep HTTP calls in service modules, not in components
  • Debounce inputs and use pagination placeholders to reduce unnecessary requests

Resources

Comments

👍 Was this article helpful?