Vladimir Vovk
Posted on May 30, 2021
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 Layout
import { 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 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>
)
}
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: () => {}
})
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)
}
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>
)
}
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>
)
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! βπ»
Credits
Photo by Ermelinda MartΓn on Unsplash
Posted on May 30, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.