Fetching API data from a component using React requires handling loading, error, auth, and success states. This makes fetching data difficult to abstract and integrate well into the hooks in a natural way. Further, it’s easy to make mistakes calling function components with the wrong props only to find out at run time.
To solve this, we need to implement three things:
- A hook that calls the API and returns data representing the status (loading, error, success, etc).
- A higher-order component that handles rendering API states (e.g. a loading indicator) and maps data from a successful API call to a passed in component’s props.
- A component for displaying data from the API without having to duplicate loading states and errors.
See also
Implementing a useApi
hook
We want to represent our API call with data instead of callbacks and managing state manually. To do that, we’ll create our own hook that makes an API call, handles auth errors (like refreshing an auth token), and returning data from the API response.
Here’s an example that (ab)uses the useEffect
hook to make an API call and automatically refresh tokens and retry if there was a recoverable auth error.
Note: refreshToken
would be some other fetch
call to acquire a new API access token—a common scenario for token based authentication in a web app.
export enum ApiStatus {
// API request is being made
Loading,
// API call was successful
Success,
// API call resulted in an unauthorized error even after attempting
// a token refresh
ErrorUnauthorized,
// API resulted in an error
Error,
// The initial request failed and we are attempting to refresh an
// access token
RefreshingToken,
// We have new access token and will attempt to make a request
// again. Note: if the retry fails the status will be `Error`.
Retrying,
}
interface IApiData {
status: ApiStatus
error: any,
data: any,
}
/*
Hook for fetching data from the backend API. Returns an `IApiData`
object. See `ApiStatus` for which states need to be handled.
API calls that fail due to an unauthorized error (expired token) are
automatically retried after attempting to refresh an access token.
To do that (and avoid infinite loops) this is essentially a state
machine that supports the following ApiStatus transitions:
Loading -> Success
Loading -> Error
Loading -> RefreshingToken
RefreshingToken -> Retrying
RefreshingToken -> Error
RefreshingToken -> ErrorUnauthorized
Retrying -> Success
Retrying -> Error
Retrying -> ErrorUnauthorized
*/
export const useApi = (url: string, body = {}) => {
const [retryToggle, setRetryToggle] = useState(false);
const [data, setData] = React.useState<IApiData>({
status: ApiStatus.Loading,
error: null,
data: null,
});
React.useEffect(() => {
if (data.status === ApiStatus.RefreshingToken) {
// Try refreshing the access token and retrying the request
console.log('Attempting to refresh access token')
myRefreshTokenFn().then(() => {
setData({
status: ApiStatus.Retrying,
data: null,
error: null,
});
// Trigger a retry
setRetryToggle((i: boolean) => !i);
}).catch((err: MyRefreshTokenError) => {
// Handle errors and set the the API status accordingly
if (err === MyRefreshTokenError.Expired) {
setData({
status: ApiStatus.ErrorUnauthorized,
data: null,
error: err
});
} else {
setData({
status: ApiStatus.Error,
data: null,
error: err
});
}
});
return;
}
;
const authToken = myAuthTokenFn();
const request: RequestInit = {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
}
fetch(url, request)
.then((response) => {
if (!response.ok) {
throw Error(response.statusText);
}
return response.json()
})
.then((data) => {
setData({
status: ApiStatus.Success,
error: null,
data
});
})
.catch((err: Error) => {
// The only way this could happen is if something is broken
// with the refresh token process (e.g. we get a new access
// token, but it's invalid due to some coding error). We
// explicitely disallow going from Retrying -> RefreshingToken
// in order to avoid a potential infinite loop.
if (data.status === ApiStatus.Retrying) {
console.log('Unauthorized. Not retrying:', data.status);
setData({
status: ApiStatus.ErrorUnauthorized,
data: null,
error: err
});
return;
}
switch (err.message) {
// Recover from an unauthorized error by triggering a token
// refresh.
case 'Unauthorized':
setData({
status: ApiStatus.RefreshingToken,
data: null,
error: err
});
// Trigger the effect again
setRetryToggle((i: boolean) => !i);
break;
default:
setData({
status: ApiStatus.Error,
data: null,
error: err
});
}
});
// This dependency allows us to re-run the effect whenever this
// value changes.
}, [retryToggle]);
return data;
}
Here’s how to use it in a component:
export const FooComponent: FunctionComponent = () => {
let { status, error, data } = useApi(`https://myapi/resource`);
return (
...
)
}
However, we now need to handle all possible variants of ApiStatus
which would result in a large amount of boilerplate code to render things like loading states and logging out because of an auth error.
Higher-order component
To reduce the boilerplate we’ll introduce a higher order component that handles all the API states and calls or component that needed the API data in the first place.
type ApiStatusHandlerProps<T> = {
status: ApiStatus,
error: Error,
data: any,
component: FunctionComponent<T>,
}
/*
Higher order component that handles API request states for loading,
error, complete. This should be paired with the `useApi` hook.
Generic type `P` is the props type to pass to `FunctionComponent`
specified by `component` if the API request is successful. This
provides type safety because you will get a type error if the props
type doesn't match the component.
*/
export function ApiStatusHandler<P>(
{ status, data, component }: ApiStatusHandlerProps<P>
): React.ReactElement<P> | null {
switch (status) {
case ApiStatus.ErrorUnauthorized:
/* Handle logging out */
return (<p>Logging you out </p>);
case ApiStatus.Error:
/* Handle displaying an error */
return (<p>Big error!</p>);
case ApiStatus.Loading:
case ApiStatus.Retrying:
case ApiStatus.RefreshingToken:
/* Show loading */
return (<p>Loading</p>);
case ApiStatus.Success:
/* Call our component with data from the API response */
return component(data);
// Compile time error if we haven't covered all ApiStatus variants
default: ((x: never) => { throw new Error(status + " was unhandled."); })(status);
}
}
Now we can use the ApiStatusHandler
like this:
export const FooComponent: FunctionComponent = () => {
let { status, error, data } = useApi(`https://myapi/resource`);
return (
<ApiStatusHandler<BarProps> status={status} error={error} data={data} component={BarComponent} />
)
}
A component for rendering content from the API call
The component called by ApiStatusHandler
gets passed in props from the API response. Since ApiStatusHandler
is parameterized by the component’s prop type we get some additional type safety—you can’t accidentally call component
with the wrong props or you’ll get a compile time error.
(In practice you might also want to have a function to translate the API response data to props)
type BarProps = {
status: string
}
const BarComponent: FunctionComponent<BarProps> ({status}) => (
<p>{status}</p>
)
Links to this note
-
Preview a Pdf in the Browser With Authentication
An easy way to display a PDF preview in the browser is to use an
iframe
and set thesrc
to a link to the file. However, there is no way to set additional headers on requests from aniframe
so you can’t use this method if the request requires authentication or any other special headers.