cft

Page Loading Progress with Next.js and Chakra UI

We will build the progress bar which shows the page loading progress when we move from one page to another.


user

Vladimir Vovk

2 years ago | 10 min read

Motivation

We all love NProgress library, but since we are already using Chakra UI which has build-in Progress and CircularProgress components let's try to build a page loading progress bar our self.

We will build the progress bar which shows the page loading progress when we move from one page to another. It will look like this.

Set up

Create a new Next.js project. You could use Start New Next.js Project Notes or clone this repo's empty-project branch.

Chakra UI

Now we are ready to start. Let's add Chakra UI to our project first.

Install Chakra UI by running this command:

yarn add @chakra-ui/react '@emotion/react@^11' '@emotion/styled@^11' 'framer-motion@^4'

For Chakra UI to work correctly, we need to set up the ChakraProvider at the root of our application src/pages/_app.tsx.

import { AppProps } from 'next/app'import { ReactElement } from 'react'

import { ChakraProvider } from '@chakra-ui/react'

function MyApp({ Component, pageProps }: AppProps): ReactElement {

return (

<ChakraProvider>

<Component {...pageProps} />

</ChakraProvider>

)}

export default MyApp

Save changes and reload the page. If you see that styles have changed then it's a good sign.

Layout

Usually, we have some common structure for all our pages. So let's add a Layout component for that. Create a new src/ui/Layout.tsx file.

import { ReactElement } from 'react'import { Flex } from '@chakra-ui/react'

type Props = {

children: ReactElement | ReactElement[]}

const Layout = ({ children, ...props }: Props) => {

return (

<Flex direction="column" maxW={{ xl: '1200px' }} m="0 auto" p={6} {...props}>

{children}

</Flex>

)}

export default Layout

Nothing fancy here. Just a div (Flex Chakra component) with display: flex, max-width: 1200px for wide screens, margin: 0 auto, and padding: 24px (6 * 4px).

Also it's convenient to import our UI components from the src/ui. Let's add src/ui/index.ts export file for that.

export { default as Layout } from './Layout'

Now we are ready to add our Layout to src/pages/_app.tsx.

// ... same imports as before, just add import of Layoutimport { Layout } from 'src/ui'

function MyApp({ Component, pageProps }: AppProps): ReactElement {

return (

<ChakraProvider>

<Layout>

<Component {...pageProps} />

</Layout>

</ChakraProvider>

)}

export default MyApp

Reload the page. You should see margins now. Cool! Let's move to the progress bar. 🎊

Progress bar

Ok, let's think for a moment. We need to be able to control our progress bar from any part of our application, right? Imaging pressing a button inside any page to show progress. React has a build-in abstraction for that called Context.

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

If it tells nothing for you don't panic. Bare with me it will make sense soon. 🤓

First, we should create a "context" and "context provider". Wrap our application's component tree with " context provider" which will allow us to use data and methods that shared by our "context". Create a new file src/services/loading-progress.tsx which will contain all the code that we need.

import { Progress, VStack, CircularProgress } from '@chakra-ui/react'import { createContext, ReactElement, useContext, useState, useEffect, useRef } from 'react'

type Props = {

children: ReactElement | ReactElement[]}

type Progress = {

value: number

start: () => void

done: () => void}

// 1. Creating a contextconst LoadingProgressContext = createContext<Progress>({

value: 0,

start: () => {},

done: () => {}})

// 2. useLoadingProgress hookexport const useLoadingProgress = (): Progress => {

return useContext<Progress>(LoadingProgressContext)}

// 3. LoadingProgress componentconst LoadingProgress = () => {

const { value } = useLoadingProgress()

return (

<VStack align="flex-end" position="absolute" top={0} left={0} right={0}>

<Progress value={value} size="xs" width="100%" />

<CircularProgress size="24px" isIndeterminate pr={2} />

</VStack>

)}

// 4. LoadingProgressProviderexport const LoadingProgressProvider = ({ children }: Props): ReactElement => {

// 5. Variables

