DEV Community

Cover image for Your own loader without any dependencies with React
Amin
Amin

Posted on

Your own loader without any dependencies with React

There are several libraries that can help us create loaders in React. The goal of today is to show you that you can create your own loaders from the simplest ones to the most advanced.

This article will focus on React and JavaScript and the design of the CSS will be left as an exercise for the reader to improve the following code.

We will also create a brand new project from scratch using Vite. If you are more comfortable with boilerplates or anything else, feel free to adapt this tutorial using these tools. Vite will help us scaffold a React project without the hassle of having too much to install.

This article assumes that you have created a folder for all the code that will follow. And that you are comfortable with HTML, JavaScript and React.

Install the necessary libraries

First, we need to install the tools that we will need for developing our loader in React. We only need Vite and React with the DOM bindings.

npm install --save-dev --save-exact vite
npm install --save --save-exact react react-dom
Enter fullscreen mode Exit fullscreen mode

Create the entrypoint

We will need to create our HTML file to start with our React application.

touch index.html
Enter fullscreen mode Exit fullscreen mode
<!DOCTYPE html>
<div id="root"></div>
<script src="./index.jsx" type="module"></script>
Enter fullscreen mode Exit fullscreen mode

Note that the type="module" here is important! This is how Vite will be able to display your application by using ECMAScript module directly in your browser.

The HTML has been stripped to avoid having too much boilerplate code to copy-paste. The browser is able to interpret this code, but do not use such code in production!

Create the JavaScript entrypoint

Next, we will have to create our JavaScript entry point file.

touch index.jsx
Enter fullscreen mode Exit fullscreen mode
import React from "react";
import {createRoot} from "react-dom/client";
import App from "./components/app";

createRoot(document.getElementById("root")).render(
  <App />
);
Enter fullscreen mode Exit fullscreen mode

Again, there are some things that have not been done here like checking that the root identifier is pointing to an existing DOM element (you should account for this case in production).

App

Our application, which all the interesting code will be, will be very simple. This is just for setting things up and make sure everything works. We will add some more things to it later.

mkdir components
touch components/app.jsx
Enter fullscreen mode Exit fullscreen mode
import React from "react";

const App = () => (
  <h1>Hello, world!</h1>
);

export default App;
Enter fullscreen mode Exit fullscreen mode

Test drive

In order to test our setup, we will need to start the Vite development server.

npx vite
Enter fullscreen mode Exit fullscreen mode

Next, open the URL that is written in your terminal. If you see the Hello, world! string displayed on your browser's page, this is a go! You can continue with this article.

Simple loader

A simple loader would be two things: a state and some JSX. Let's try to simulate a slow network bandwidth by displaying a loader.

import React, {useState, useEffect} from "react";

