Reusable Progress component in Remix or React Router with tailwind

Table of contents

If you've been using Remix.run to develop applications you have probably run into the following scenario:

  • You have a form that gets submitted to the server / you have a redirect on the page

  • It takes a while to process and transition

  • You want to show a loading state to the user

Well, this article goes over a neat little trick to show a spinner to your user while the page is loading which is highly modular and customizable. Let's first go over the component setup and later we can discuss how we can use it.

Setup

First, install a dependency called spin-delay, this allows you to have a delay before the spinner is actually shown and a minimum duration before it disappears, so this allows us to do the following:

"Wait for 500ms, if the transition hasn't happened yet, show the spinner, and make it last for at least 200ms"

You can add this by running:

$ npm install spin-delay

If you do not want to do this you can just remove the spin-delay part of the implementation. After that, we are good and don't really need anything else to implement this. Here is the whole implementation:

import { useSpinDelay } from "spin-delay";
// If you're following along for the react-router tutorial just replace the import here
import { useNavigation } from "@remix-run/react";

interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
  children?: React.ReactNode;
  skeleton?: React.ReactNode;
  loading?: boolean; 
  minDuration?: number;
  delay?: number;
}

const Progress = ({ children, loading, className, minDuration = 200, delay = 500, skeleton, ...props }: ProgressProps) => {
  const navigation = useNavigation(); 
  const isLoading = loading ?? navigation.state !== "idle";
  const isLoadingAfterDelay  = useSpinDelay(isLoading, { minDuration, delay });
  return isLoadingAfterDelay ? (
    <div {...props} className="flex items-center justify-center">
      {skeleton ? (
        skeleton
      ) : (
        <svg
          className="animate-spin fill-blue-600 text-gray-200"
          role="status"
          viewBox="0 0 100 101"
          xmlns="http://www.w3.org/2000/svg"
        >
          <path
            d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
            fill="currentColor"
          />
          <path
            d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
            fill="currentFill"
          />
        </svg>
      )}
    </div>
  ) : (
    <>{children}</>
  );
};

export { Progress };

Alright, so how does this work?

Let's go piece by piece and explain what we are doing.

First, we use the useNavigation hook that Remix provides to us to listen to any changes in navigation.

There are three states that navigation provides, namely:

  • idle - nothing is going on on the page

  • submitting - something is being submitted to an action and the action is processing that data

  • loading - the loaders are being run and we are waiting for them to finish

So by default, this component will show the loader on any change on the page whether it's a navigation to another page or a submission to an action. This is the most sensible default as even if you submit to an action and that action returns a redirect it will go from a "submitting" state to a "loading" state after it begins the redirect transition.

But this is where our loading prop comes in!

We allow the component to override when the spinner needs to be shown by allowing us to pass in a prop that tells it when it should load. This allows us to be very granular, for example, if you have a fetcher that is executing something and you want to only show a loader while it's running, you can pass in the following:

const Component = () => {
  const fetcher = useFetcher();
  return (
    <Progress loading={fetcher.state !== "idle"}> your content here </Progress>
  );
}

And the spinner will only show when the fetcher is doing something. This is all accomplished by simply doing this:


const navigation = useNavigation(); 
// If the prop is passed in we use the prop, otherwise we use the default
const isLoading = loading ?? navigation.state !== "idle";

Because the ?? checks if the loading prop is a value different than null or undefined and returns the right-hand side if it is, otherwise returns true/false depending on what we passed in, we use that to override the default and have very granular loading in our Remix app.

The second thing we do with this loading state is define a delay and minDuration by default to 500ms and 200ms respectively. You can change these to suit your needs. This allows us to add a delay and a minimum duration for better UX to the loading process and we simply consume the utility hook like so:

const isLoadingAfterDelay  = useSpinDelay(isLoading, { minDuration, delay });

And then finally we have the following part:

return isLoadingAfterDelay ? (
    <div {...props} className="flex items-center justify-center">
      {skeleton ? (
        skeleton
      ) : (
        <svg
          className="animate-spin fill-blue-600 text-gray-200"
          role="status"
          viewBox="0 0 100 101"
          xmlns="http://www.w3.org/2000/svg"
        >
          <path
            d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
            fill="currentColor"
          />
          <path
            d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
            fill="currentFill"
          />
        </svg>
      )}
    </div>
  ) : (
    children
  );

So the SVG element is just a spinner icon, I've added some predefined colors but you can change this for whatever you want and whatever colors your project uses. The interesting part is the one where we check if the loading is going on.

If the delay has gone through (500ms) we decide to show a spinner to the user, the wrapper <div> just makes sure that the spinner is in the center but you can change it to suit your needs or remove it completely, up to you!

The skeleton part is the interesting part. If you want to make a more detailed UI with some custom skeletons with a library like: https://www.npmjs.com/package/react-loading-skeleton or you want to use a completely different UI while the load is happening you can use this prop to pass in a custom component to be shown instead of the spinner, eg:

const Component = () => { 
  return (
    <Progress skeleton={<Skeleton />}> your content here </Progress>
  );
}

And finally, when the page isn't loading, we just return the children passed to the component, and that is it!

Uses

So let's see how we can use this with some examples:

Only show when submitting to an action and not on loading states:

// Only show when submitting to an action and not on loading states
const Component = () => { 
  const navigation = useNavigation();
  return (
    <Progress loading={navigation.state === "submission"}> 
      I am only shown when there is no submission going on
    </Progress>
  );
}

Only show when submitting with a fetcher:

// Only show when submitting with a fetcher
const Component = () => { 
  const fetcher = useFetcher();
  return (
    <Progress loading={fetcher.state !== "idle"}> 
      I am only shown when the fetcher is idle
    </Progress>
  );
}

Show a custom skeleton when not idle:

// Show a custom skeleton when not idle
const Component = () => {  
  return (
    <Progress skeleton={<div> I am shown when the page isn't idle with a delay</div>}> 
      I am only shown when the page is idle
    </Progress>
  );
}

Show a custom skeleton when not idle with no delay:

// Show a custom skeleton when not idle with no delay
const Component = () => {  
  return (
    <Progress delay={0} skeleton={<div> I am shown when the page isn't idle right away!</div>}> 
      I am only shown when the page is idle
    </Progress>
  );
}

Progress within a button:

// Show either the button text or a spinner depending on the state
const Button = ({ children }) => {  
  return (
    <button>
      <Progress delay={0}> 
        {children}
      </Progress>
    </button>
  );
}

Thank you

If you've reached the end you're a champ! I hope you liked my article.

If you wish to support me follow me on Twitter here:

twitter.com/AlemTuzlak59192

or if you want to follow my work you can do so on GitHub:

github.com/AlemTuzlak

And you can also sign up for the newsletter to get notified whenever I publish something new!