How to Implement Infinite Scroll in Next JS SSG? - reactjs

I created a markdown blog by following the Next Js documentation and Using Typescript. To Fetch a list of blog posts I used getStaticProps as the docs suggested. I tried with some npm packages, it's not working, or maybe I didn't know how to set up perfectly.
If there is a good plugin or custom hooks, Please tell me how can I add the infinite scroll feature in Next Js static site generation.
Project source code is available in this GitHub repository: https://github.com/vercel/next-learn-starter/blob/master/typescript-final/pages/index.tsx

I did some research and created my own react hooks to implement infinite scroll in Next JS. So, I am answering my question.
If anyone has a better method, please don't forget to share it here.
I found two working methods. First One done by using innerHeight, scrollTop, offsetHeight values. It worked perfectly, but somehow I felt this is not the correct way to do that. Please correct me if I am wrong.
The second method was to asynchronously observe changes in the intersection of a target element by Intersection Observer API. First, I created a pagination custom react hook to limit my blog posts then I used IntersectionObserver to fetch the blog posts of the next pages.
usePagination.tsx:
import { useState } from "react";
function usePagination(data, itemsPerPage: number) {
const [currentPage, setCurrentPage] = useState(1);
const maxPage = Math.ceil(data.length / itemsPerPage);
function currentData() {
const begin = (currentPage - 1) * itemsPerPage;
const end = begin + itemsPerPage;
return data.slice(null, end);
}
function next() {
setCurrentPage((currentPage) => Math.min(currentPage + 1, maxPage));
}
return { next, currentData, currentPage, maxPage };
}
export default usePagination;
Index.tsx:
import { GetStaticProps } from "next";
import { getSortedPostsData } from "../lib/posts";
import Layout from "../components/layout";
import usePagination from "../lib/Hooks/usePagination";
import { useRef, useState, useEffect } from "react";
export default function Home({
allPostsData,
}: {
allPostsData: {
id: string;
title: string;
date: string;
cover: string;
}[];
}) {
const { next, currentPage, currentData, maxPage } = usePagination(
allPostsData,
8
);
const currentPosts = currentData();
const [element, setElement] = useState(null);
const observer = useRef<IntersectionObserver>();
const prevY = useRef(0);
useEffect(() => {
observer.current = new IntersectionObserver(
(entries) => {
const firstEntry = entries[0];
const y = firstEntry.boundingClientRect.y;
if (prevY.current > y) {
next();
}
prevY.current = y;
},
{ threshold: 0.5 }
);
}, []);
useEffect(() => {
const currentElement = element;
const currentObserver = observer.current;
if (currentElement) {
currentObserver.observe(currentElement);
}
return () => {
if (currentElement) {
currentObserver.unobserve(currentElement);
}
};
}, [element]);
return (
<Layout>
<article className="w-full mt-12 flex flex-wrap items-center justify-center">
{currentPosts &&
currentPosts.map(({ id, title, date, cover }) => (
<div key={id} // ......
</div>
))}
</article>
{currentPage !== maxPage ? (
<h1 ref={setElement}>Loading Posts...</h1>
) : (
<h1>No more posts available...</h1>
)}
</Layout>
);
}
export const getStaticProps: GetStaticProps = async () => {
const allPostsData = getSortedPostsData();
return {
props: {
allPostsData,
},
};
};

Related

Get value from promise using React/Typescript [duplicate]

