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
Create the entrypoint
We will need to create our HTML file to start with our React application.
touch index.html
<!DOCTYPE html>
<div id="root"></div>
<script src="./index.jsx" type="module"></script>
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
import React from "react";
import {createRoot} from "react-dom/client";
import App from "./components/app";
createRoot(document.getElementById("root")).render(
<App />
);
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
import React from "react";
const App = () => (
<h1>Hello, world!</h1>
);
export default App;
Test drive
In order to test our setup, we will need to start the Vite development server.
npx vite
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;
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
import React from "react";
const Loader = ({when, children}) => {
if (when) {
return (
<div>
Loading...
</div>
);
}
return children;
};
export default Loader;
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;
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
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>
);
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
import React from "react";
const HomePage = () => (
<h1>
Home
</h1>
);
export default HomePage;
import React from "react";
const UsersPage = () => (
<h1>
Users
</h1>
);
export default UsersPage;
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;
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;
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;
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;
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;
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;
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
import {createContext} from "react";
export const LoaderContext = createContext();
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
import {useContext} from "react";
import {LoaderContext} from "../contexts/loader";
export const useLoader = () => useContext(LoaderContext);
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
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>
);
};
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>
);
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;
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;
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;
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!
Top comments (0)