const step = useRef(5)

const [value, setValue] = useState(0)

const [isOn, setOn] = useState(false)

// 6. useEffect

useEffect(() => {

if (isOn) {

let timeout: number = 0

if (value < 20) {

step.current = 5

} else if (value < 40) {

step.current = 4

} else if (value < 60) {

step.current = 3

} else if (value < 80) {

step.current = 2

} else {

step.current = 1

}

if (value <= 98) {

timeout = setTimeout(() => {

setValue(value + step.current)

}, 500)

}

return () => {

if (timeout) {

clearTimeout(timeout)

}

}

}

}, [value, isOn])

// 7. start

const start = () => {

setValue(0)

setOn(true)

}

// 8. done

const done = () => {

setValue(100)

setTimeout(() => {

setOn(false)

}, 200)

}

return (

<LoadingProgressContext.Provider

value={{

value,

start,

done

}}

>

{isOn ? <LoadingProgress /> : <></>}

{children}

</LoadingProgressContext.Provider>

)}

Wow! Let's break it down.

1. Creating a context

First we need to create a context with some default variables. We will change this values later.

// 1. Creating a contextconst LoadingProgressContext = createContext<Progress>({

value: 0,

start: () => {},

done: () => {}})

We will use value as a progress percentage. It should be a number from 0 to 100. start function will show the progress bar and set timeout to increment value. done function will set the value to 100 and hide the progress bar.

2. useLoadingProgress hook

// 2. useLoadingProgress hookexport const useLoadingProgress = (): Progress => {

return useContext<Progress>(LoadingProgressContext)}

This function will return context values and methods, so we could use them anywhere in our app. We will learn how to use it later.

3. LoadingProgress component

// 3. LoadingProgress componentconst LoadingProgress = () => {

const { value } = useLoadingProgress()

return (

<VStack align="flex-end" position="absolute" top={0} left={0} right={0}>

<Progress value={value} size="xs" width="100%" />

<CircularProgress size="24px" isIndeterminate pr={2} />

</VStack>

)}

This is our progress bar component. It consists of two Chakra UI components Progress and CircularProgress combined inside vertical stack VStack. Vertical stack has an absolute position with top: 0; left: 0; right: 0 which means that it will be on the top of our page.

4. LoadingProgressProvider

The main purpose of this function is to wrap our application's components tree and share values and methods with them.

// ... the most important partreturn (

<LoadingProgressContext.Provider

value={{

value,

start,

done

}}

>

{isOn ? <LoadingProgress /> : <></>}

{children}

</LoadingProgressContext.Provider>

)

Here we see that "provider" will return the component which wraps all children components and renders them. Share value value, start and done methods. Renders LoadingProgress component depends on if it's on or off now.

5. Variables

// 5. Variables

const step = useRef(5)

const [value, setValue] = useState(0)

const [isOn, setOn] = useState(false)

We will use step to change the speed of the progress bar growth. It will grow faster in the beginning and then slow down a little bit.

value will contain the progress bar value. Which is from 0 to 100.

isOn variable will indicate if the progress bar is visible now.

6. useEffect

// 6. useEffect

useEffect(() => {

if (isOn) {

let timeout: number = 0

if (value < 20) {

step.current = 5

} else if (value < 40) {

step.current = 4

} else if (value < 60) {

step.current = 3

} else if (value < 80) {

step.current = 2

} else {

step.current = 1

}

if (value <= 98) {

timeout = setTimeout(() => {

setValue(value + step.current)

}, 500)

}

return () => {

if (timeout) {

clearTimeout(timeout)

}

}

}

}, [value, isOn])

This function will run when the app starts and when the value or isOn variable will change. It will set the step variable depends on the current value value. Remember we want our progress bar to slow down at the end. Then if the value is less than 98 we will set a timeout for 500 milliseconds and increase the value by step. Which will trigger the useEffect function again, because value was changed.

7. start

This function will reset the progress bar to 0 and make it visible.

8. done