This question already has answers here:
Using async/await inside a React functional component
(4 answers)
Closed 7 months ago.
I was given a snippet of a class named GithubService. It has a method getProfile, returning a promise result, that apparently contains an object that I need to reach in my page component Github.
GithubService.ts
class GithubService {
getProfile(login: string): Promise<GithubProfile> {
return fetch(`https://api.github.com/users/${login}`)
.then(res => res.json())
.then(({ avatar_url, name, login }) => ({
avatar: avatar_url as string,
name: name as string,
login: login as string,
}));
}
export type GithubProfile = {
avatar: string;
name: string;
login: string;
};
export const githubSerive = new GithubService();
The page component should look something like this:
import { githubSerive } from '~/app/github/github.service';
export const Github = () => {
let name = 'Joshua';
const profile = Promise.resolve(githubSerive.getProfile(name));
return (
<div className={styles.github}>
<p>
{//something like {profile.name}}
</p>
</div>
);
};
I'm pretty sure the Promise.resolve() method is out of place, but I really can't understand how do I put a GithubProfile object from promise into the profile variable.
I've seen in many tutorials they explicitly declare promise methods and set the return for all outcomes of a promise, but I can't change the source code.
as you are using React, consider making use of the useState and useEffect hooks.
Your Code could then look like below, here's a working sandBox as well, I 'mocked' the GitHub service to return a profile after 1s.
export default function Github() {
const [profile, setProfile] = useState();
useEffect(() => {
let name = "Joshua";
const init = async () => {
const _profile = await githubService.getProfile(name);
setProfile(_profile);
};
init();
}, []);
return (
<>
{profile ? (
<div>
<p>{`Avatar: ${profile.avatar}`}</p>
<p>{`name: ${profile.name}`}</p>
<p>{`login: ${profile.login}`}</p>
</div>
) : (
<p>loading...</p>
)}
</>
);
}
You should wait for the promise to be resolved by either using async/await or .then after the Promise.resolve.
const profile = await githubSerive.getProfile(name);
const profile = githubSerive.getProfile(name).then(data => data);
A solution would be:
import { githubSerive } from '~/app/github/github.service';
export async function Github() {
let name = 'Joshua';
const profile = await githubSerive.getProfile(name);
return (
<div className={styles.github}>
<p>
{profile.name}
</p>
</div>
);
}
But if you are using react, things would be a little different (since you have tagged reactjs in the question):
import { githubSerive } from '~/app/github/github.service';
import * as React from "react";
export const Github = () => {
let name = 'Joshua';
const [profile, setProfile] = React.useState();
React.useEffect(() => {
(async () => {
const profileData = await githubSerive.getProfile(name);
setProfile(profileData);
})();
}, [])
return (
<div className={styles.github}>
<p>
{profile?.name}
</p>
</div>
);
}

React Context. how to avoid "Cannot read properties of undefined" error before having a value

I am learning React, and trying to build a photo Album with a a modal slider displaying the image clicked (on a different component) in the first place.
To get that, I set <img src={albums[slideIndex].url} /> dynamically and set slideIndex with the idof the imgclicked , so the first image displayed in the modal slider is the one I clicked.
The problem is that before I click in any image albums[slideIndex].urlis obviously undefined and I get a TypeError :cannot read properties of undefined
How could I solve that?
I tried with data checks with ternary operator, like albums ? albums[slideIndex].url : "no data", but doesn't solve it.
Any Ideas? what i am missing?
this is the component where I have the issue:
import React, { useContext, useEffect, useState } from "react";
import { AlbumContext } from "../../context/AlbumContext";
import AlbumImage from "../albumImage/AlbumImage";
import "./album.css";
import BtnSlider from "../carousel/BtnSlider";
function Album() {
const { albums, getData, modal, setModal, clickedImg } =
useContext(AlbumContext);
console.log("clickedImg id >>", clickedImg.id);
useEffect(() => {
getData(); //-> triggers fetch function on render
}, []);
///////////
//* Slider Controls
///////////
const [slideIndex, setSlideIndex] = useState(clickedImg.id);
console.log("SlideINDEx", slideIndex ? slideIndex : "no hay");
const nextSlide = () => {
if (slideIndex !== albums.length) {
setSlideIndex(slideIndex + 1);
} else if (slideIndex === albums.length) {
setSlideIndex(1);
}
console.log("nextSlide");
};
const prevSlide = () => {
console.log("PrevSlide");
};
const handleOnclick = () => {
setModal(false);
console.log(modal);
};
return (
<div className="Album_Wrapper">
<div className={modal ? "modal open" : "modal"}>
<div>
<img src={albums[slideIndex].url} alt="" />
<button className="carousel-close-btn" onClick={handleOnclick}>
close modal
</button>
<BtnSlider moveSlide={nextSlide} direction={"next"} />
<BtnSlider moveSlide={prevSlide} direction={"prev"} />
</div>
</div>
<div className="Album_GridContainer">
{albums &&
albums.map((item, index) => {
return (
<AlbumImage
className="Album_gridImage"
key={index}
image={item}
/>
);
})}
</div>
</div>
);
}
export default Album;
THis is my AlbumContext :
import React, { createContext, useState } from "react";
export const AlbumContext = createContext();
export const AlbumContextProvider = ({ children }) => {
const [albums, setAlbums] = useState();
const [modal, setModal] = useState(false);
const [clickedImg, setClickedImg] = useState("");
const showImg = (img) => {
setClickedImg(img);
setModal(true);
console.log(clickedImg);
};
const getData = async () => {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/albums/1/photos"
);
const obj = await response.json();
console.log(obj);
setAlbums(obj);
} catch (error) {
// console.log(error.response.data.error);
console.log(error);
}
};
console.log(`Albums >>>`, albums);
return (
<AlbumContext.Provider
value={{ albums, getData, showImg, modal, setModal, clickedImg }}
>
{children}
</AlbumContext.Provider>
);
};
Thanks very much in advance
Your clickedImg starts out as the empty string:
const [clickedImg, setClickedImg] = useState("");
And in the consumer, you do:
const [slideIndex, setSlideIndex] = useState(clickedImg.id);
So, it takes the value of clickedImg.id on the first render - which is undefined, because strings don't have such properties. As a result, both before and after fetching, slideIndex is undefined, so after fetching:
albums ? albums[slideIndex].url : "no data"
will evaluate to
albums[undefined].url
But albums[undefined] doesn't exist, of course.
You need to figure out what slide index you want to be in state when the fetching finishes - perhaps start it at 0?
const [slideIndex, setSlideIndex] = useState(0);
maybe because your code for checking albums is empty or not is wrong and its always return true condition so change your code to this:
<div className="Album_GridContainer">
{albums.length > 0 &&
albums.map((item, index) => {
return (
<AlbumImage
className="Album_gridImage"
key={index}
image={item}
/>
);
})}
</div>
change albums to albums.length

