Salve clã! 😁
Ultimamente tive que adicionar um scroll infinito em uma aplicação e a primeira coisa que veio a cabeça foi procurar uma biblioteca que já tenha implementado isso. Porém me perguntei: Porque não? Porque não implementar esta funcionalidade? e aqui estou 😁.
Introdução
Infinite scroll é uma funcionalidade que ajuda a melhorar a experiência do usuário quando há muitos itens a serem exibidos. Quando o scroll se aproxima ou chega ao final da lista ou página, automaticamente a função que faz a requisição para buscar mais posts é acionada passando a próxima pagina para a rota, sem nem o usuário ter que seleciona-la. Assim que os novos elementos forem recebidos do backend, eles serão concatenados com os que já existiam na lista.
Apesar de substituir a paginação no frontend ainda é preciso dela em seu backend pois a procura por mais posts ocorre pelo incremento das páginas.
Nós conseguimos ver o uso desta estratégia em sites agrupadores de promoção como Promobit e Opa!Ganhei. Além de ser muito utilizada também em redes sociais.
IntersectionObserver API
Para realizar esta funcionalidade utilizaremos uma API nativa do browser para nos auxiliar a monitorar o scroll na tela, chamada IntersectionObserver. Esta API é uma ótima alternativa para gerenciar elementos que vão entrar e sair de outro elemento ou da janela de exibição (viewport) e para quando isto acontecer disparar uma função de callback.
Esta é ferramenta muito vasta, caso queira dar uma lida mais aprofundada deixarei o link da MDN nas referências.
Ao código. 👨💻🚀
Utilizarei o projeto desenvolvido durante a NLW 05, para realizar esta funcionalidade.
Para não perdemos tempo com código que não esta relacionado a este post, abaixo estará parte do código desenvolvido no projeto.
export default function Home({ allEpisodes, latestEpisodes }: IHome) {
return (
<div className={styles.homepage}>
<section className={styles.allEpisodes} >
{...}
<tbody>
{allEpisodes.map(episode => (
<tr key={episode.id}>
<td style={{width: 72}}>
<Image width={120} height={120} src={episode.thumbnail} alt={episode.title} objectFit="cover"/>
</td>
<td>
<Link href={`/episodes/${episode.id}`}>
<a>{episode.title}</a>
</Link>
</td>
<td>{episode.members}</td>
<td style={{width: 100}}>{episode.publishedAt}</td>
<td>{episode.durationAsString}</td>
<td>
<button type="button">
<img src="/play-green.svg" alt="Tocar episódio"/>
</button>
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
)
}
export const getStaticProps: GetStaticProps = async () => {
const { data } = await api.get('episodes', {
params: {
_limit: 3,
_sort:"published_at",
_order: "desc"
}
});
{...}
return {
props: {
allEpisodes,
latestEpisodes,
}
}
};
Como estamos em um projeto NextJS, é comum buscar os todos os episódios pelo getStaticProps e o resultado enviarmos para o componente da página. Porém como iremos implementar o infinite scroll, precisamos no inicio buscar somente a primeira página de episódios.
Portanto precisamos adicionar o query param _page=1
para buscarmos a primeira página de episódios.
const { data } = await api.get('episodes', {
params: {
_page: 1,
_limit: 3,
_sort:"published_at",
_order: "desc"
}
});
Agora dentro do componente da página, precisamos armazenar a variável allEpisodes
em um estado, para posteriormente adicionarmos novos episódios a medida com o usuário for descendo a página. Além disso também será necessário criar um estado para armazenar o valor da página atual.
export default function Home({ allEpisodes, latestEpisodes }: IHome) {
const [episodes, setEpisodes] = useState(allEpisodes);
const [currentPage, setCurrentPage] = useState(2);
{...}
}
O IntersectionObserver precisa monitorar um ou mais elementos para detectar se ele está ou não dentro do campo de visão da viewport. Para isso então vamos adicionar ao final da lista de podcasts um elemento HTML para ser observado e nele adicionamos uma referência.
const loadMoreRef = useRef(null);
//NO FINAL DA LISTA DE PODCASTS
<p ref={loadMoreRef}>Carregando mais episodios...</p>
A ideia então é: Sempre que o elemento com a referência loadMoreRef estiver visível precisamos buscar mais episódios, quando não estiver, o usuário não chegou ao final da listagem, portanto nada será feito.
Sintaxe do IntersectionObserver
A sintaxe do IntersectionObserver é a seguinte:
let observer = new IntersectionObserver(callback, options);
Para declararmos nosso observer (observador) será necessário passar ao construtor uma função callback e alguns parâmetros de configuração.
Declarando o observador
Sobre os parâmetros de configuração, você pode ver a descrição completa na MDN da API mas falarei um pouco sobre o threshold que a porcentagem de exibição do elemento observado. Isso quer dizer que, nosso exemplo, somente quando o nosso elemento HTML p
for exibido 100% é que será disparada a função callback.
Com o observer declarado será necessário passar nosso elemento que será observado a ele através do método observe.
useEffect(() => {
const options = {
root: null,
rootMargin: "20px",
threshold: 1.0
};
const observer = new IntersectionObserver((entities) => {
const target = entities[0];
if (target.isIntersecting){
setCurrentPage(old => old + 1);
}
}, options);
if (loaderRef.current){
observer.observe(loaderRef.current);
}
}, []);
Função callback
Na função callback recebemos como parâmetro todos os elementos observados em formato de array mas como nós só estamos observando um elemento atribuímos o primeiro campo do array ao target.
Dentro do target temos a propriedade chamada isIntersecting que indica se o elemento observado fez a transição para um estado de interseção ou fora de um estado de interseção. Com isso conseguimos garantir que o elemento entrou na área visível da tela e precisamos buscar mais episódios.
Nessa parte, eu achei melhor fazer a requisição em outro useEffects sempre que o valor da página sofrer alteração.
useEffect(() => {
const handleResquest = async () => {
const { data } = await api.get('episodes', {
params: {
_page: currentPage,
_limit: 3,
_sort:"published_at",
_order: "desc"
}
});
if (!data.length){
console.log("Os episodios acabaram");
return;
}
setEpisodes([...episodes, ...data]);
}
handleResquest();
}, [currentPage]);
O useEffect acima é bem parecido com nosso getStaticProps que busca por novos episódios, a diferença é que concatenamos os novos episódios aos já existentes.
Com isso temos um scroll infinito funcionando 🚀! Vou deixar o código completo abaixo para você dar uma olhada em caso de dúvidas.
export default function Home({ allEpisodes, latestEpisodes }: IHome) {
const [episodes, setEpisodes] = useState(allEpisodes);
const [currentPage, setCurrentPage] = useState(2);
const [hasEndingPosts, setHasEndingPosts] = useState(false);
const loaderRef = useRef(null);
useEffect(() => {
const options = {
root: null,
rootMargin: "20px",
threshold: 1.0
};
const observer = new IntersectionObserver((entities) => {
const target = entities[0];
if (target.isIntersecting){
setCurrentPage(old => old + 1);
}
}, options);
if (loaderRef.current){
observer.observe(loaderRef.current);
}
}, []);
useEffect(() =>
const handleResquest = async () => {
const { data } = await api.get('episodes', {
params: {
_page: currentPage,
_limit: 3,
_sort:"published_at",
_order: "desc"
}
});
if (!data.length){
setHasEndingPosts(true);
return;
}
setEpisodes([...episodes, ...data]);
}
handleResquest();
}, [currentPage]);
return (
<div className={styles.homepage}>
<section className={styles.allEpisodes} >
{...}
<tbody>
{episodes.map(episode => (
<tr key={episode.id}>
<td style={{width: 72}}>
<Image width={120} height={120} src={episode.thumbnail} alt={episode.title} objectFit="cover"/>
</td>
<td>
<Link href={`/episodes/${episode.id}`}>
<a>{episode.title}</a>
</Link>
</td>
<td>{episode.members}</td>
<td style={{width: 100}}>{episode.publishedAt}</td>
<td>{episode.durationAsString}</td>
<td>
<button type="button">
<img src="/play-green.svg" alt="Tocar episódio"/>
</button>
</td>
</tr>
))}
</tbody>
</table>
<p ref={loaderRef}>Carregando mais episodios...</p>
</section>
</div>
)
}
É isso ae! 😁 Vimos como implementar um scroll infinito simples que quase sempre optamos por utilizar uma lib que já implemente isso para a gente 😂😂.
Espero ter ajudado você a compreender a construção dessa funcionalidade e fico muito feliz por você ter chegado até aqui 🖖🤘. Vale salientar que o aprendizado é constante e sempre haverá o que melhorar. Caso tenha alguma dúvida ou dicas de melhorias, fique a vontade para entrar em contato comigo.
See you soon!