close
address. Gaston Geenslaan 11 B4, 3001 Leuven
s.
All Insights

A React Hook to prevent flickering spinners

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.

Written by
Ward Poel

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.

react hook - wheelhouse

The problem

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>
);
}
Example of a flickering loading indicator

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

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

The hook

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.

Conclusion

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

Subscribe to our newsletter

Raccoons NV (Craftworkz NV, Oswald AI NV, Brainjar NV, Wheelhouse NV, TPO Agency NV and Edgise NV are all part of Raccoons) is committed to protecting and respecting your privacy, and we’ll only use your personal information to administer your account and to provide the products and services you requested from us. From time to time, we would like to contact you about our products and services, as well as other content that may be of interest to you. If you consent to us contacting you for this purpose, please tick below:

By submitting this form, you agree to our privacy policy.

In order to provide you the content requested, we need to store and process your personal data. If you consent to us storing your personal data for this purpose, please tick the checkbox below.

More blog articles that can inspire you

24/11/2022

Design consistency: from chaos to order when designing

Design consistency is a design principle I care about, notice very fast, and get frustrated about all the time. 😅

what we do

Socials

address. Gaston Geenslaan 11 B4, 3001 Leuven