API Error Handling in React

Written by antonkrylov322 | Published 2022/10/18
Tech Story Tags: typescript | error-handling | rest-api | api | javascript | software-development | software-engineering | react

TLDRThe error handler must be accessible from the outside (the handler must not eat it) The error must be able to work with Zero-configuration (configured defaults) It’s important because every time you hide errors behind abstractions, you get big difficulties afterward. Backend error format is one of the universal ones described below. Use a common request handler everywhere and feel joyful and joyful users will never be puzzled by what happens with errors with them again. The final solution is to agree on a single format with the backend rather than fence a new solution.via the TL;DR App

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)

  1. 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.

  2. 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

  1. 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
}

  1. 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 }
}

  1. 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

  1. 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 catch method, so you can try to do it this way :)

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]]
    }
}

  1. 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
    }
    

  2. 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;
    }
    

  3. 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;
    }
    

  4. 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

  1. Always try to handle errors, users don’t know how your server works)
  2. Don’t hide some really needed stuff under abstraction (like hiding errors), it’s better to configure it outside
  3. 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)
  4. Use Either’s for better Typescript types
  5. Try this approach in your app :)


Written by antonkrylov322 | Frontend developer with taste of beer
Published by HackerNoon on 2022/10/18