Fetching, Fetched, and Fetch Error Is Not Enough
June 2nd, 2019
When we read about making HTTP requests we often see the usual fetching, fetched, or a fetch error state. And then the user interface should be updated to reflect that update. These three states describe the most important implementations to doing any CRUD (Create, Read, Update, Delete) operation.
As developers, we are responsible for keeping the user engaged with our interface and help them have the best experience possible. We think of users as our allies and unwanted bugs as our enemies.
When retrieving data for example, we want to let the user know that we are trying to retrieve data while they are waiting for it. When the data is retrieved, we should show the data. When an error occurred and the data was not able to be retrieved for what ever the reason says in the error object, we must let the user know that there was an error and utilize what was retrieved in the error. The last thing we want to do is leave them hanging--unless we're trying to get them to leave and never come back of course.
But that's not enough.
There's a fourth state that should not only belong with the fetching states, but in any sort of operation, especially CRUD operations.
At the company I work at, we do a lot of HTTP requests. One of the biggest problems we had was that there were random frozen loading spinners in random web pages that ended up being stuck in the phase until the user refreshed the page. This doesn't happen all the time however. But my boss really did not like frozen loading spinners. This was extremely bad user experience. I don't blame him, because every user affected by this issue is left hanging and forced to do some action that is totally opposite of what we want them to do.
Can you guess what it is? Yes, you guessed right. They hit the back button and go somewhere else. They close their browser and occupy themselves with something else. The list goes on. Or the worst thing that could ever happen... is that they hit the back button and decide to use a competitor's website instead. We just lost a potential valuable customer. Bad user experience is an implicit loss of money, unfortunately :(.
You need a timed out state. When the server doesn't respond or for some reason the fetching state was dispatched and the call got stuck right before sending the request (it happens), the loading spinner you attached the fetching state to becomes frozen. It's no longer a temporary loading spinner used to signal that the data is coming. It's now a loading spinner that runs infinitely and the whole world is never coming to an end. You've now passed the responsibility of handling that bug to the user. You failed as a developer. Please try again.
So how do we implement this in React?
Some libraries like axios provide a timeout option. But you shouldn't rely on this to be 100% accurate. My experience at the company I work at has shown me that it is not enough and we should not heavily depend on it.
Instead of doing the usual fetching/fetched/fetch error implementation we'll go ahead and do an updating/updated/update error one because we hear "fetch" on every corner of the street in JavaScript.
For this tutorial we will make a custom react hook that will provide a method updateUser
to invoke the update handler, and inside it will dispatch some actions while making the API call. It will also set a temporary timeout function to be invoked after ___ amount of seconds.
The hook will be registered with a few states. These states along with the update handler will be passed to the caller. We'll start with the hook implementation and then apply the timeout part afterwards.
Lets start with a basic component App.js
and work our way up:
App.js
import React from 'react'
import './App.css'
const App = (props) => {
return <div>Update Timeout</div>
}
export default App
Now to go ahead and start with the whole "updating user" implementation we are going to create a hook called useUpdateUser
. The main point of this hook is to perform an update operation on a user's email or password.
useUpdateTimeout.js
import axios from 'axios'
const useUpdateUser = () => {
const updateUser = async (userId, params) => {
try {
if (!userId) {
throw new Error('userId is undefined')
} else if (!params) {
throw new Error('params is undefined')
}
const url = `https://someapi.com/v1/account/${userId}/`
const response = await axios.put(url, params)
const updatedUser = response.data
return updatedUser
} catch (error) {
throw error
}
}
return {
updateUser,
}
}
export default useUpdateUser
Now to define the states we are going to use useReducer. I personally use useReducer on just about every hook that uses some sort of state (even when its just 1 state -_-).
const initialState = {
updating: false,
updated: false,
updateError: null,
}
Here we defined three necessary states to make an app running normally. In the JavaScript community, we've often been taught that when there are no errors in a request, you pass in null to the error argument so that the caller will know that data has been retrieved without issues. So, we used the same standard here on updateError because it works fine here too.
Now we need to define a reducer to apply changes to concurrent state updates. The reducers should reflect on the initial state:
import { useReducer } from 'react'
And then after the initialState implementation we would define the reducer:
const reducer = (state, action) => {
switch (action.type) {
case 'updating':
return { ...initialState, updating: true }
case 'updated':
return { ...initialState, updated: true }
case 'set-error':
return { ...initialState, updateError: action.error }
default:
return state
}
}
You might have noticed that the initialState is being spread in each switch case instead of spreading the usual state. Why is that?
This effectively does the same thing as you would normally write with spreading state, only now we don't have to write all the boilerplate code. To avoid unecessary bugs and code size, we want the whole implementation to be as simple as possible. When updating switches to true, the UI should be set back to its original state and only care about the updating part. When the update has finished and the user profile has been updated, the UI should be set back to its original state and also only care that the user profile was updated (the updated part of the state). The same also goes for updateError.
Otherwise we'd be writing it like this:
const reducer = (state, action) => {
switch (action.type) {
case 'updating':
return { ...state, updated: false, updating: true }
case 'updated':
return { ...state, updated: true, updating: false, updateError: null }
case 'set-error':
return {
...state,
updated: false,
updating: false,
updateError: action.error,
}
default:
return state
}
}
Which version do you prefer? I don't know about you but I prefer the initialState version! (One would argue that using the initialState version takes away all the power and flexibility of our state updates. I totally agree, but the states here are accomplishing the same goal).
The next thing we want to do now is to attach our implementation to our useUpdateTimeout
hook with useReducer:
useUpdateTimeout.js
const useUpdateUser = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const updateUser = async (userId, params) => {
try {
if (!userId) {
throw new Error('userId is undefined')
} else if (!params) {
throw new Error('params is undefined')
}
const url = `https://someapi.com/v1/account/${userId}/`
const response = await axios.put(url, params)
const updatedUser = response.data
return updatedUser
} catch (error) {
throw error
}
}
return {
updateUser,
}
}
And we also want to provide these helpful utilities to the caller by spreading them on the return statement so that they actually update the components when the states change:
return {
...state,
updateUser,
}
So far, we now have something like this:
useUpdateTimeout.js
import { useReducer } from 'react'
import axios from 'axios'
const initialState = {
updating: false,
updated: false,
updateError: null,
}
const reducer = (state, action) => {
switch (action.type) {
case 'updating':
return { ...initialState, updating: true }
case 'updated':
return { ...initialState, updated: true }
case 'set-error':
return { ...initialState, updateError: action.error }
default:
return state
}
}
const useUpdateUser = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const updateUser = async (userId, params) => {
try {
if (!userId) {
throw new Error('userId is undefined')
} else if (!params) {
throw new Error('params is undefined')
}
const url = `https://someapi.com/v1/account/${userId}/`
const response = await axios.put(url, params)
const updatedUser = response.data
return updatedUser
} catch (error) {
throw error
}
}
return {
...state,
updateUser,
}
}
export default useUpdateUser
When we make the app invoke updateUser it's a good idea to also make it dispatch some actions while going through its process for the components to update accordingly:
const updateUser = async (userId, params) => {
try {
dispatch({ type: 'updating' })
if (!userId) {
throw new Error('userId is undefined')
} else if (!params) {
throw new Error('params is undefined')
}
const url = `https://someapi.com/v1/api/user/${userId}/`
const response = await axios.put(url, params)
const updatedUser = response.data
dispatch({ type: 'updated' })
return updatedUser
} catch (error) {
dispatch({ type: 'set-error', error })
}
}
The UI should change depending on which type of action is being dispatched at the time.
The app should be running just fine right now and we can just stop there. However, this post was to implement a timedOut state, so we are going to implement that next.
To begin with, we should think about the setTimeout function that JavaScript already provides us. This will help make a timed out request happen because it can be used to dispatch a timed-out action that the UI components can listen from.
When the timed-out action is dispatched, the UI should immediately just drop what it was doing and display in their space that the operation was timed out. This way, the user will know that either something happened with their internet or something went wrong with the server. You can optionally provide a retry button to retry the request. I'll be making another tutorial to implement that so hang on there if you're looking for some guidance!
Anywho, the very first thing we want to declare is where to attach the setTimeout reference to.
For this, we'll import useRef from react and attach it onto the .current property inside the useEffect block:
import { useReducer, useRef } from 'react'
Putting it inside the hook:
const [state, dispatch] = useReducer(reducer, initialState)
const timeoutRef = useRef(null)
And now, inside the updateUser method this is where we declare the setTimeout function to start counting down to dispatch a timed-out action if the timer ever reaches the ends of its life:
const updateUser = async (userId, params) => {
try {
dispatch({ type: 'updating' })
if (!userId) {
throw new Error('userId is undefined')
} else if (!params) {
throw new Error('params is undefined')
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
dispatch({ type: 'timed-out' })
}, 30000)
const url = `https://someapi.com/v1/api/user/${userId}/`
const response = await axios.put(url, params)
clearTimeout(timeoutRef.current)
const updatedUser = response.data
dispatch({ type: 'updated' })
return updatedUser
} catch (error) {
clearTimeout(timeoutRef.current)
dispatch({ type: 'set-error', error })
}
}
Final output:
import { useReducer, useRef } from 'react'
import axios from 'axios'
const initialState = {
updating: false,
updated: false,
updateError: null,
}
const reducer = (state, action) => {
switch (action.type) {
case 'updating':
return { ...initialState, updating: true }
case 'updated':
return { ...initialState, updated: true }
case 'set-error':
return { ...initialState, updateError: action.error }
case 'timed-out':
return { ...initialState, timedOut: true }
default:
return state
}
}
const useUpdateUser = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const timeoutRef = useRef(null)
const updateUser = async (userId, params) => {
try {
dispatch({ type: 'updating' })
if (!userId) {
throw new Error('userId is undefined')
} else if (!params) {
throw new Error('params is undefined')
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
dispatch({ type: 'timed-out' })
}, 30000)
const url = `https://someapi.com/v1/api/user/${userId}/`
const response = await axios.put(url, params)
clearTimeout(timeoutRef.current)
const updatedUser = response.data
dispatch({ type: 'updated' })
return updatedUser
} catch (error) {
clearTimeout(timeoutRef.current)
dispatch({ type: 'set-error', error })
}
}
return {
...state,
updateUser,
}
}
export default useUpdateUser
This actually looks like a finished implementation so far! However, I like to provide a little customization to the hook just to make it more flexible by letting the caller provide a custom timeout:
const useUpdateUser = ({ timeout = 30000 }) => {
const [state, dispatch] = useReducer(reducer, initialState)
const timeoutRef = useRef(null)
...
}
timeoutRef.current = setTimeout(() => {
dispatch({ type: 'timed-out' })
}, timeout)
What's going to happen with updateUser is that it first dispatches an updating action. The UI components should display some kind of "pending" representation so that the user gets excited about their profile being updated. If this method ever gets accidentally called twice, we have an early clearTimeout(timeoutRef.current) happening right above the setTimeout line so that it can remove the previous one that was just set.
The line after that is the setTimeout line. This is the most important part of this entire post, as without it there won't be no timeout feature! :)
Once the await call succeeds, we know the user's profile was updated successfully. Once that success response arrives, we then know that the setTimeout statement isn't needed anymore, so we erase it with clearTimeout(timeoutRef.current). And finally at the end of the execution we dispatch an updated action so that the successful profile update can be reflected in the interface.
If there were any errors during the update process, a clearTimeout(timeoutRef.current) also runs. The reason is because since we actually received some response back from the request, the timeout is no longer relative in the path that the code is going because now we only care about the error that occurred instead.
This is one way the hook would be implemented with this hook in a real scenario:
import React, { useState } from 'react'
import './App.css'
import useUpdateUser from './useUpdateUser'
const App = (props) => {
const {
updating,
updated,
updateError,
timedOut,
updateUser,
} = useUpdateUser({
timeout: 12000,
})
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const onSubmit = (e) => {
e.preventDefault()
const params = { email, password }
updateUser('my-user-id123', params)
}
const isInitial = !updating && !updated && !updateError && !timedOut
const errMsg =
updateError &&
(updateError.message || 'An error occurred. Please try again later')
return (
<div className='container'>
<h2>
{isInitial && 'Update your email or password below'}
{updating && 'Updating your profile...'}
{updated && 'Your profile has been updated'}
{errMsg && <span className='error-txt'>{errMsg}</span>}
{timedOut &&
'We did not receive a response from the server. Please try again later'}
</h2>
<form onSubmit={onSubmit}>
<div>
<input
type='text'
placeholder='Email'
name='email'
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<input
type='text'
placeholder='Password'
name='password'
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div>
<button type='submit'>Submit</button>
</div>
</form>
</div>
)
}
export default App
Here are the most beautiful screenshots of the implementation:
There we have it! Stay tuned for another tutorial for next time. Also, you can subscribe to my newsletter at https://jsmanifest.com to get my updates straight to your inbox. They're free.