React-admin Datagrid: expand all rows by default

I have a react-admin (3.14.1) List using a Datagrid, where each row is expandable.
Does anyone know how to expand all the rows by default?
Or expand a row programmatically?
I've checked the Datagrid code in node_modules/ra-ui-materialui/lib/list/datagrid/Datagrid.js, no obvious props...
Datagrid.propTypes = {
basePath: prop_types_1.default.string,
body: prop_types_1.default.element,
children: prop_types_1.default.node.isRequired,
classes: prop_types_1.default.object,
className: prop_types_1.default.string,
currentSort: prop_types_1.default.shape({
field: prop_types_1.default.string,
order: prop_types_1.default.string,
}),
data: prop_types_1.default.any,
// #ts-ignore
expand: prop_types_1.default.oneOfType([prop_types_1.default.element, prop_types_1.default.elementType]),
hasBulkActions: prop_types_1.default.bool,
hover: prop_types_1.default.bool,
ids: prop_types_1.default.arrayOf(prop_types_1.default.any),
loading: prop_types_1.default.bool,
onSelect: prop_types_1.default.func,
onToggleItem: prop_types_1.default.func,
resource: prop_types_1.default.string,
rowClick: prop_types_1.default.oneOfType([prop_types_1.default.string, prop_types_1.default.func]),
rowStyle: prop_types_1.default.func,
selectedIds: prop_types_1.default.arrayOf(prop_types_1.default.any),
setSort: prop_types_1.default.func,
total: prop_types_1.default.number,
version: prop_types_1.default.number,
isRowSelectable: prop_types_1.default.func,
isRowExpandable: prop_types_1.default.func,
};
I have a solution that does something similar, not using jquery. It's a custom hook that makes the first id of a resource expand, which in my case is the first item in the List.
// useExpandFirst.ts
import * as React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Identifier, ReduxState, toggleListItemExpand } from 'react-admin';
type AboutExpansion = { noneExpanded: boolean; firstId: Identifier };
const useExpandFirst = (props) => {
const { resource } = props;
const once = React.useRef(false);
const dispatch = useDispatch();
const { noneExpanded, firstId } = useSelector<ReduxState, AboutExpansion>((state) => {
const list = state?.admin?.resources?.[resource]?.list;
return {
noneExpanded: list?.expanded?.length === 0,
firstId: list?.ids[0],
};
});
React.useEffect(() => {
if (noneExpanded && firstId && !once.current) {
once.current = true;
const action = toggleListItemExpand(resource, firstId);
dispatch(action);
}
}, [noneExpanded, firstId, dispatch, resource]);
};
Instead of using the hook in the component that actually renders the List, I'm using it in some other (not so) random component, for example the app's Layout. That causes way less rerenders of the component that renders the List.
// MyLayout.tsx
const MyLayout: React.FC<LayoutProps> = (props) => {
// expand the first company record as soon as it becomes available
useExpandFirst({ resource: 'companies' });
return (
<Layout
{...props}
appBar={MyAppBar}
sidebar={MySidebar}
menu={MyMenu}
// notification={MyNotification}
/>
);
};
It's not perfect, but it does the job. With just a few modifications, you can alter it to expand all id's. That would mean that you have to dispatch the action for each id (in the useEffect hook function).
I found this question and used this answer to solve the same problem.
export const useExpandDefaultForAll = (resource) => {
const [ids, expanded] = useSelector(
(state) => ([state.admin.resources[resource].list.ids, state.admin.resources[resource].list.expanded])
);
const dispatch = useDispatch();
useEffect(() => {
for (let i = 0; i < ids.length; i++) {
if (!expanded.includes(ids[i])){
dispatch(toggleListItemExpand(resource, ids[i]));
}
}
}, [ids]);
};
And i also call it in my List component:
const OrderList = (props) => {
useExpandDefaultForAll(props.resource);
...
I will be glad if it is useful to someone. If you know how to do it better then please fix it.
Hacked it with jquery, dear dear.
import $ from 'jquery'
import React, {Fragment} from 'react';
import {List, Datagrid, TextField, useRecordContext} from 'react-admin';
export class MyList extends React.Component {
gridref = React.createRef()
ensureRowsExpanded(ref) {
// Must wait a tick for the expand buttons to be completely built
setTimeout(() => {
if (!ref || !ref.current) return;
const buttonSelector = "tr > td:first-child > div[aria-expanded=false]"
const buttons = $(buttonSelector, ref.current)
buttons.click()
}, 1)
}
/**
* This runs every time something changes significantly in the list,
* e.g. search, filter, pagination changes.
* Surely there's a better way to do it, i don't know!
*/
Aside = () => {
this.ensureRowsExpanded(this.gridref)
return null;
}
render = () => {
return <List {...this.props} aside={<this.Aside/>} >
<Datagrid
expand={<MyExpandedRow />}
isRowExpandable={row => true}
ref={this.gridref}
>
<TitleField source="title" />
</Datagrid>
</List>
}
}
const MyExpandedRow = () => {
const record = useRecordContext();
if (!record) return "";
return <div>Hello from {record.id}</div>;
}
Relies on particular table structure and aria-expanded attribute, so not great. Works though.

Using document.cookie in Gatsby

I need to be able to set and access cookies in my Gatsby project, and I was able to get something solid setup using this tutorial. I'm building a hook that sets a cookie, and utilizing it throughout the site. This is what the helper looks like when it's all said and done.
use-cookie.ts
import { useState, useEffect } from 'react';
const getItem = (key) =>
document.cookie.split('; ').reduce((total, currentCookie) => {
const item = currentCookie.split('=');
const storedKey = item[0];
const storedValue = item[1];
return key === storedKey ? decodeURIComponent(storedValue) : total;
}, '');
const setItem = (key, value, numberOfDays) => {
const now = new Date();
// set the time to be now + numberOfDays
now.setTime(now.getTime() + numberOfDays * 60 * 60 * 24 * 1000);
document.cookie = `${key}=${value}; expires=${now.toUTCString()}; path=/`;
};
/**
*
* #param {String} key The key to store our data to
* #param {String} defaultValue The default value to return in case the cookie doesn't exist
*/
export const useCookie = (key, defaultValue) => {
const getCookie = () => getItem(key) || defaultValue;
const [cookie, setCookie] = useState(getCookie());
const updateCookie = (value, numberOfDays) => {
setCookie(value);
setItem(key, value, numberOfDays);
};
return [cookie, updateCookie];
};
I'm calling the hook into a component like so:
DealerList.tsx
import React, { ReactNode, useEffect } from 'react';
import { Container } from 'containers/container/Container';
import { Section } from 'containers/section/Section';
import { Link } from 'components/link/Link';
import s from './DealerList.scss';
import { useCookie } from 'hooks/use-cookie';
interface DealerListProps {
fetchedData: ReactNode;
}
let cookie;
useEffect(() => {
cookie = useCookie();
}, []);
export const DealerList = ({ fetchedData }: DealerListProps) => {
const dealerInfo = fetchedData;
if (!dealerInfo) return null;
const [cookie, updateCookie] = useCookie('one-day-location', 'sacramento-ca');
return (
<>
<Section>
<Container>
<div className={s.list}>
{dealerInfo.map((dealer: any) => (
<div className={s.dealer} key={dealer.id}>
<div className={s.dealer__info}>
<h3 className={s.name}>
{dealer.company.name}
</h3>
<span className={s.address}>{dealer.address.street}</span>
<span className={s.city}>{dealer.address.city} {dealer.address.zip}</span>
</div>
<div className={s.dealer__contact}>
<span className={s.email}>{dealer.email}</span>
<span className={s.phone}>{dealer.phone}</span>
</div>
<div className={s.dealer__select}>
<Link
to="/"
className={s.button}
onClick={() => {
updateCookie(dealer.phone, 10);
}}
>
Select Location
</Link>
</div>
</div>
))}
</div>
</Container>
</Section>
</>
);
};
It works well on gatsby develop and I'm able to access the value of the cookie and change the contact information that's displayed accordingly. However, when I try and build, or push to Netlify, I'm getting this error.
WebpackError: ReferenceError: document is not defined
I know this has something to do with document.cookie on lines 4 and 17, but I'm struggling trying to figure out how to fix it. Any suggestions? I'm imported useEffect, and from my research that has something to do with it, but what can I do to get it working properly?
Thanks in advance.
I did a bit more research, and I found this simple hook, replaced the code in use-cookie.ts with this, made a few modifications to it (included below), installed universal-cookie and it seems to work perfectly. Here's the new code:
use-cookie.ts
import { useState } from 'react';
import Cookies from 'universal-cookie';
export const useCookie = (key: string, value: string, options: any) => {
const cookies = new Cookies();
const [cookie, setCookie] = useState(() => {
if (cookies.get(key)) {
return cookies.get(key);
}
cookies.set(key, value, options);
});
const updateCookie = (value: string, options: any) => {
setCookie(value);
removeItem(value);
cookies.set(key, value, options);
};
const removeItem = (key: any) => {
cookies.remove(key);
};
return [cookie, updateCookie, removeItem];
};
If anyone has a better way to do this though, please let me know!
According to Gatsby's Debugging HTML Builds documentation:
Some of your code references “browser globals” like window or
document. If this is your problem you should see an error above like
“window is not defined”. To fix this, find the offending code and
either a) check before calling the code if window is defined so the
code doesn’t run while Gatsby is building (see code sample below) or
b) if the code is in the render function of a React.js component, move
that code into a componentDidMount lifecycle or into a useEffect hook,
which ensures the code doesn’t run unless it’s in the browser.
So, without breaking the rule of hooks, calling a hook inside another hook, causing a nested infinite loop. You need to ensure the document creation before calling it. Simply by adding a checking condition:
import { useState } from 'react';
const getItem = (key) => {
if (typeof document !== undefined) {
document.cookie.split(`; `).reduce((total, currentCookie) => {
const item = currentCookie.split(`=`);
const storedKey = item[0];
const storedValue = item[1];
return key === storedKey ? decodeURIComponent(storedValue) : total;
}, ``);
}
};
const setItem = (key, value, numberOfDays) => {
const now = new Date();
// set the time to be now + numberOfDays
now.setTime(now.getTime() + numberOfDays * 60 * 60 * 24 * 1000);
if (typeof document !== undefined) {
document.cookie = `${key}=${value}; expires=${now.toUTCString()}; path=/`;
}
};
/**
*
* #param {String} key The key to store our data to
* #param {String} defaultValue The default value to return in case the cookie doesn't exist
*/
export const useCookie = (key, defaultValue) => {
const getCookie = () => getItem(key) || defaultValue;
const [cookie, setCookie] = useState(getCookie());
const updateCookie = (value, numberOfDays) => {
setCookie(value);
setItem(key, value, numberOfDays);
};
return [cookie, updateCookie];
};
Since you might be calling useCookie custom hook in a component or page where the document doesn't exist yet, I would double-check by using the same condition or using a useEffect with empty dependencies ([], now it won't break the rule of hooks):
const Index = props => {
let cookie;
// both works
if (typeof document !== undefined) {
cookie = useCookie();
}
useEffect(() => {
cookie = useCookie();
}, []);
return <Layout>
<Seo title="Home" />
<h1>Hi people</h1>
</Layout>;
};

