In my NextJS app, I have a PostList.jsx component that looks like this:
import { useQuery } from '#apollo/react-hooks';
import Typography from '#material-ui/core/Typography';
import { NetworkStatus } from 'apollo-client';
import gql from 'graphql-tag';
import getPostsQuery from '../../apollo/schemas/getPostsQuery.graphql';
import Loading from './Loading';
import Grid from '#material-ui/core/Grid';
import PostPreview from './PostPreview';
import withStyles from '#material-ui/core/styles/withStyles';
import React, { useLayoutEffect } from 'react';
const styles = (theme) => ({
root: {
padding: theme.spacing(6, 2),
width: '100%',
},
});
export const GET_POSTS = gql`${getPostsQuery}`;
export const getPostsQueryVars = {
start: 0,
limit: 7,
};
const PostsList = (props) => {
const { classes } = props;
const {
loading,
error,
data,
fetchMore,
networkStatus,
} = useQuery(
GET_POSTS,
{
variables: getPostsQueryVars,
// Setting this value to true will make the component rerender when
// the "networkStatus" changes, so we'd know if it is fetching
// more data
notifyOnNetworkStatusChange: true,
},
);
const loadingMorePosts = networkStatus === NetworkStatus.fetchMore;
const loadMorePosts = () => {
fetchMore({
variables: {
skip: posts.length
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) {
return previousResult
}
return Object.assign({}, previousResult, {
// Append the new posts results to the old one
posts: [...previousResult.posts, ...fetchMoreResult.posts]
})
}
})
};
const scrollFunction = () => {
const postsContainer = document.getElementById('posts-container');
if (postsContainer.getBoundingClientRect().bottom <= window.innerHeight) {
console.log('container bottom reached');
}
};
useLayoutEffect(() => {
document.addEventListener('scroll', scrollFunction);
scrollFunction();
// returned function will be called on component unmount
return () => {
document.removeEventListener('scroll', scrollFunction);
};
}, []);
if (error) return <div>There was an error!</div>;
if (loading) return <Loading />;
const { posts, postsConnection } = data;
const areMorePosts = posts.length < postsConnection.aggregate.count;
return (
<Grid item className={classes.root}>
<Grid container spacing={2} direction="row" id="posts-container">
{posts.map((post) => {
return (
<Grid item xs={12} sm={6} md={4} lg={3} xl={2} className={`post-preview-container`}>
<PostPreview
title={post.title}
excerpt={post.excerpt}
thumbnail={`https://i.schandillia.com/d/${post.thumbnail.hash}${post.thumbnail.ext}`}
/>
</Grid>
);
})}
</Grid>
{areMorePosts && (
<button onClick={() => loadMorePosts()} disabled={loadingMorePosts}>
{loadingMorePosts ? 'Loading...' : 'Show More'}
</button>
)}
</Grid>
);
};
export default withStyles(styles)(PostsList);
As you can see, this component fetches documents from a database via a GraphQL query using Apollo Client and displays them paginated. The pagination is defined by the getPostsQueryVars object. Here, if you scroll down to the bottom and there still are posts available, you'll get a button clicking which the next set of posts will be loaded.
What I'm keen on doing here is implement some kind of an infinite scroll and do away with the button altogether. So far, I've added a scroll event function to the component using React hooks and can confirm it's triggering as expected:
const scrollFunction = () => {
const postsContainer = document.getElementById('posts-container');
if (postsContainer.getBoundingClientRect().bottom <= window.innerHeight) {
console.log('container bottom reached');
}
};
useLayoutEffect(() => {
document.addEventListener('scroll', scrollFunction);
scrollFunction();
return () => {
document.removeEventListener('scroll', scrollFunction);
};
}, []);
But how do I proceed from here? How do achieve the following once the container bottom is reached AND areMorePosts is true:
Display a <h4>Loading...</h4> right before the last </Grid>?
Trigger the loadMorePosts() function?
remove <h4>Loading...</h4> once loadMorePosts() has finished executing?
Related
I have the main page 'feed' where i used to have three functions, but I moved it into custom context. The console logs in context file output all the objects correctly, but nothing is visible in feed when i concole.log them.
context file:
import React, { useEffect, createContext, useContext, useState } from 'react'
import { useMemo } from 'react';
import getPosts from '../api/getPosts';
import filterImportedPosts from '../utils/filterImportedPosts';
export const ItemContext = createContext({
postData: {}, setPostData: () => { }
});
export const FilteredItemsContext = createContext({ filteredItems: [], setFilteredItems: () => { } })
export const FilterContext = createContext({ filter: '', setFilter: () => { } })
export function useItemContext() {
return useContext(ItemContext)
}
export function useFilteredItemsContext() {
return useContext(FilteredItemsContext)
}
export function useFilterContext() {
return useContext(FilterContext)
}
export default function PostProvider({ children }) {
const [postData, setPostData] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
const [filter, setFilter] = useState('');
useEffect(() => {
getPosts(setPostData)
console.log('postData: ', postData)
}, []);
useEffect(() => {
// console.log(filter);
const tempFiltItems = filterImportedPosts(postData, filter);
setFilteredItems(tempFiltItems);
console.log('tempFiltItems: ', filteredItems)
}, [filter, postData]);
const filteredItemsState = useMemo(() => {
return { filteredItems, setFilteredItems }
}, [filteredItems, setFilteredItems])
return (
<FilterContext.Provider value={{ filter, setFilter }}>
<FilteredItemsContext.Provider value={filteredItemsState}>
<ItemContext.Provider value={{ postData, setPostData }}>
{children}
</ItemContext.Provider>
</FilteredItemsContext.Provider >
</FilterContext.Provider>
)
}
and here the feed file:
import React, { useState, useEffect } from 'react';
import SinglePost from '../components/singlePost/singlePost';
import FilterPane from '../components/filterPane/filterPane.feedPost';
import { Box, Spinner, Text } from '#chakra-ui/react';
import getPosts from '../api/getPosts';
import Loader from '../../common/Loader';
import filterImportedPosts from '../utils/filterImportedPosts';
import PostProvider, { useFilterContext, useFilteredItemsContext, useItemContext } from './../context/PostDataContext';
export default function Feed() {
//-----------------IMPORT DATA FROM SERVER----------------------
const [error, setError] = useState(null);
const { postData, setPostData } = useItemContext();
const { filter, setFilter } = useFilterContext();
const { filteredItems, setFilteredItems } = useFilteredItemsContext();
useEffect(() => {
console.log(filteredItems)
}, [filteredItems, setFilteredItems])
// this helps while the data is loaded
// if (postData.length === 0) {
// return (
// <Box pos='absolute' top='45vh' left='40%'>
// <Loader />
// </Box>
// )
// }
// console.log(filteredItems);
return (
<PostProvider>
<Box mt={'7vh'} mb={'7vh'} ml={'3vw'} mr={'3vw'} zIndex={200}>
<FilterPane
setFilter={setFilter}
filter={filter}
filteredItems={filteredItems}
/>
{error && (
<div>Error occurred while loading profile info. Details: {error}</div>
)}
{!error && (
<>
{filteredItems.map((item, index) => {
return <SinglePost key={index} item={item} />;
})}
</>
)}
</Box>
</PostProvider>
);
}
console window printscreen. As you can see the filteredItems in context exist but nothing gets shown in the actual feed - the objects are empty. Could someone assist please?
I have a custom component where I want to prevent the useEffect to fire every time the component is rendering.
The main idea is to get the font-family name from the API and then pass it to the style value, so I want to get the font family just once - not every time the component renders in other screens.
Here's what I tried, but it doesn't work as expected, it's not updating the state after getting the value from API (getValue() not called).
import React, {useCallback, useEffect, useRef} from 'react';
import {useState} from 'react';
import {Text, StyleSheet, Platform} from 'react-native';
import {COLORS} from '../../common';
const AppText = ({children, style, ...rest}) => {
const isMounted = useRef(false);
const [fontFamily, setFontFamily] = useState('Helvetica-Bold');
const getValue = useCallback(() => {
// mock API
setTimeout(() => {
console.log('AppText: get font family name from API!!');
setFontFamily('HelveticaNeue');
}, 200);
}, []);
useEffect(() => {
if (isMounted.current) {
getValue();
} else {
isMounted.current = true;
return;
}
}, [getValue]);
return (
<Text
style={[
styles.text,
style,
{
fontFamily: fontFamily,
},
]}
{...rest}>
{children}
</Text>
);
};
export {AppText};
using:
Home/About/etc // other screens
const Home = () => {
return(
<View>
<AppText> Hey from home </AppText>
</View>
);
}
You can use a context to propergate the value down into multiple components (or use props) and just fetch it once higher up in the tree.
//App.js
import {useEffect, createContext, useState} from "react";
export const FontFamilyContext = createContext("DefaultFont");
const App = () =>{
const [font,setFont] = useState();
useEffect( () =>{
setFont(loadFont());
},[]);
return (
<FontFamilyContext.Provider value={font} >
<Screen />
<ScreenViaProp fontFamily={font} />
</FontFamilyContext.Provider>
);
}
export default App;
//Screen.jsx
//Advantage The font family can be used in nested components deep down
import { useContext } from "react";
import {FontContext} from "./App";
const Screen = () =>{
const fontFamily = useContext(FontFamilyContext);
return (
<div style={{fontFamily: fontFamily}}>
</div>
)
}
// ScreenViaProp .jsx Easier and no context is required
const ScreenViaProp = ({fontFamily}) =>{
return (
<div style={{fontFamily: fontFamily}}>
</div>
)
}
const DEFAULT_FONT_FAMILY = 'Helvetica-Bold';
const AppText = ({children, style, ...rest}) => {
const [fontFamily, setFontFamily] = useState(DEFAULT_FONT_FAMILY);
useEffect(() => {
// mock API
setTimeout(() => {
console.log('AppText: get font family name from API!!');
const FETCHED_FONT_FAMILY = 'HelveticaNeue';
if (FETCHED_FONT_FAMILY !== fontFamily) setFontFamily(FETCHED_FONT_FAMILY);
}, 200);
}, []);
return (
<Text
style={[
styles.text,
style,
{
fontFamily: fontFamily,
},
]}
{...rest}>
{children}
</Text>
);
};
You can also use a custom hook to load the font value only once in you app :
customHooks.js
let fontFromApi = null;
const fontP = new Promise(resolve => {
setTimeout(() => {
console.log('get font family name from API!!');
fontFromApi = 'HelveticaNeue';
resolve(fontFromApi);
}, 200);
});
export function useFont() {
const [font, setFont] = useState(fontFromApi || "Helvetica-Bold");
if (fontFromApi === null) {
fontP.then(v => setFont(v));
}
return font;
}
I declared a custom hook called useFont returning the font from the API. If the font is not loaded yet, it will return the fallback value Helvetica-Bold.
AppText.jsx
const { useFont } from './customHooks'
const AppText = ({children, style, ...rest}) => {
const fontFamily = useFont();
return (
<Text
style={[
styles.text,
style,
{ fontFamily }
]}
{...rest}>
{children}
</Text>
);
};
I need a performant way to fetch data depending on different dependencies.
So, fetchPosts needs to run:
on load
when currentPage is changed
when currentTimeline is changed
and, when currentTimeline is changed, the currentPage needs to be set to 0 again, to start from scratch.
So what happens now:
The fetchposts gets called multiple times on load, which causes a massive amount of requests on the database.
Question:
how could I solve this, that it only calls the fetchPosts on load and only when currentpage/currentTimeline is changed.
And when currentTimeline is changed, the currentpage gets set to 0.
/* eslint-disable react-hooks/exhaustive-deps */
// #flow
import style from "./style.module.scss";
import React, { useState, useEffect, Suspense } from "react";
import { Grid, NoSsr } from "#material-ui/core";
import { Post as PostComponent } from "#components";
import type { Post } from "#types";
import { getPosts, addPosts } from "../../api/Posts";
import { TimelineFooter, PostCreation } from "#components";
import { Box } from "#material-ui/core";
import InfiniteScroll from "react-infinite-scroll-component";
import Typography from "#material-ui/core/Typography";
import { timelineState } from "#atoms";
import { useRecoilValue } from "recoil";
import { deletePost } from "../../api/Posts";
/**
* Timeline
*/
function Timeline() {
const [open, setOpen] = useState(false);
const [posts, setPosts] = useState([]);
const [hasMore, setHasMore] = useState(true);
const [currentPage, setCurrentPage] = useState(0);
const currentTimeline = useRecoilValue(timelineState);
const fetchPosts = () => {
getPosts(currentTimeline.tab, currentPage).then((result: *) => {
if (result.length > 0) {
setPosts(posts.concat(result));
setHasMore(true);
} else {
setHasMore(false);
}
});
};
useEffect(() => {
console.log("current page");
fetchPosts();
}, [currentPage]);
useEffect(() => {
console.log("current currentTimeline");
setPosts([]);
setCurrentPage(0);
}, [currentTimeline]);
useEffect(() => {
console.log("posts en currentpage");
if (posts.length === 0) {
if (currentPage !== 0) {
setCurrentPage(0);
} else {
fetchPosts();
}
}
}, [posts, currentPage]);
const fetchMoreData = () => {
setCurrentPage(currentPage + 1);
};
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
setOpen(false);
};
const handlePostCreation = (message: string, files: Array<*>) => {
let formData = new FormData();
formData.append("content", message);
if (files) {
for (const file of files) {
formData.append("files", file);
}
}
formData.append("timelineCategory", currentTimeline.tab);
formData.append("regionName", "General");
addPosts(formData)
.then(() => {
setPosts([]);
})
.then(handleClose);
};
const onDeletePost = uuid => {
deletePost(uuid).then(() => {
const newposts = posts.filter(item => item.uuid !== uuid);
setPosts(newposts);
});
};
return (
<>
<Box pl={2} pr={2}>
<Grid container justify="center" className={style.fullWidth}>
<NoSsr>
<InfiniteScroll
style={{ width: "100%" }}
dataLength={posts.length}
next={fetchMoreData}
hasMore={hasMore}
loader={
<Box p={2} textAlign="center">
<Typography variant="subtitle2">
Meer laden...
</Typography>
</Box>
}
endMessage={
<Box p={2} textAlign="center">
<Typography variant="subtitle2">
Alle berichten geladen.
</Typography>
</Box>
}
>
<Suspense
fallback={
<span>Gebruikergegevens inladen...</span>
}
>
{posts.map((item: Post) => (
<Grid item xs={12} key={item.uuid}>
<PostComponent
post={item}
onDeletePost={onDeletePost}
/>
</Grid>
))}
</Suspense>
</InfiniteScroll>
</NoSsr>
</Grid>
</Box>
<TimelineFooter handleClickOpen={handleClickOpen} />
<PostCreation
open={open}
handleClose={handleClose}
handlePostCreation={handlePostCreation}
/>
</>
);
}
export default Timeline;
1. Infinite useEffect call.
You are calling fetch repeatedly because of this useEffect call (or, at least, this useEffect call).
You are running a useEffect with posts as dependency, while in your fetchPosts, you are calling setPosts, which triggers this useEffect, causing an infinite loop.
useEffect(() => {
console.log("posts en currentpage");
if (posts.length === 0) {
if (currentPage !== 0) {
setCurrentPage(0);
} else {
fetchPosts(); //<--- this triggers setPosts which triggers this useEffect call.
}
}
}, [posts, currentPage]);
This useEffect also triggers setCurrentPage(0) (which also triggers this useEffect) also triggers fetchPosts which also triggers setPosts which causes this to run indefinitely.
In Summary - is this useEffect necessary?
2. Unnecessary useEffect call.
The following may seem like a very normal use of useEffect in many situations, and looks very convenient, but it gets out of hand/control sometimes - E.g. your current situation, where you are manipulating your currentPage base on different conditions.
useEffect(() => {
console.log("current page");
fetchPosts();
}, [currentPage]);
You could have put your fetchPosts() here.
const fetchMoreData = () => {
setCurrentPage(currentPage + 1);
fetchPosts(currentPage +1);
};
and here
useEffect(() => {
console.log("current currentTimeline");
setPosts([]);
setCurrentPage(0);
fetchPosts(0);
}, [currentTimeline]);
Of course, you will need to update your fetchPost function to conditionally take in a parameter.
You could start by making some minor improvements like using the state update function to update currentPage as its value depends on previous state.
This way is recommended by react.
https://reactjs.org/docs/hooks-reference.html#usestate
const fetchMoreData = () => {
setCurrentPage(prevCurrentPage => prevCurrentPage + 1);
};
Comment inside this hook indicates thats it is for 'posts on current page'.
However 'posts' looks to be an array of total posts rather than posts per page.
useEffect(() => {
console.log("posts en currentpage");
if (posts.length === 0) {
if (currentPage !== 0) {
setCurrentPage(0);
} else {
fetchPosts();
}
}
}, [posts, currentPage]);
The above hook will also execute on mount as initially posts will be empty and currentPage will be 0.
So on load itself 'fetchPosts' will be called twice.
If you intend to keep track of all posts per page then you may want to rethink the logic here.
TL;DR
When a store change triggers a component function, the current component state is ignored/reset, not letting me use its state data to feed the triggered function.
Full Description
This react-native app has a button located in a heading Appbar stack navigator, which must trigger a function that the currently focused Screen has.
The thing is that this screen is very deep within the navigation scheme, thus I decided to use Redux to directly notify the screen that the button has been pressed.
This also means that every time that this button is pressed and a store slice gets dispatched, I can trigger any function only depending on the Screen implementation.
If i use the very same function from a button within the component it works perfectly. However if I call the same function from the redux store change i get this log:
Console Behavior
# component loaded
false
# started writing, this is the component state
h
he
hel
hell
hello
#header button 'create' state change detected
true
#content as viewed by the onPressPublish function
content: ""
Error 400 - Cannot save empty content
#store reset for further use
false
Appbar
export const AppBarStackNavigator = (props) => {
const { toggle } = useSelector(toolbarSelector);
const handleCreatePress = () => {
dispatch(setCreate({ pressed: true }));
}
return (
<Appbar.Header
style={{ backgroundColor: theme.colors.background, elevation: 0 }}
>
<Button
icon="seed"
mode="contained"
// disabled={!contentProps.valid}
onPress={handleCreatePress}
labelStyle={{ color: 'white' }}
style={{
width: 115,
borderRadius: 50,
alignSelf: "flex-end"
}}>
Sembrar
</Button>
</Appbar.Header>
);
}
Store
import { createSlice } from "#reduxjs/toolkit";
export const toolbarSlice = createSlice({
name: 'toolbar',
initialState: {
create: false
},
reducers: {
setCreate(state, action) {
state.create = action.payload.pressed;
}
}
})
export const { setCreate } = toolbarSlice.actions;
export const toolbarSelector = state => state.toolbar
export default toolbarSlice.reducer;
The navigationally-deep component
import { toolbarSelector, setCreate } from '../store/toolbar';
import { useSelector } from 'react-redux';
// import { useFocusEffect, TabActions, useNavigation } from '#react-navigation/native';
export const DeepComponent = (props) => {
const theme = useTheme();
const { create } = useSelector(toolbarSelector);
return (
<ChildComponent {...props} create={create} setCreate={setCreate} style={{ backgroundColor: theme.colors.background }} />
);
};
Its child (where the function is)
import { useDispatch, useSelector } from 'react-redux';
export const ChildComponent = (props) => {
const dispatch = useDispatch();
const [content, setContent] = useState(''));
let payload = {};
const onPressPublish = async () => {
try {
console.log(payload);
payload = {
...payload,
content,
// images <- other component states
}
console.log(payload);
const seed = await api.writeOne(payload);
} catch (error) {
console.log(error);
Alert.alert('Could not publish :(', error.message);
}
navigation.goBack();
}
useEffect(() => {
console.log(props.create)
console.log(content)
if (props.create) {
console.log(content)
onPressPublish();
}
return () => {
dispatch(props.setCreate({ pressed: false }));
};
}, [props.create])
const onTextChange = (value, props) => {
// ...
setContent(value);
// ...
}
return (
<TextInput
mode='flat'
placeholder={inputPlaceholder}
multiline
onChangeText={text => onTextChange(text, props)}
keyboardShouldPersistTaps={true}
autoFocus
clearButtonMode='while-editing'>
<ParsedContent content={content} />
</TextInput>
<Button
disabled={!contentProps.valid}
onPress={onPressPublish}>
{buttonText}
</Button>
)
}
Here are some suggestions to change the code, you still have not provided any code in your question that would make sense (like the payload variable) but this may give you an idea where to go.
When you create an app with create-react-app you should have a linter that tells you when you have missing dependencies in hooks, you should not ignore these warnings:
//create onPressPublish when component mounts
const onPressPublish = useCallback(async (content) => {
try {
//removed payload as it makes no sense to create it
// and never use it anywhere
//removed assigning to seed becasue it is never used anywhere
await api.writeOne({ content });
} catch (error) {
console.log(error);
Alert.alert('Could not publish :(', error.message);
}
navigation.goBack();
}, []);
const { create, setCreate } = props;
useEffect(() => {
if (create) {
//passing content
onPressPublish(content);
}
return () => {
dispatch(setCreate({ pressed: false }));
};
}, [
//correct dependencies without linter warnings
content,
dispatch,
onPressPublish,
create,
setCreate,
]);
I am using getServerSideProps in pages/post/index.js:
import React from "react";
import Layout from "../../components/Layout";
function Post({ post }) {
console.log("in render", post);
return (
<Layout title={post.name}>
<pre>{JSON.stringify(post, undefined, 2)}</pre>
</Layout>
);
}
export async function getServerSideProps({ query }) {
return fetch(
`${process.env.API_URL}/api/post?id=${query.id}`
)
.then(result => result.json())
.then(post => ({ props: { post } }));
}
export default Post;
When I directly load /post/2 it works as expected but when I go from /posts to /post/2 by clicking on a link:
<Link
as={`/post/${post.id}`}
href={`/post?id=${post.id}`}
>
It looks like nothing happens for 2 seconds (the api delay) and then the content shows. I can see in the network tab that _next/data/development/post/9.json is being loaded by fetchNextData.
I would like to show a loading spinner when I move from one route to another using next/Link but I can't find any documentation on getServerSideProps that allows me to do this.
When I directly go to /post/:id I'd like the data to be fetched server side and get a fully rendered page (works) but when I then move to another route the data should be fetched from the client (works). However; I would like to have a loading indicator and not have the UI freeze up for the duration of the data request.
Here is an example using hooks.
pages/_app.js
import Router from "next/router";
export default function App({ Component, pageProps }) {
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
const start = () => {
console.log("start");
setLoading(true);
};
const end = () => {
console.log("finished");
setLoading(false);
};
Router.events.on("routeChangeStart", start);
Router.events.on("routeChangeComplete", end);
Router.events.on("routeChangeError", end);
return () => {
Router.events.off("routeChangeStart", start);
Router.events.off("routeChangeComplete", end);
Router.events.off("routeChangeError", end);
};
}, []);
return (
<>
{loading ? (
<h1>Loading...</h1>
) : (
<Component {...pageProps} />
)}
</>
);
}
You can use nprogress in your _app.js
import NProgress from 'nprogress';
import "nprogress/nprogress.css";
import Router from 'next/router';
NProgress.configure({
minimum: 0.3,
easing: 'ease',
speed: 800,
showSpinner: false,
});
Router.events.on('routeChangeStart', () => NProgress.start());
Router.events.on('routeChangeComplete', () => NProgress.done());
Router.events.on('routeChangeError', () => NProgress.done());
or dynamic import to _app.js to reduce bundle size
ProgessBar.js
import Router from 'next/router';
import NProgress from 'nprogress';
import "nprogress/nprogress.css";
NProgress.configure({
minimum: 0.3,
easing: 'ease',
speed: 500,
showSpinner: false,
});
Router.events.on('routeChangeStart', () => NProgress.start());
Router.events.on('routeChangeComplete', () => NProgress.done());
Router.events.on('routeChangeError', () => NProgress.done());
export default function () {
return null;
}
_app.js
import dynamic from 'next/dynamic';
const ProgressBar = dynamic(() => import('components/atoms/ProgressBar'), { ssr: false });
const App = () => {
...
return <>
...
<ProgressBar />
</>
}
Ps: If you want to change color of progress bar, you can override in global css, something like this
#nprogress .bar {
background: #6170F7 !important;
height: 3px !important;
}
You can create a custom hook:
usePageLoading.ts
import Router from 'next/router';
import { useEffect, useState } from 'react';
export const usePageLoading = () => {
const [isPageLoading, setIsPageLoading] = useState(false);
useEffect(() => {
const routeEventStart = () => {
setIsPageLoading(true);
};
const routeEventEnd = () => {
setIsPageLoading(false);
};
Router.events.on('routeChangeStart', routeEventStart);
Router.events.on('routeChangeComplete', routeEventEnd);
Router.events.on('routeChangeError', routeEventEnd);
return () => {
Router.events.off('routeChangeStart', routeEventStart);
Router.events.off('routeChangeComplete', routeEventEnd);
Router.events.off('routeChangeError', routeEventEnd);
};
}, []);
return { isPageLoading };
};
and then inside your App component use it:
_app.js
import Router from "next/router";
import { usePageLoading } from './usePageLoading';
export default function App({ Component, pageProps }) {
const { isPageLoading } = usePageLoading();
return (
<>
{isPageLoading ? (
<h1>Loading...</h1>
) : (
<Component {...pageProps} />
)}
</>
);
}
How about simply adding a component level loading state to Post (vs. adding a loader on App Level for every route change since some route changes might not require server side rendering).
Setting the isLoading state to true when the relevant query param changes, in this case the post id, and setting the state to false once the props, in this case the post data, updated.
Along these lines:
pages/post/index.js:
import React from "react";
import Layout from "../../components/Layout";
import { useRouter } from 'next/router';
function Post({ post }) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
// loading new post
useEffect(()=> {
setIsLoading(true);
}, [router.query?.id]);
// new post loaded
useEffect(()=> {
setIsLoading(false)
}, [post]);
return (
<>
{isLoading ? (
<h1>Loading...</h1>
) : (
<Layout title={post.name}>
<pre>{JSON.stringify(post, undefined, 2)}</pre>
</Layout>
)}
</>
);
}
export async function getServerSideProps({ query }) {
return fetch(
`${process.env.API_URL}/api/post?id=${query.id}`
)
.then(result => result.json())
.then(post => ({ props: { post } }));
}
export default Post;
Just adding to the previous answers, you can receive a url parameter in the event handlers, and use those to filter out which route you want a loading state and which not. Simple example in _app.js:
function MyApp({ Component, pageProps: { ...pageProps } }: AppProps) {
const router = useRouter();
const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => {
const handleChangeStart = (url: string) => {
if (url === "<root_to_show_loading>") {
setIsLoading(true);
}
};
const handleChangeEnd = (url: string) => {
if (url === "<root_to_show_loading") {
setIsLoading(false);
}
};
router.events.on("routeChangeStart", handleChangeStart);
router.events.on("routeChangeComplete", handleChangeEnd);
router.events.on("routeChangeError", handleChangeEnd);
}, []);
return (
<main>
{isLoading ? <LoadingSpinner /> : <Component {...pageProps} />}
</main>
);
}
export default MyApp;
**Here is how I did it in NextJs with Material UI and nprogress**
import '../styles/globals.css';
import { useEffect, useState } from 'react';
import Router from 'next/router';
import NProgress from 'nprogress';
import { useStyles } from '../src/utils';
import { CircularProgress } from '#material-ui/core';
NProgress.configure({ showSpinner: false });
function MyApp({
Component,
pageProps
}) {
const classes = useStyles();
const [loading, setLoading] = useState(false);
useEffect(() => {
const jssStyles = document.querySelector('#jss-server-side');
if (jssStyles) jssStyles.parentElement.removeChild(jssStyles);
const start = () => {
console.log('start');
NProgress.start();
setLoading(true);
};
const end = () => {
console.log('findished');
NProgress.done();
setLoading(false);
};
Router.events.on('routeChangeStart', start);
Router.events.on('routeChangeComplete', end);
Router.events.on('routeChangeError', end);
return () => {
Router.events.off('routeChangeStart', start);
Router.events.off('routeChangeComplete', end);
Router.events.off('routeChangeError', end);
};
}, []);
return (
<>
{loading ? (
<div className={classes.centered}>
<CircularProgress size={25} color='primary' />
</div>
) : (
<Component {...pageProps} />
)}
</>
);
}
export default MyApp;
Result:
Progress bar like NProgress in 90 lines of code (vs NProgress v0.2.0 is 470 lines .js + 70 lines .css):
import { useEffect, useReducer, useRef } from 'react';
import { assert } from './assert';
import { wait } from './wait';
import { getRandomInt } from './getRandomNumber';
let waitController: AbortController | undefined;
// https://gist.github.com/tkrotoff/db8a8106cc93ae797ea968d78ea28047
export function useProgressBar({
trickleMaxWidth = 94,
trickleIncrementMin = 1,
trickleIncrementMax = 5,
dropMinSpeed = 50,
dropMaxSpeed = 150,
transitionSpeed = 600
} = {}) {
// https://stackoverflow.com/a/66436476
const [, forceUpdate] = useReducer(x => x + 1, 0);
// https://github.com/facebook/react/issues/14010#issuecomment-433788147
const widthRef = useRef(0);
function setWidth(value: number) {
widthRef.current = value;
forceUpdate();
}
async function trickle() {
if (widthRef.current < trickleMaxWidth) {
const inc =
widthRef.current +
getRandomInt(trickleIncrementMin, trickleIncrementMax); // ~3
setWidth(inc);
try {
await wait(getRandomInt(dropMinSpeed, dropMaxSpeed) /* ~100 ms */, {
signal: waitController!.signal
});
await trickle();
} catch {
// Current loop aborted: a new route has been started
}
}
}
async function start() {
// Abort current loops if any: a new route has been started
waitController?.abort();
waitController = new AbortController();
// Force the show the JSX
setWidth(1);
await wait(0);
await trickle();
}
async function complete() {
assert(
waitController !== undefined,
'Make sure start() is called before calling complete()'
);
setWidth(100);
try {
await wait(transitionSpeed, { signal: waitController.signal });
setWidth(0);
} catch {
// Current loop aborted: a new route has been started
}
}
function reset() {
// Abort current loops if any
waitController?.abort();
setWidth(0);
}
useEffect(() => {
return () => {
// Abort current loops if any
waitController?.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
start,
complete,
reset,
width: widthRef.current
};
}
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useProgressBar } from './useProgressBar';
const transitionSpeed = 600;
// https://gist.github.com/tkrotoff/db8a8106cc93ae797ea968d78ea28047
export function RouterProgressBar(
props?: Parameters<typeof useProgressBar>[0]
) {
const { events } = useRouter();
const { width, start, complete, reset } = useProgressBar({
transitionSpeed,
...props
});
useEffect(() => {
events.on('routeChangeStart', start);
events.on('routeChangeComplete', complete);
events.on('routeChangeError', reset); // Typical case: "Route Cancelled"
return () => {
events.off('routeChangeStart', start);
events.off('routeChangeComplete', complete);
events.off('routeChangeError', reset);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return width > 0 ? (
// Use Bootstrap, Material UI, Tailwind CSS... to style the progress bar
<div
className="progress fixed-top bg-transparent rounded-0"
style={{
height: 3, // GitHub turbo-progress-bar height is 3px
zIndex: 1091 // $zindex-toast + 1 => always visible
}}
>
<div
className="progress-bar"
style={{
width: `${width}%`,
//transition: 'none',
transition: `width ${width > 1 ? transitionSpeed : 0}ms ease`
}}
/>
</div>
) : null;
}
How to use:
// pages/_app.tsx
import { AppProps } from 'next/app';
import Head from 'next/head';
import { RouterProgressBar } from './RouterProgressBar';
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>My title</title>
<meta name="description" content="My description" />
</Head>
<RouterProgressBar />
<Component {...pageProps} />
</>
);
}
More here: https://gist.github.com/tkrotoff/db8a8106cc93ae797ea968d78ea28047
To add to the previous answers and show complete code, you can add a delay with setTimeout when setting state in the event hook to avoid a flicker of loading on fast loading routes (either static routes, or server routes ready to go).
import Router from 'next/router';
import { useEffect, useRef, useState } from 'react';
const usePageLoad = (delay = 200) => {
const timeoutRef = useRef();
const [loading, setLoading] = useState(false);
useEffect(() => {
const start = () => {
timeoutRef.current = window.setTimeout(() => {
setLoading(true);
}, delay);
};
const end = () => {
window.clearTimeout(timeoutRef.current);
setLoading(false);
};
Router.events.on('routeChangeStart', start);
Router.events.on('routeChangeComplete', end);
Router.events.on('routeChangeError', end);
return () => {
Router.events.off('routeChangeStart', start);
Router.events.off('routeChangeComplete', end);
Router.events.off('routeChangeError', end);
};
}, [delay]);
return loading;
};
export default usePageLoad;
Then use this hook in _app and adjust the delay as needed for your application.
import PageLoader from '../components/PageLoader';
import usePageLoad from '../components/use-page-load';
const App = ({ Component, pageProps }) => {
const loading = usePageLoad();
return (
{
loading
? <PageLoader />
: <Component {...pageProps} />
}
);
};