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