cribbble

React Hooks: useLoadingState

avatar
Ward Poel

This hook is made for async tasks when you have to wait and possibly want to show a spinner to the user. For example when you send a POST, PUT, PATH or DELETE request to the backend.

Usage

The hook requires a callback function as first parameter and you can pass an object down as second parameter.

Example

The normal way

Let's say we have a list of users and we want to add a new user to that list. On top of the users list there is a form for creating a new user.

// The normal way
import { useState, useEffect } from 'react';

function Users() {
	let [name, setName] = useState("");
	let [users, setUsers] = useState([]);
	let [pending, setPending] = useState(false);

	// Fetch initial users
	useEffect(() => { ... }, []);

	function handleNameChange(event) {
		setName(event.target.value);
	}

	async function handleSubmitUser(event) {
		event.preventDefault();

		setPending(true);
		let response = await fetch("/api/users", {
			method: "POST",
			body: JSON.stringify({ name }),
		});
		
		if (response.ok) {
			let user = await response.json();
			setUsers([...users, user]);
			setName('')
		}

		setPending(false)
	}

	let renderButtonChildren = 'Save';
	if (pending) {
		renderButtonChildren = <Spinner />
	}

	return (
		<div>
			<form onSubmit={handleSubmitUser}>
				<div>
					<label htmlFor="name">Name</label>
					<input
						id="name"
						type="text"
						name="name"
						value={name}
						onChange={handleNameChange}
					></input>
				</div>

				<button type="submit" disabled={pending}>
					{renderButtonChildren}
				</button>
			</form>

			<UsersList users={users} />
		</div>
	);
}

We have a pending state that will immediately change to true when we press the button. But in some cases our fetch request is very fast so we see the spinner flickering and it will disappear straight away.

The new way with useLoadingState

I think that a better user experience is that the spinner will not show immediately, but after a little delay. And when the spinner is visible, it will stay at least visible for half a second. And another pro is that we don't have to keep a pending state in every component.

// The new way with useLoadingState
import { useState, useEffect } from 'react';
import useLoadingState from './use-loading-state.js'

function Users() {
	let [name, setName] = useState("");
	let [users, setUsers] = useState([]);
	let [submitUser, submittingUser, pendingSubmitUser] = useLoadingState(
		async function () {
			let response = await fetch("/api/users", {
				method: "POST",
				body: JSON.stringify({ name }),
			});

			if (response.ok) {
				let user = await response.json();
				setUsers([...users, user]);
				setName("");
			}
		}
	);

	// Fetch initial users
	useEffect(() => { ... }, []);

	function handleNameChange(event) {
		setName(event.target.value);
	}

	async function handleSubmitUser(event) {
		event.preventDefault();
		if (submittingUser) return;

		submitUser();
	}

	let renderButtonChildren = "Save";
	if (pendingSubmitUser) {
		renderButtonChildren = <Spinner />;
	}

	return (
		<div>
			<form onSubmit={handleSubmitUser}>
				<div>
					<label htmlFor="name">Name</label>
					<input
						id="name"
						type="text"
						name="name"
						value={name}
						onChange={handleNameChange}
					></input>
				</div>

				<button type="submit" disabled={pendingSubmitUser}>
					{renderButtonChildren}
				</button>
			</form>

			<UsersList users={users} />
		</div>
	);
}

Let's take a closer look at the hook

let [submitUser, submittingUser, pendingSubmitUser] = useLoadingState(callback, options);
  • submitUser is the function we have to call to do our POST request in this example.
  • submittingUser is a boolean that will become true, when we call submitUser. This boolean is used preventing the user of firing the request multiple times. See handleSubmitUser.
  • pendingSubmitUser 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 change 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 the async function that will execute the POST request in this example.
  • 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 spinners shows, it will stay at least for 600ms.

The Hook

Make sure to checkout my other hooks we use in this hook.

import { useRef } from 'react';
import useMountedState from './use-mounted-state.js';
import useImmutableCallback from './use-immutable-callback.js';

export default 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, minBusyMs = 500 } = options;

    	let minBusyPromise;
    	setTimeout(function () {
    		if (count === idRef.current) {
    			minBusyPromise = new Promise(function (resolve) {
    				setTimeout(resolve, minBusyMs);
    			});

    			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];
}

If you have any questions, I'm @WardPoel on Twitter.