One of the essential things in building modern web apps is to provide a good user experience. Think of showing a loading indicator when waiting, for example, a spinner when you’re submitting a form. These fancy animations are excellent if they are not flickering before your eyes. In this blog, you will find a practical React Hook to prevent loading indicators from flickering.
First, let’s start with a basic example where your loading indicator could flicker based on your internet speed. In this example, we have a list of books, and we want to add a new book to our must-read list.
function Books() {
let [title, setTitle] = useState('');
let [author, setAuthor] = useState('');
let [books, setBooks] = useState([]);
let [loading, setLoading] = useState(false);
// Fetch initial books
useEffect(() => { ... }, []);
// Title change handler
function handleTitleChange(event) {...}
// Author change handler
function handleAuthorChange(event) {...}
async function handleSubmit(event) {
event.preventDefault();
setLoading(true);
let response = await fetch("/api/books", {
method: "POST",
body: JSON.stringify({ title, author })
});
if (response.ok) {
let book = await response.json();
setBooks([...books, book]);
setTitle('');
setAuthor('');
}
setLoading(false);
}
return (
<div>
<form onSubmit={handleSubmit}>
<Input name="title" label="Title" type="text" value={title} onChange={handleTitleChange} />
<Input name="author" label="Author" type="text" value={author} onChange={handleAuthorChange} />
<Button type="submit">{loading ? <Spinner /> : 'Save'}</Button>
</form>
</div>
);
}
I think showing a loading indicator on your application is a great plus for UX. This way, users know the application is busy doing stuff in the background, and they are not stuck on a white screen. But what if your backend response is super fast, and your user gets a blink of a loading indicator? In that case, showing a loading indicator can be pretty annoying.
The code snippet above will always show a spinner, even when the data is fetched within 0.2 seconds. Of course, you need to display a spinner when data fetching takes more than 0.2 seconds. However, in my opinion, when data fetching is done within 0.2 seconds, a spinner is not needed.
The solution is simple: only show loading indicators when your async tasks are taking too long. But what is too long? When your server responds within 200ms, it is acceptable not showing a loading indicator (of course, this is a personal preference). With the useLoadingState
React Hook I made, it’s possible to change the times 😉.
Let’s upgrade our previous example to work with our new hook:
function Books() {
let [title, setTitle] = useState('');
let [author, setAuthor] = useState('');
let [books, setBooks] = useState([]);
let [addBook, runningAddBook, pendingAddBook] = useLoadingState(async function(book) {
let response = await fetch('/api/books', { method: 'POST', body: JSON.stringify(book) });
if (response.ok) {
let addedBook = await response.json();
setBooks([...books, addedBook]);
}
});
// Fetch initial books
useEffect(() => { ... }, []);
// Name change handler
function handleNameChange(event) {...};
// Author change handler
function handleAuthorChange(event) {...};
async function handleSubmit(event) {
event.preventDefault();
let book = { title, author };
await addBook(book);
};
return (
<div>
<form submit={handleSubmit}>
<Input name="name" type="text" value={name} onChange={handleNameChange} />
<Input name="author" type="text" value={author} onChange={handleAuthorChange} />
<Button type="submit" disable={runningAddBook} disabled={pendingAddBook}>
{pendingAddBook ? <Spinner /> : 'Save'}
</Button>
</form>
</div>
);
}
Let’s take a closer look at the hook:
let [addBook, runningAddBook, pendingAddBook] = useLoadingState(callback, options);
addBook
is an async function returned by the hook.runningAddBook
is a boolean that will turn true when we call addBook
, this boolean is used to disable
the onClick of the Button. • This is not a native HTML attribute but a custom one that prevents clicking the Button.pendingAddBook
• is a boolean that will become true after a delay we can provide in the options object. The default delay is 200ms. After 200ms, the boolean will become true, and we will disable the Button and show a spinner inside of the Button based on this boolean. When this boolean changes to true, it will at least stay true for minBusyMs variable we also can pass in the options object. The default value for minBusyMs is 500ms.callback
is your async function which will execute when calling addBook
. In this case, this function will make a POST request to our books endpoint.options
is an object you can pass through. Example: { delayMs: 400ms, minBusyMs: 600ms }
, • if we would pass this options object, the spinner will show after 400ms if the request is not finished yet. And when the spinner shows, it will stay for at least 600ms.Would you like to see a live example? Check it out here: https://codesandbox.io/s/use-loading-state-2pv8m1
import { useRef } from 'react';
import useMountedState from './use-mounted-state.js';
import useImmutableCallback from './use-immutable-callback.js';
export function useLoadingState(func, options = {}) {
let idRef = useRef(0);
let [running, setRunning] = useMountedState(false);
let [pending, setPending] = useMountedState(false);
let callback = useImmutableCallback(async function (...args) {
let count = ++idRef.current;
let { delayMs = 200, minPendingMs = 500 } = options;
let minBusyPromise;
setTimeout(function () {
if (count === idRef.current) {
minBusyPromise = new Promise(function (resolve) {
setTimeout(resolve, minPendingMs);
});
setPending(true);
}
}, delayMs);
try {
setRunning(true);
let result = await func(...args);
return result;
} finally {
if (minBusyPromise) await minBusyPromise;
if (count === idRef.current) {
idRef.current = -1;
setRunning(false);
setPending(false);
}
}
});
return [callback, running, pending];
}
In this hook, we use two other custom hooks. You can find these at https://scribbble.io/wardpoel/. The useImmutableCallback
is my own implementation of the newly announced React useEvent
hook. As this hook is not yet available, I created my own version.
The UX in today’s web apps improved massively in the last couple of years. However, there is still room for improvement 😉.
Written by
Ward Poel
Want to know more?