const App = () => {
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);

    const timeout = setTimeout(() => {
      setLoading(false);
    }, 2000);

    return () => {
      clearTimeout(timeout);
      setLoading(false);
    };
  }, []);

  if (loading) {
    return (
      <div>
        Loading...
      </div>
    );
  }

  return (
    <h1>Hello, world!</h1>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

First, we create a state for storing the state of our loader.

Then, we use an effect to start our loader when our component is rendered. We don't need to run this effect more than that since it is simply used to initialize our loader.

We wait for two seconds before stopping our loader and we also make sure when our component is removed from the DOM to stop the timeout and the loader. Stopping the loader is maybe too much, but this will be interesting when our loader gets its state from a higher order component like a context provider.

Next, we make sure to display a simple text indicating that there is something to load when the state is true.

And if the state is false, we simply display the content of our app.

Better loader

Actually, there is too much JSX in this app component. The JSX for the loader and for the app itself. It would be great to have the loader's JSX in its own component. That way, we can focus on rendering our app and someone else can focus on making our loader sexier.

touch components/loader.jsx
Enter fullscreen mode Exit fullscreen mode
import React from "react";

const Loader = ({when, children}) => {
  if (when) {
    return (
      <div>
        Loading...
      </div>
    );
  }

  return children;
};

export default Loader;
Enter fullscreen mode Exit fullscreen mode
import React, {useState, useEffect} from "react";
import Loader from "./loader";

const App = () => {
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);

    const timeout = setTimeout(() => {
      setLoading(false);
    }, 2000);

    return () => {
      clearTimeout(timeout);
      setLoading(false);
    };
  }, []);

  return (
    <Loader when={loading}>
      <h1>Hello, world!</h1>
    </Loader>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

As you can see, we did more than just extracting the JSX for our loader. We added a prop for checking when to render our loader and when to render the children. Having children is useful because it helps removing the if statement and having a clean JSX code in our app.

In our app component, we simply call our loader that will have children. The children will simply be our app JSX, meaning the view of our app. And as promises, no more if statement, we simply provide a when prop that will render the loading if the loading state is true, and the children otherwise.

This way of seeing the loader is slightly better because it helps reducing the friction between the logic of displaying the loader and its rendering view and the app itself.

Some questions remain with this code.

What happens if we get several routes? Maybe one route will want to trigger the loader from somewhere else? Is it possible to trigger the loader manually?

Hello, router!

We will now introduce the concepts of routes. This will be an excuse to have a slightly more advanced example of where we can start tinkering and enhancing our loader.

First, we need to install the necessary library for using the History API.

npm install --save --save-exact react-router-dom
Enter fullscreen mode Exit fullscreen mode

Now, we can add the necessary to our entry point file.

import React from "react";
import {createRoot} from "react-dom/client";
import {BrowserRouter} from "react-router-dom";
import App from "./components/app";

createRoot(document.getElementById("root")).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);
Enter fullscreen mode Exit fullscreen mode

And now we can create two simple pages. The first one being the home page and the second being the users page.

mkdir pages
touch pages/home.jsx
touch pages/users.jsx
Enter fullscreen mode Exit fullscreen mode
import React from "react";

const HomePage = () => (
  <h1>
    Home
  </h1>
);

export default HomePage;
Enter fullscreen mode Exit fullscreen mode
import React from "react";

const UsersPage = () => (
  <h1>
    Users
  </h1>
);

export default UsersPage;
Enter fullscreen mode Exit fullscreen mode

And now we can import it all in our app.

import React, {useState, useEffect} from "react";
import {Routes, Route} from "react-router-dom";
import Loader from "./loader";
import HomePage from "../pages/home";
import UsersPage from "../pages/users";

