blog

Slow Down Please

23 Jul 2024

When it comes to creating a smooth user experience, even small details can make a big difference. Imagine using a website where every click feels responsive and every interaction smooth. But sometimes, things can feel too quick, making you wonder if your action was registered at all.

Submit Button

Try clicking this button

What do you think? Fast, right? But kind of janky. When users see that, their first thought probably isn’t “Wow, that’s fast,” but more like, “Did it run? Is it submitted?” It gets even worse if there’s no other notification, like a toast message or a completed page, to confirm the action.

Let’s jump to the code for that button:

const Component = () => {
  const [isLoading, setIsLoading] = useState(false);

  return (
    <button
      onClick={async () => {
        setIsLoading(true);
        await something();
        setIsLoading(false);
      }}
    >
      {isLoading ? <Spinner /> : 'Submit'}
    </button>
  );
};

Here is the button again but with some stats.

something()0ms / 100ms
isLoading0ms / 100ms

Can you see anything wrong with it? Nope, it’s perfectly functional. But that doesn’t mean there’s nothing to improve. One tweak we can make is to intentionally slow down the loading a bit.

const sleep = async (value: number) => { 
  return await new Promise((resolve) => setTimeout(resolve, value)); 
}; 

const Component = () => {
  const [isLoading, setIsLoading] = useState(false);

  return (
    <button
      onClick={async () => {
        setIsLoading(true);
        await something(); 
        await Promise.all([something(), sleep(500)]); 
        setIsLoading(false);
      }}
    >
      {isLoading ? <Spinner /> : 'Submit'}
    </button>
  );
};

I made 2 changes:

  1. Now there is a sleep function, which is just a small function that resolves after a certain amount of milliseconds.
  2. I also wrapped something() together with sleep(500) using Promise.all

Now what’s going to happen? If something takes 200ms to complete, the <Spinner /> will still show up because sleep is still going for another 300ms.

Let’s see it in action

something()0ms / 100ms
isLoading0ms / 500ms
something()100ms / 2000ms
sleep()500ms / 2000ms

Now it’s so much better. It will be more visible to the user that the submit is running, and they won’t question whether it is registered or not again.

Tree View

Next, let’s look at another example with a tree view:

public
0ms / 100ms
src
0ms / 500ms
package.json

Here is the code for the <Folder /> component

const Folder = ({ folder }) => {
  const [isOpen, setIsOpen] = useState(false);
  const { get, isLoading } = useData(folder.id);

  return (
    <div
      onClick={async () => {
        const v = !isOpen;
        setIsOpen(v);
        if (v) await get();
      }}
    >
      <ChevronIcon />
      {isLoading ? <Spinner /> : isOpen ? <FolderOpenIcon /> : <FolderIcon />}
      {folder.name}
    </div>
  );
};

Notice how the current implementation feels a bit too quick, with the spinner showing up and disappearing almost instantly for some folders. Now, let’s look at the improved version where I delay the spinner for 150ms.

public
0ms / 100ms
src
0ms / 500ms
package.json

See? Much better, right? By slowing down the spinner, it removes the jankiness, and the overall experience feels more seamless and immediate.

Here’s how I made this improvement:

  1. I added a useDebounce function to handle debouncing. This ensures that the value is only updated after it has remained unchanged for the entire delay period, even if it changes frequently.

  2. I wrapped the isLoading state with useDebounce so that the <Spinner /> only appears after a short delay. This way, the spinner doesn’t show up instantly with every brief change in the isLoading state, resulting in a smoother and less jarring user experience.

const Folder = ({ folder }) => {
  const [isOpen, setIsOpen] = useState(false);
  const { get, isLoading } = useData(folder.id);

  const debouncedIsLoading = useDebounce(isLoading, 150); 

  return (
    <div
      onClick={async () => {
        const v = !isOpen;
        setIsOpen(v);
        if (v) await get();
      }}
    >
      <ChevronIcon />
      {isLoading ? <Spinner /> : isOpen ? <FolderOpenIcon /> : <FolderIcon />}
      {debouncedIsLoading ? <Spinner /> : isOpen ? <FolderOpenIcon /> : <FolderIcon />}
      {folder.name}
    </div>
  );
};

By adding this debounce, we ensure that the <Spinner /> doesn’t flash too quickly, providing a more pleasant experience for the user.

Here is the <TreeView /> again with the delay control so you can see the effect of different values. Experiment with the delay values to find the perfect balance for your application.

public
0ms / 100ms
src
0ms / 500ms
package.json
Delay150ms / 500ms

By making these small adjustments, we can significantly enhance the user experience, making interactions feel more intuitive and less jarring. Remember, speed is important, but clarity and user comfort are equally crucial.

React