Найти в Дзене
IlyaDev

Бесконечный скролл. React, TS.

Привет друг ! В этой статье попробуем реализовать универсальный компонент (обёртку), для реализации загрузки новых данных при достижении конца списка пользователем 🧐. За достижением оного обратимся за помощью к браузерному API - Intersection Observer. Чего мы хотим добиться? тут 🥸. И так - поехали !🛼 В родительском компоненте, для нашего будущего InfiniteScrollWrapper.tsx - тот самый универсальный компонент пишем: // моковый массив
const MOCK_DATA = Array.from({ length: 100 }, (_, i) => `Item ${i + 1}`);
// колличество эллементв на странницу
const PAGE_SIZE = 10;
// наш массив MOCK_DATA
const [data, setData] = useState<string[]>(MOCK_DATA.slice(0, PAGE_SIZE));
// состояние для "странницы"
const [page, setPage] = useState<number>(1);
// флажок надо еще или нет :)
const [hasMore, setHasMore] = useState<boolean>(true);
// фейковый запрос
const loadMore = async (): Promise<string[]> => {
return new Promise((resolve) => {
setTimeout(() => {
const nextPageData = MOCK_DATA.sl
Оглавление

Привет друг ! В этой статье попробуем реализовать универсальный компонент (обёртку), для реализации загрузки новых данных при достижении конца списка пользователем 🧐.

За достижением оного обратимся за помощью к браузерному API - Intersection Observer.

Я - Intersection Observer !! :)
Я - Intersection Observer !! :)

Чего мы хотим добиться? тут 🥸.

И так - поехали !🛼

1. Создадим моковые данные и фейковый запрос:

В родительском компоненте, для нашего будущего InfiniteScrollWrapper.tsx - тот самый универсальный компонент пишем:

// моковый массив
const MOCK_DATA =
Array.from({ length: 100 }, (_, i) => `Item ${i + 1}`);
// колличество эллементв на странницу
const PAGE_SIZE = 10;

// наш массив MOCK_DATA
const [data, setData] = useState<string[]>(MOCK_DATA.slice(0, PAGE_SIZE));
// состояние для "странницы"
const [page, setPage] = useState<number>(1);
// флажок надо еще или нет :)
const [hasMore, setHasMore] = useState<boolean>(true);

// фейковый запрос
const loadMore = async (): Promise<string[]> => {
return new Promise((resolve) => {
setTimeout(() => {
const nextPageData = MOCK_DATA.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
setData((prevData) => [...prevData, ...nextPageData]);
setPage((prevPage) => prevPage + 1);
if ((page + 1) * PAGE_SIZE >= MOCK_DATA.length) {
setHasMore(false);
}
resolve(nextPageData);
}, 1000);
});
};

2. Создадим универсальный InfiniteScrollWrapper.tsx:

При типизации функции запроса loadMore, предпочёл дженерик <T>, вместо any, мне кажется так юридически правильнее будет 🙃.

import {ReactNode, useEffect, useRef} from 'react';

interface IScrollCheckWrapper<T> {
children: ReactNode;
// Типизированный дженерик для возврата данных
loadMore: () => Promise<T>;
// Флаг, показывающий, есть ли еще данные для загрузки
hasMore: boolean;
}

export const InfiniteScrollWrapper = <T,>({ children, loadMore, hasMore}: IScrollCheckWrapper<T>) => {
const observerRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
// Сохраняем текущее значение ref
const currentRef = observerRef.current;
// вот и IntersectionObserver подъехал :)
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore();
}
});

if (currentRef) {
observer.observe(currentRef);
}

return () => {
if (currentRef) {
observer.unobserve(currentRef);
}
};
}, [loadMore, hasMore]);

return (
<div>
{children}
<div ref={observerRef} style={{ height: '1px', background: 'transparent' }} />
</div>
);
};

3. Допилим родителя:

