Introduction Recently I came across an interesting bug, it sounded something like this: the user does something, but nothing happens. After a bit of digging, I realized that the problem was that the backend returned an error, and from the client side it was not processed in any way. After some thought, I decided to make a default error handler for the entire application. And today I will tell you what formats for common handlers exist and why it is better to agree on a single format with the backend than to fence a new solution every time. TL/DR final solution https://gist.github.com/pivaszbs/b44e534a0fc01efb6a1b8ba55011c40b?embedable=true Step-by-step explanation Define the requirements (my example) The error must be accessible from the outside (the handler must not eat it). It’s important because every time you hide errors behind abstractions, you get big difficulties afterward. The handler must be able to work with Zero-configuration (configured defaults). It’s important because it’s easier to read and work with What needs to be done Agree with the backend about the general error format, you can choose one of the universal ones described below // Backend error format type ApiError = { message: string } Make a general request handler that can be configured, like this: type Config = { errorHandler: (errors: ApiError[]) => void } // hook for common usage const useQuery = ({ errorHandler = defaultErrorHandler, url }: { url: string } & Partial<Config>) => { const [errors, setErrors] = useState(); const [data, setData] = useState(); const [loading, setLoading] = useState(); useEffect(() => { setLoading(true); fetch(url) .then(data => data.json()) .then(({ statusCode, status, ...data }) => { if (statusCode === 404) { const errors = [data.message]; errorHandler(errors); setErrors(errors); } else { setData(data); } }) .catch(e => { setErrors([e]) }) .finally(() => { setLoading(false) }) }, [url]) return { errors, data, loading } } Use a common request handler everywhere and feel happy and joyful that users will never again be puzzled by what happens with errors const { breed } = useParams(); const { data, errors } = useQuery({ url: `https://my-random-url`, errorHandler: console.error }); My realization explanation I chose the inner working format with Either because sometimes I want to extract errors by different functions and easily decompose my solution into small functions (RequestWrapper is a simple example of Either monad). Also, it’s better typed than the method, so you can try to do it this way :) catch type RequestWrapper<T, E extends ApiError> = [T, undefined] | [undefined, E[]]; const convertToEither = async <T, E extends ApiError>(req: Promise<T>): Promise<RequestWrapper<T, E>> => { try { return [await req, undefined]; } catch (e) { return [undefined, [e]] } } I am too lazy to realize logger and toast right now, so I just mock it :) class Toast { static showError = console.error; } class Loggger { static logError = console.log } I don’t want to handle and extract errors in one place, so I decided to make an extract function const extractErrors = async <T extends ApiResult, E extends ApiError>(wrapper: RequestWrapper<T, E>): Promise<RequestWrapper<T, ApiError>> => { const [res, errors = []] = wrapper; if (res?.status === 'incorrect-field-values') { return [, [...errors, ...res.errors]] } return wrapper; } I want to pass my error handler from outside, so I configure it in a fabric way const defaultErrorHandler = <E extends ApiError>(errors: E[]) => { errors.forEach(error => { Toast.showError(error); }) } const defaultErrorHandlerFabric = (errorHandler = defaultErrorHandler) => async <T, E extends ApiError>(wrapper: RequestWrapper<T, E>) => { const [, errors] = wrapper; if (errors?.length) { errorHandler(errors) } return wrapper; } Just put it all together const handleRequest = <T extends Promise<Response>>(req: T, config: Required<Config>) => { return convertToEither(req) .then(convertToJson) .then(extractErrors) .then(defaultErrorHandlerFabric(config.errorHandler)) .then(defaultLogger) } const useQuery = ({ errorHandler = defaultErrorHandler, url }: { url: string } & Partial<Config>) => { const [errors, setErrors] = useState(); const [data, setData] = useState(); const [loading, setLoading] = useState(); useEffect(() => { setLoading(true); handleRequest(fetch(url), { errorHandler }) .then(([data, errors]) => { setErrors(errors); setData(data); }) .finally(() => { setLoading(false) }) }, [url]) return { errors, data, loading } } Final thoughts Always try to handle errors, users don’t know how your server works) Don’t hide some really needed stuff under abstraction (like hiding errors), it’s better to configure it outside Don’t worry that all of your requests will be handled by one method, it’s ok (if it's scary, remember that you draw the entire application with react) Use Either’s for better Typescript types Try this approach in your app :)