Handle API Errors in JS Promise Chains with Redirects
Learn best practices to handle specific API errors in JavaScript promise chains, perform redirects without re-renders or store conflicts. Use wrappers, Axios interceptors for centralized fetch error handling in React apps.
How to properly handle redirects in JavaScript promise chains for specific API errors without causing unnecessary component re-renders or store update conflicts?
Problem Description:
The API returns various error types. For certain errors, the user must be redirected to another page. However, all project requests have handlers in components that process both successful and error responses. Failing to interrupt the promise chain before redirecting causes extra component re-renders and store conflicts (e.g., component unmounting triggers store cleanup while the rejected promise outputs errors via the store).
Checking error types in every component is inefficient; a centralized solution for all API requests and components is needed. Returning a dummy pending promise feels unsafe.
Pseudocode Example:
function sendApiRequest(url, params) {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(data => {
if (!data.success) {
if (data.code === 111) {
// Interrupt further execution to avoid re-renders and store conflicts
// during redirect
window.location.replace('#/another-page');
return new Promise(() => {});
}
throw new Error('ApiError');
}
console.log(data);
})
.catch(error => {
// Handle network errors or errors from then() blocks
console.error('NetworkError:', error);
});
}
What is the best practice to resolve this issue centrally and safely?
To handle specific api errors in JavaScript promise chains—like a 403 or custom code triggering a redirect—catch the error early, perform the redirect, then return Promise.resolve() to fulfill the chain safely. This stops propagation without rethrows or dummy pendings, preventing downstream fetch error handlers in components from firing and causing re-renders or store conflicts. Centralized via Axios interceptors or a wrapper function keeps it DRY across your app.
Contents
- The Problem with Promise Chains and Redirects
- Why Returning Pending Promises Fails
- Core Solution: Catch, Redirect, Resolve
- Centralized Implementation with Wrappers
- React Router Integration
- Axios Interceptors for Global Handling
- Testing and Edge Cases
- Sources
- Conclusion
The Problem with Promise Chains and Redirects
Ever chained a bunch of promises for an api error prone fetch, only to watch your React component spaz out with extra re‑renders after a redirect? That’s the trap here. Your pseudocode nails it: a fetch hits a bad status, you throw in .then, catch globally logs it, but if you redirect mid‑chain without halting things properly, the rejection bubbles up. Components waiting on that promise update state anyway—boom, unmount conflicts, store dispatches during cleanup, the works.
Why does this happen? Promises are fire‑and‑forget by design. A rejection jumps to the nearest .catch, but if you handle it partially (like logging or redirecting) and don’t rethrow or fulfill explicitly, the chain might continue or propagate unexpectedly. In React, that means useEffect or sagas keep churning post‑redirect, firing error failed to fetch logs or Redux actions on a ghost component.
JavaScript.info’s guide on promise error handling breaks it down: control jumps to .catch, but appending one at the end centralizes things—yet for redirects, you need to interrupt before that end handler.
Why Returning Pending Promises Fails
That return new Promise(() => {}); in your example? It’s a hanging pending promise, which feels clever but bites back. Components awaiting it never resolve or reject, so they stall forever—loaders spin, timeouts trigger, and you’ve leaked memory. Plus, it’s not centralized; every API call needs this hack.
Rethrowing works for full aborts (throw err sends it downstream), but for selective redirects on, say, code 111? It floods every handler with noise. You want surgical: detect api error, redirect if specific, then fulfill the chain so callers think “all good, no updates needed.”
This Stack Overflow thread on proper promise chain errors spells it out—if you want to continue (or fake it post‑redirect), return normally; to abort selectively, rethrow only non‑redirect cases.
Core Solution: Catch, Redirect, Resolve
Here’s the clean play: in your error handler, check the code. Match? Redirect and return Promise.resolve(null) (or any sentinel). Chain fulfills, no rejections propagate, components chill without re‑rendering.
function sendApiRequest(url, params) {
return fetch(url, params)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
.then(data => {
if (!data.success) {
if (data.code === 111) {
// Redirect and fulfill—no further execution
window.location.replace('#/another-page');
return Promise.resolve(null); // Key: fulfills chain
}
throw new Error('ApiError');
}
return data; // Success path
})
.catch(error => {
console.error('NetworkError:', error);
throw error; // Rethrow non-handled for upstream
});
}
Why resolve(null)? It signals “handled, move on” without data. Callers can check if (result === null) return; but mostly, they just skip updates. No pendings, no leaks.
MDN on Promise.catch confirms: .catch returns a promise, so resolving inside chains naturally.
Centralized Implementation with Wrappers
Don’t repeat this per‑component—wrap all APIs in a service. Boom, centralized api error handler.
// api.js
export const apiClient = {
get: wrap(fetch, 'GET'),
post: wrap(fetch, 'POST'),
// etc.
};
function wrap(fetchFn, method) {
return (url, options = {}) =>
fetchFn(url, { method, ...options })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
if (data.code === 111 || data.code === 403) { // Specifics here
window.location.href = '/unauthorized'; // Or router.push
return Promise.resolve(null);
}
if (!data.success) throw data;
return data;
})
.catch(err => {
console.error('API Error:', err);
throw err; // Let components handle generics
});
}
Every apiClient.get('/user') now auto‑handles redirects. Components just try { const data = await apiClient.get(url); if (!data) return; /* use data */ } catch { /* generic */ }.
CoreUI’s promise error guide pushes this: .catch or async/await with resolve post‑action.
React Router Integration
In React? Ditch window.location—use useNavigate or history. But centralized? Wrap in a hook or HOC.
// useApi.js
import { useNavigate } from 'react-router-dom';
export const useApi = () => {
const navigate = useNavigate();
return {
fetchData: (url) => fetch(url)
.then(/* ... */)
.then(data => {
if (data.code === 111) {
navigate('/another-page', { replace: true });
return Promise.resolve(null);
}
return data;
})
/* ... */
};
};
For true centralization without per‑hook, eject to interceptors (next). Components: const { data } = useApi(); if (!data) return null;. No re‑renders post‑navigate—the promise fulfills before unmount.
A Stack Overflow on React API fail redirects shows async/await catching early for clean history.replace.
Axios Interceptors for Global Handling
Fetching everywhere? Axios shines for promise chain globals. Response interceptor checks every request.
import axios from 'axios';
axios.interceptors.response.use(
response => response,
error => {
const data = error.response?.data;
if (data?.code === 111 || error.response?.status === 403) {
window.location.replace('/unauthorized'); // Or router
return Promise.resolve(null); // Fulfills for all callers
}
return Promise.reject(error); // Propagate others
}
);
One setup, app‑wide. React store? Interceptor runs pre‑dispatch—no conflicts. ITNext on centralizing React API errors loves this for catch‑all routes too.
Testing and Edge Cases
Test it? Mock fetch rejects, assert redirect fires and promise resolves.
test('redirects on code 111', async () => {
jest.spyOn(window.location, 'replace');
const mockReject = Promise.reject({ code: 111 });
// Mock fetch to mockReject
const result = await sendApiRequest('/test');
expect(result).toBeNull();
expect(window.location.replace).toHaveBeenCalledWith('/page');
});
Edge: concurrent requests? Interceptor handles all. Auth refreshes? Chain before interceptor. Race to redirect? First wins, resolves silence others.
Joe Attardi’s deep dive warns: logging without rethrow/resolve lets chains run—don’t.
Sources
- Error handling with promises — Explains .catch jumping and chain fulfillment via resolve: https://javascript.info/promise-error-handling
- How to handle error properly in Promise chain? — Rethrow to abort or return to continue selectively: https://stackoverflow.com/questions/43076811/how-to-handle-error-properly-in-promise-chain
- How to Redirect if API call fails in React Js? — Async/await catch for early redirects without propagation: https://stackoverflow.com/questions/67016343/how-to-redirect-if-api-call-fails-in-react-js
- Understanding error handling in Promise chains — Risks of fulfilling post-log without explicit resolve/reject: https://joeattardi.dev/understanding-error-handling-in-promise-chains
- Promise.prototype.catch() — Official docs on chaining catches and returning promises: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch
- How to handle promise errors in JavaScript — Resolve after handling to safely end chains: https://coreui.io/answers/how-to-handle-promise-errors-in-javascript/
Conclusion
Centralize api error redirects in wrappers or Axios interceptors: catch specifics, navigate, Promise.resolve(null) to fulfill without fuss. No more re‑renders, store drama, or per‑component checks—your promise chain stays predictable. Scale it, test mocks, and watch conflicts vanish.