React infinite-scroll with array from hook state

I'm having troubles to set up react-infinite-scroller within my React component.
I do not want to fetch data via an API directly in my component with loadMore because I already got it from my IndexedDB.
So I want to use my Array dbScans (array with objects) and want to have infinite scroll with max. 3 items of the array.
I tried to create a loadProducts function to slice and concate my array that I want to render but I am getting overload errors, when I try to call it in the Infinite-Scroll component.
import React, { useEffect, useState } from 'react';
import InfiniteScroll from 'react-infinite-scroller';
export default function ProductHistory() {
const [dbScans, setDbScans] = useState<IProductClient[]>([]);
const [loadedProducts, setLoadedProducts] = useState([]);
useEffect(() => {
(async function getDataFromDB() {
setDbScans(await db.yourScans.toArray());
})();
}, []);
let productHistory = dbScans;
// This function is not working
const loadProducts = (page: number) => {
const perPage = 3;
const moreItems: any = dbScans.slice(
page * perPage - perPage,
page * perPage + 1
);
// Am I doing this right?
setLoadedProducts(loadedProducts.concat(moreItems));
}
return (
<>
<div className="product-history">
{ productHistory.length > 0 ?
<InfiniteScroll
pageStart={0}
loadMore={loadProducts(0)} // This is not working
hasMore={true}
loader={<div className="loader" key={0}>Loading ...</div>}
>
{productHistory.map((product: any) =>
<div className="product-history__item" key={product.id}>
<p>{product.name}
</div>
)}
</InfiniteScroll>
: ''
}
</div>
</>
)
}
You should introduce a state variable called as lastObjectPosition which will have the position of the last object that is being shown by the infinite scroll.
const perPage = 3;
const [lastObjectPosition , setLastObjectPosition ] = useState(0);
And then hasMore attribute should be set like this:
hasMore={lastObjectPosition < dbScans.length}
And finally you should modify loadProducts function like this,
const loadProducts = () => {
setLoadedProducts(currentProducts => {
return [
...currentProducts,
dbScans.slice(lastObjectPosition, lastObjectPosition+perPage)
]
});
setLastObjectPosition(currentValue => {
return currentValue + perPage
}
}

Resources