This function will set the progress bar value to 100 and hides it after 200 milliseconds.

Huh! This module was tough. But we are almost ready to use our progress bar!

Export

One more thing left here is to export our new code. Create a file src/services/index.ts.

export { LoadingProgressProvider, useLoadingProgress } from './loading-progress'

And add LoadingProgressProvider to our application components tree. To do that, open the src/pages/_app.tsx file and add LoadingProgressProvider there.

function MyApp({ Component, pageProps }: AppProps): ReactElement {

return (

<ChakraProvider>

<LoadingProgressProvider>

<Layout>

<Component {...pageProps} />

</Layout>

</LoadingProgressProvider>

</ChakraProvider>

)}

Add router change events to Layout

Now we need to listen to Router events and when the page changes show the progress bar we just created. To do that open src/ui/Layout.tsx and add these imports.

import { useEffect } from 'react'import Router from 'next/router'import { useLoadingProgress } from 'src/services'

Then we need to add this code at the top of the Layout function.

// ...const Layout = ({ children, ...props }: Props) => {

// 1. useLoadingProgress hook

const { start, done } = useLoadingProgress()

// 2. onRouterChangeStart

const onRouteChangeStart = () => {

start()

}

// 3. onRouterChangeComplete

const onRouteChangeComplete = () => {

setTimeout(() => {

done()

}, 1)

}

// 4. Subscribe to router events

useEffect(() => {

Router.events.on('routeChangeStart', onRouteChangeStart)

Router.events.on('routeChangeComplete', onRouteChangeComplete)

Router.events.on('routeChangeError', onRouteChangeComplete)

return () => {

Router.events.off('routeChangeStart', onRouteChangeStart)

Router.events.off('routeChangeComplete', onRouteChangeComplete)

Router.events.off('routeChangeError', onRouteChangeComplete)

}

}, [])

return (

<Flex direction="column" maxW={{ xl: '1200px' }} m="0 auto" p={6} {...props}>

{children}

</Flex>

)}

1. useLoadingProgress hook

useLoadingProgress hook will return us the start and done methods from our LoadingProgessProvider so that we could start and stop our progress bar.

2. onRouterChangeStart

This function will show and start the progress bar.

3. onRouterChangeComplete

This function will set the progress bar value to 100 and hide it after 200 milliseconds. Noticed that it wrapped with setTimeout. This needed to delay the done function a little bit. Otherwise, we can't see anything if a page will change quickly. Which is the case with Next.js. 😆💪🏻

4. Subscribe to router events

Here we will use useEffect function to subscribe and unsubscribe to routeChangeStart, routeChangeComplete and routeChangeError Next.js Router events.

Test time!

Let's add a second page just for the test case. Create a new src/pages/second-page.tsx file for that.

const SecondPage = () => <h1>Hey! I'm a second page!</h1>

export default SecondPage

Then let's add a link to the second page from our index page. Open src/pages/index.tsx and add a Link import on top.

import Link from 'next/link'

Then add Link to the index page body.

export default function Home() {

return (

<div>

// ...

<main>

// ...

<Link href="/second-page">Second page</Link>

</main>

</div>

)}

Now reload the index page and try to press on the Second page link.

Cool right! 🥳

Want to see how it will look like for a slow Internet connection? Good! Just add these imports to src/pages/index.tsx file.

import { Button, Box } from '@chakra-ui/react'import { useLoadingProgress } from 'src/services'

Add this code on the top of the Home function before return.

const { start } = useLoadingProgress()

And add this code below our link to the second page.

<Box>

<Button mr={4} onClick={() => start()}>

Start

</Button>

</Box>

Reload the index page, press the Start button and enjoy! 🌈

That's all. 😊
Check out the 
repo, subscribe and drop your comments below!

Upvote


user
Created by

Vladimir Vovk

I am passionate about the web and mobile technologies, React Native, React, GraphQL, building beautiful user experiences, and making the world a better place. 🤓


people
Post

Upvote

Downvote

Comment

Bookmark

Share


Related Articles