App.tsx:
return (
<div style={{ minHeight: '100vh', padding: '20px' }}>
<InfiniteScrollWrapper<string[]>
loadMore={loadMore}
hasMore={hasMore}
>
<ul style={{display: 'flex', flexDirection: 'column', gap: '6px'}}>
{data.map((item, index) => (
<li key={index} style={{
listStyle: 'none',
color: 'black',
padding: '20px',
borderRadius: '12px',
border: '1px solid black',
width: '300px',
height: '30px',
}}>{item}</li>
))}
</ul>
</InfiniteScrollWrapper>
</div>
);

4. Чего-то не хватает 🤔?

В функции loadMore, не просто так стоит задержка в 1 секунду. Таким образом эмитируем "pending" нашего запроса. Ну и как порядочные разрабы вешаем лоадер.

В App.tsx пишем передаём доп пропс loader:

<InfiniteScrollWrapper<string[]>
loadMore={loadMore}
hasMore={hasMore}
loader={<div style={{color: 'black'}}>Загрузка...</div>}
>

В InfiniteScrollWrapper.tsx принимаем его, с распростёртыми объятиями - разумеется 🤗.

interface IScrollCheckWrapper<T> {
children: ReactNode;
loadMore: () => Promise<T>;
hasMore: boolean;
// Опциональный лоадер
loader?: ReactNode;
}

Ну и привяжем его отрисовку ко флагу, который сигнализирует о том, что надо ещё элементов, на странницу, подкинуть (hasMore).

hasMore === true;
hasMore === true;
{hasMore && loader && <div style={{ textAlign: 'center', margin: '10px 0' }}>{loader}</div>}

5. Итоговый код:

//--------App.tsx:
import {useState} from 'react';

import {InfiniteScrollWrapper} from './components/InfiniteScrollWrapper.tsx';

import './App.css';

export const App = () => {
const MOCK_DATA =
Array.from({ length: 100 }, (_, i) => `Item ${i + 1}`);
const PAGE_SIZE = 10;

const [data, setData] = useState<string[]>(MOCK_DATA.slice(0, PAGE_SIZE));
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);

const loadMore = async (): Promise<string[]> => {
return new Promise((resolve) => {
setTimeout(() => {
const nextPageData = MOCK_DATA.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
setData((prevData) => [...prevData, ...nextPageData]);
setPage((prevPage) => prevPage + 1);
if ((page + 1) * PAGE_SIZE >= MOCK_DATA.length) {
setHasMore(false);
}
resolve(nextPageData);
}, 1000);
});
};

return (
<div style={{ minHeight: '100vh', padding: '20px' }}>
<InfiniteScrollWrapper<string[]>
loadMore={loadMore}
hasMore={hasMore}
loader={<div style={{color: 'black'}}>Загрузка...</div>}
>
<ul style={{display: 'flex', flexDirection: 'column', gap: '6px'}}>
{data.map((item, index) => (
<li key={index} style={{
listStyle: 'none',
color: 'black',
padding: '20px',
borderRadius: '12px',
border: '1px solid black',
width: '300px',
height: '30px',
}}>{item}</li>
))}
</ul>
</InfiniteScrollWrapper>
{!hasMore && <p style={{color: 'black'}}>Все эллементы загружены!</p>}
</div>
);
};
//--------InfiniteScrollWrapper.tsx:
import {ReactNode, useEffect, useRef} from 'react';

interface IScrollCheckWrapper<T> {
children: ReactNode;
loadMore: () => Promise<T>;
hasMore: boolean;
loader?: ReactNode;
}

export const InfiniteScrollWrapper = <T,>({
children,
loadMore,
hasMore,
loader,
}: IScrollCheckWrapper<T>) => {
const observerRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
const currentRef = observerRef.current; // Сохраняем текущее значение ref
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore();
}
});

if (currentRef) {
observer.observe(currentRef);
}

return () => {
if (currentRef) {
observer.unobserve(currentRef);
}
};
}, [loadMore, hasMore]);

return (
<div>
{children}
{hasMore && loader && <div style={{ textAlign: 'center', margin: '10px 0' }}>{loader}</div>}
<div ref={observerRef} style={{ height: '1px', background: 'transparent' }} />
</div>
);
};

Теперь мы можем использовать InfiniteScrollWrapper в любом месте нашего приложения! Да и вообще мы молодцы 🧸!

Спасибо за внимание! До Новых Встреч!🤗🤗🤗

-3