DEV Community

Cover image for Page Loading Progress with Next.js and Chakra UI
Vladimir Vovk
Vladimir Vovk

Posted on

Page Loading Progress with Next.js and Chakra UI

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.

NProgress Library

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.

Loading progress result

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'
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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

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

function MyApp({ Component, pageProps }: AppProps): ReactElement {
  return (
    <ChakraProvider>
      <Layout>
        <Component {...pageProps} />
      </Layout>
    </ChakraProvider>
  )
}

export default MyApp
Enter fullscreen mode Exit fullscreen mode

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 context
const LoadingProgressContext = createContext<Progress>({
  value: 0,
  start: () => {},
  done: () => {}
})

// 2. useLoadingProgress hook
export const useLoadingProgress = (): Progress => {
  return useContext<Progress>(LoadingProgressContext)
}

// 3. LoadingProgress component
const 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. LoadingProgressProvider
export 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 context
const LoadingProgressContext = createContext<Progress>({
  value: 0,
  start: () => {},
  done: () => {}
})
Enter fullscreen mode Exit fullscreen mode

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 hook
export const useLoadingProgress = (): Progress => {
  return useContext<Progress>(LoadingProgressContext)
}
Enter fullscreen mode Exit fullscreen mode

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 component
const 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 part
return (
    <LoadingProgressContext.Provider
      value={{
        value,
        start,
        done
      }}
    >
      {isOn ? <LoadingProgress /> : <></>}
      {children}
    </LoadingProgressContext.Provider>
  )
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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])

Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>
  )
}

Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

Then add Link to the index page body.

export default function Home() {
  return (
    <div>
      // ...

      <main>
        // ...

        <Link href="/second-page">Second page</Link>
      </main>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

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

Loading progress result

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'
Enter fullscreen mode Exit fullscreen mode

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

const { start } = useLoadingProgress()
Enter fullscreen mode Exit fullscreen mode

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

<Box>
  <Button mr={4} onClick={() => start()}>
    Start
  </Button>
</Box>
Enter fullscreen mode Exit fullscreen mode

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

Loading progress on slow Internet connection

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

Credits

Photo by Ermelinda MartΓ­n on Unsplash

Discussion (0)