const App = () => {
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);

    const timeout = setTimeout(() => {
      setLoading(false);
    }, 2000);

    return () => {
      clearTimeout(timeout);
      setLoading(false);
    };
  }, []);

  return (
    <Loader when={loading}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/users" element={<UsersPage />} />
      </Routes>
    </Loader>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

You should now see a loader, and after two seconds your pages. If this is the case: success!

But as stated earlier, it would be great if we could have a loader on demand. Maybe the home page does not need a loader? Maybe the users page will need to fetch data from a remote, far, far away server that has a modest configuration?

Props?

Let's try to add to our pages a way to trigger the loader by passing two new props.

import React, {useState, useCallback} from "react";
import {Routes, Route} from "react-router-dom";
import Loader from "./loader";
import HomePage from "../pages/home";
import UsersPage from "../pages/users";

const App = () => {
  const [loading, setLoading] = useState(false);

  const startLoading = useCallback(() => {
    setLoading(true);
  }, []);

  const stopLoading = useCallback(() => {
    setLoading(false);
  }, []);

  return (
    <Loader when={loading}>
      <Routes>
        <Route
          path="/"
          element={(
            <HomePage
              startLoading={startLoading}
              stopLoading={stopLoading} />
          )} />
        <Route
          path="/users"
          element={(
            <UsersPage
              startLoading={startLoading}
              stopLoading={stopLoading} />
          )} />
      </Routes>
    </Loader>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

We created two new functions that we just passed as arguments to our components that gets rendered when a route matches. We also took the opportunity to remove the effect, since this will be triggered by our pages now.

And here are our new pages.

import React, {useEffect} from "react";

const HomePage = ({startLoading, stopLoading}) => {
  useEffect(() => {
    startLoading();

    const timeout = setTimeout(() => {
      stopLoading();
    }, 1000);

    return () => {
      clearTimeout(timeout);
      stopLoading();
    };
  }, []);

  return (
    <h1>
      Home
    </h1>
  );
};

export default HomePage;
Enter fullscreen mode Exit fullscreen mode
import React, {useEffect} from "react";

const UsersPage = ({startLoading, stopLoading}) => {
  useEffect(() => {
    startLoading();

    const timeout = setTimeout(() => {
      stopLoading();
    }, 2000);

    return () => {
      clearTimeout(timeout);
      stopLoading();
    };
  }, []);

  return (
    <h1>
      Users
    </h1>
  );
};

export default UsersPage;
Enter fullscreen mode Exit fullscreen mode

But wait!!! Do not try this out yet. Well, in fact, do try it out and open the console if you are brave enough.

What will happen is that the home page (for instance) will go and trigger a new state change with the startLoading function.

This function is tied to the App component that will naturally re-render its children. This is where it gets tough because our loader will also change its children to render the loader that will in turn render its own children (the home page).

And since the home page also gets re-rendered, its behavior is to call an effect that will start triggering the loader, and the cycle continues and will go on forever, heating up your CPU, triggering your fan and consuming too much power for only displaying and hiding the loader in an infinite loop.

This is bad! We need to find a way to untie the rendering of the loader from the rendering of our children. The App component will need an emergency surgery to fix this issue.

A better loader?

Here is our new App component if we want to fix our issue.

import React, {useState, useCallback} from "react";
import {Routes, Route} from "react-router-dom";
import Loader from "./loader";
import HomePage from "../pages/home";
import UsersPage from "../pages/users";

const App = () => {
  const [loading, setLoading] = useState(false);

  const startLoading = useCallback(() => {
    setLoading(true);
  }, []);

  const stopLoading = useCallback(() => {
    setLoading(false);
  }, []);

  return (
    <>
      <Loader when={loading} />
      <Routes>
        <Route
          path="/"
          element={(
            <HomePage
              startLoading={startLoading}
              stopLoading={stopLoading} />
          )} />
        <Route
          path="/users"
          element={(
            <UsersPage
              startLoading={startLoading}
              stopLoading={stopLoading} />
          )} />
      </Routes>
    </>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

And here is our new loader.

import React, {useMemo} from "react";

const Loader = ({when}) => {
  const style = useMemo(() => ({
    position: "absolute",
    top: "0",
    left: "0",
    right: "0",
    bottom: "0",
    backgroundColor: "white",
    display: "flex",
    justifyContent: "center",
    alignItems: "center",
    zIndex: 1000
  }), []);

  if (when) {
    return (
      <div style={style}>
        <h1>
          Please wait... I SAID PLEASE WAIT, COME BACK!!!
        </h1>
      </div>
    );
  }

  return null;
};

export default Loader;
Enter fullscreen mode Exit fullscreen mode

I added a slight addition to the styles of our loader just to make sure our loader is rendered on top of the others. This will simulate a component conditional rendering, except the components do not get rendered infinitely anymore thanks to the rendering of the loader that has been untied from the rendering of our pages.

There is still something that bothers me. It works and all, but do we really need a loader for every page? Wouldn't it be better to have only an opt-in function that I can call whenever I want? Also, when I will have 100 pages I will have to create additional props that will get in my own pages' logic. Some pages may have the startLoading prop, some won't. It is really not aesthetic. Is there a better solution?

A way (complex and) better loader

We can use a shared context to share some functions! Let's first create a new context.

mkdir contexts
touch contexts/loader.js
Enter fullscreen mode Exit fullscreen mode
import {createContext} from "react";

export const LoaderContext = createContext();
Enter fullscreen mode Exit fullscreen mode

Really simple: we created a context and we exported it. Now let's create a custom hook to use this context.

mkdir hooks
touch hooks/loader.js
Enter fullscreen mode Exit fullscreen mode
import {useContext} from "react";
import {LoaderContext} from "../contexts/loader";

export const useLoader = () => useContext(LoaderContext);
Enter fullscreen mode Exit fullscreen mode

Again, very simple stuff. We created a function that will simply use the imported context for our loader.

And now, let's create our provider for our loader.

mkdir providers
touch providers/loader.jsx
Enter fullscreen mode Exit fullscreen mode
import React, {useMemo, useState, useCallback} from "react";

import {LoaderContext} from "../contexts/loader";

export const LoaderProvider = ({children}) => {
  const [loading, setLoading] = useState(false);
  const startLoading = useCallback(() => setLoading(true), [setLoading]);
  const stopLoading = useCallback(() => setLoading(false), [setLoading]);
  const value = useMemo(() => ({loading, startLoading, stopLoading}), [loading, startLoading, stopLoading]);

  return (
    <LoaderContext.Provider value={value}>
      {children}
    </LoaderContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

This component will help us add a loader without having to handle the value, the states, the callbacks etc... Everything tied to the logic of our loader stays in this file to not pollute the rest of our code.

Now, we need to provide every component with the exposed values of our provider. Let's go back to our main entry point.

import React from "react";
import {createRoot} from "react-dom/client";
import {BrowserRouter} from "react-router-dom";
import {LoaderProvider} from "./providers/loader";
import App from "./components/app";

createRoot(document.getElementById("root")).render(
  <BrowserRouter>
    <LoaderProvider>
      <App />
    </LoaderProvider>
  </BrowserRouter>
);
Enter fullscreen mode Exit fullscreen mode

And now some cleaning on our app.

import React from "react";
import {Routes, Route} from "react-router-dom";
import Loader from "./loader";
import HomePage from "../pages/home";
import UsersPage from "../pages/users";
import {useLoader} from "../hooks/loader";

const App = () => {
  const {loading} = useLoader();

  return (
    <>
      <Loader when={loading} />
      <Routes>
        <Route path="/" element={(<HomePage />)} />
        <Route path="/users" element={(<UsersPage />)} />
      </Routes>
    </>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Looking great! We removed every state logic and the props so that our app gets leaned and cleaned up. Now let's get into the pages.

import React, {useEffect} from "react";
import {useLoader} from "../hooks/loader";

const HomePage = () => {
  const {startLoading, stopLoading} = useLoader();

  useEffect(() => {
    startLoading();

    const timeout = setTimeout(() => {
      stopLoading();
    }, 1000);

    return () => {
      clearTimeout(timeout);
      stopLoading();
    };
  }, []);

  return (
    <h1>
      Home
    </h1>
  );
};

export default HomePage;
Enter fullscreen mode Exit fullscreen mode
import React, {useEffect} from "react";
import {useLoader} from "../hooks/loader";

const UsersPage = () => {
  const {startLoading, stopLoading} = useLoader();

  useEffect(() => {
    startLoading();

    const timeout = setTimeout(() => {
      stopLoading();
    }, 2000);

    return () => {
      clearTimeout(timeout);
      stopLoading();
    };
  }, []);

  return (
    <h1>
      Users
    </h1>
  );
};

export default UsersPage;
Enter fullscreen mode Exit fullscreen mode

If you followed up to this point, you should see that our application works as expected. This is good news! This means that we can now call our loader from wherever we want, pages, components, other providers, we are free to do so!

Summary

In conclusion, we started from a simple need that was to display a loader, we slightly increased the difficulty and the needs and encountered some issues that we fixed with constructs from the React library.

We also took the opportunity to see how concepts like effects, contexts, custom hooks and providers can be used altogether to add a new feature to our React app.

Whether you are using a simple state with props drilling (not always bad when there is only a few steps) or a complex solution with contexts, hooks and providers, always remember that React is a rather simple library and that you have to manage the rendering of your component, here this means not having the state of our loader being tied up to the rendering of our pages for instance (but this applies to a lot of concepts).

This may look like a simple task, but the design thinking behind a loader is critical to ensure that the development of your application gets as smooth as possible and that the client is rendered with the maximum performance possible.

A better solution would have been to use a portal since our loader is really something that gets on top of our application, and not part of it. This is left as an exercise for the reader to implement a better version of this loader using a React Portal. Also try to make a better design for the loader, I'm just terrible with CSS!

Discussion (0)