Mundarija (27)
- Bu darsda
- 1. Nega bu kerak?
- 2. Nega scroll hodisasi emas
- 3. React'da qorovul: ref callback
- 3.1 Kuzatuvchi qayerda yaratiladi
- 3.2 Qo'g'irchoq kuzatuvchi: mantiqni tekshirish
- 3.3 Haqiqiy kuzatuvchi bilan
- 3.4 rootMargin — oldinroq yuklash
- 3.5 Tuzoq: ko'rinib turgan qorovul
- 4. Tayyor yo'l: react-intersection-observer
- 4.1 useInView
- 4.2 Biz sinab ko'rdik: narxi
- 5. Hamma uchun cheksiz lenta
- 5.1 Narxi
- 5.2 role="feed"
- 5.3 «Yana yuklash» — gibrid
- 6. Ko'p element: virtualizatsiya bilan
- 7. IntersectionObserver boshqa ishlarda
- 8. Tajriba: vazifalar da cheksiz lenta bormi?
- 9. Ko'p uchraydigan xatolar
- 10. Mashqlar
- 1-mashq (oson): qaysi sozlama?
- 2-mashq (o'rta): ikki marta so'rov
- 3-mashq (qiyin): ikki marta avtomatik, keyin tugma
- 11. Real ishda
- Xulosa
- Manbalar
Cheksiz scroll va IntersectionObserver React'da: qorovul hook, react-intersection-observer va a11y
Qisqacha: Cheksiz lentada ro'yxat oxiriga ko'rinmas qorovul element qo'yiladi; u ko'ringanda keyingi bo'lak yuklanadi. Buni
scrollhodisasi emas,IntersectionObserveraytadi — o'lchovimizda bitta aylantirishda ~71 tascrollhodisasi va atigi 2 ta kuzatuvchi chaqiruvi. React'da kuzatuvchi ref callback ichida yaratiladi va React 19 ref cleanup'i bilan uziladi;rootMarginyuklashni oldinroq boshlaydi. Tayyor yo'l —react-intersection-observer(useInView, +1.5 KB gzip). Cheksiz lentaning narxi bor: footer'ga yetib bo'lmaydi, Bobur Tab bilan chiqolmaydi — shuning uchunrole="feed",aria-busyva «Yana yuklash» tugmasi.
Bu darsda
scrollhodisasi vaIntersectionObserverfarqini o'lchov bilan ko'rasiz.- Qorovul uchun React hook yozasiz: ref callback, React 19 cleanup va eng yangi funksiyani ushlab turish.
- Kuzatuvchini qo'g'irchoq bilan almashtirib, cheksiz lenta mantiqini brauzersiz tekshirasiz.
- "Ko'rinib turgan qorovul" tuzog'ini
keybilan yechasiz varootMarginiframe ichida nega ishlamasligini bilasiz. react-intersection-observerninguseInViewvaInViewini ishlatasiz.- Cheksiz lentani hamma uchun qulay qilasiz:
role="feed", e'lonlar, «Yana yuklash» va virtualizatsiya.
Oldin bilishingiz kerak: IntersectionObserver, TanStack Query IV: sahifalash va cheksiz ro'yxat, useRef II: DOM elementlari va ref prop, Jadvallar II: server tomonli jadval.
1. Nega bu kerak?
O'tgan darsda buyurtmalar jadvali sahifalarga bo'lindi: «Oldingi» va «Keyingi» tugmalari. Jadval uchun bu to'g'ri — hisobchi "3-sahifa" ga qaytishni xohlaydi.
«Bahor» mobil ilovasida esa boshqa ekran bor: "Mijozlar fikrlari" lentasi. Uni telefonda barmoq bilan pastga suradilar — Instagram yoki Telegram kanalidagidek. Har 10 ta fikrdan keyin «Keyingi» tugmasini bosish bu yerda noqulay. Fikrlar o'zi kelib tursin: oxiriga yaqinlashganda keyingi bo'lak yuklansin. Bu — cheksiz lenta (infinite scroll).
Ikki darsda bu g'oyaga tegib o'tganmiz. IntersectionObserver darsida qorovul elementni oddiy JavaScript bilan kuzatganmiz. TanStack Query IV darsida useInfiniteQuery bilan React'da birinchi cheksiz lentani yozganmiz. Bu dars ularni bog'laydi va chuqurlashtiradi: React'da kuzatuvchini to'g'ri ulash, tuzoqlar, tayyor kutubxona va eng muhimi — cheksiz lentani hamma ishlata oladigan qilish.
Hayotiy o'xshatish: konveyerli sushi restorani. Siz so'ramaysiz — likopchalar o'zi keladi. Lekin oshpaz har soniya stolingizga qaramaydi. Faqat oxirgi likopcha sizga yaqinlashganda yangisini qo'yadi. Qorovul — o'sha "oxirgi likopcha".
2. Nega scroll hodisasi emas
Eng oddiy yechim — scroll hodisasini tinglash va har safar "oxiriga yetdikmi?" deb hisoblash. Muammo — bu hodisa juda tez-tez keladi. Haqiqiy brauzerda sanab ko'ring: blokni oxirigacha aylantiring va ikki hisoblagichga qarang:
<style>
body { font: 1rem/1.5 system-ui, sans-serif; margin: 1rem; }
#box { height: 200px; overflow-y: auto; border: 1px solid #ccd; }
.item { height: 40px; padding: 0 0.5rem; }
#sentinel { height: 1px; }
</style>
<div id="box"></div>
<p>scroll hodisasi: <b id="scrolls">0</b> ·
IntersectionObserver: <b id="hits">0</b></p>
<script>
const box = document.getElementById("box");
for (let i = 1; i <= 30; i++) {
const div = document.createElement("div");
div.className = "item";
div.textContent = `Fikr №${100 + i}`;
box.append(div);
}
const sentinel = document.createElement("div");
sentinel.id = "sentinel";
box.append(sentinel);
let scrolls = 0;
let hits = 0;
box.addEventListener("scroll", () => {
scrolls += 1;
document.getElementById("scrolls").textContent = scrolls;
}, { passive: true });
new IntersectionObserver(([entry]) => {
hits += 1;
document.getElementById("hits").textContent = hits;
const seen = entry.isIntersecting;
console.log(`IO #${hits}: ko'rinadimi — ${seen}`);
}, { root: box }).observe(sentinel);
</script>Biz Chrome 155 da o'lchadik: blokni bitta silliq harakat bilan boshidan oxirigacha (1 001 px: 30 × 40 px band + 1 px qorovul − 200 px oyna) aylantirdik, uch marta. Natija har safar deyarli bir xil:
- scroll hodisasi3 o'lchov: 72, 71, 7171 marta
- IntersectionObserverhar safar 22 marta
Manba: IlmHamroh kurs tajribasi, 2026-10-17: Chrome 155.0.8059.82 (headless, puppeteer), yuqoridagi natija bloki, har o'lchov yangi sahifada, scrollTo({ behavior: "smooth" })
71–72 ta scroll hodisasi, 2 ta kuzatuvchi chaqiruvi. Kuzatuvchining birinchi chaqiruvi — observe() dan keyin darhol (qorovul ko'rinmayapti: false), ikkinchisi — qorovul ko'ringanda (true).
scroll bilan har ~71 hodisada getBoundingClientRect() chaqirilardi — har biri brauzerni joylashuvni qayta hisoblashga majbur qilishi mumkin. Kuzatuvchi esa bu hisobni brauzerning o'ziga topshiradi: u kesishishni chizish bilan birga, asosiy oqimni band qilmasdan tekshiradi va faqat holat o'zgarganda xabar beradi.
3. React'da qorovul: ref callback
3.1 Kuzatuvchi qayerda yaratiladi
React'da kuzatuvchi DOM elementga kerak. Element esa faqat commit'dan keyin bor. Eng aniq joy — ref callback (useRef II darsi): React element DOM'ga tushganda funksiyani chaqiradi. React 19 da u cleanup qaytara oladi — element olib tashlanganda React aynan shu funksiyani chaqiradi. Kuzatuvchi uchun ideal juftlik: yaratish va uzish bir joyda.
function useSentinel(onVisible) {
// eng yangi funksiya — ref'da (latest ref naqshi)
const latest = useRef(onVisible);
latest.current = onVisible;
return useCallback((el) => {
const io = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) latest.current();
});
io.observe(el);
return () => io.disconnect(); // React 19: element ketganda
}, []);
}
// ishlatish: <li ref={useSentinel(loadMore)} />Uch qaror:
useCallback(…, [])— ref callback'ning havolasi barqaror. Har renderda yangi funksiya bersangiz, React har renderda eski kuzatuvchini uzib, yangisini yaratadi.latest.current— latest ref naqshi. Kuzatuvchi bir marta yaratiladi, lekinonVisiblehar renderda yangi (uhasNextPagekabi yangi qiymatlarni ko'radi). Ref orqali kuzatuvchi doim eng yangisini chaqiradi — eskirgan qiymat muammosi yo'q.useEffectemas. Effekt bilan ham bo'ladi (useRef+useEffect), lekin qorovul shartli chiziladi (hasNextPage && …). Effekt "element paydo bo'ldi/yo'qoldi" ni o'zi sezmaydi — bog'liqliklarni qo'lda kuzatish kerak. Ref callback buni bepul beradi.
3.2 Qo'g'irchoq kuzatuvchi: mantiqni tekshirish
Kursning tekshiruv muhitida (jsdom) IntersectionObserver yo'q — u brauzerning chizish mexanizmiga bog'liq. Haqiqiy kuzatuvchili blokni keyingi bo'limda brauzerda ishga tushirasiz. Lekin cheksiz lentaning mantiqi (qachon yuklash, qachon to'xtash, nechta so'rov) kuzatuvchiga bog'liq emas. Uni qo'g'irchoq (test double) bilan tekshiramiz: interfeysi o'sha (observe, disconnect), lekin "qorovul ko'rindi" ni biz tugma bilan aytamiz. Testlarda (keyingi qismlarda) aynan shunday qilinadi.
import { useCallback, useRef } from "react";
import {
QueryClient,
QueryClientProvider,
useInfiniteQuery,
} from "@tanstack/react-query";
// Qo'g'irchoq kuzatuvchi: IntersectionObserver bilan bir xil
// interfeys (observe, disconnect), "ko'rindi" ni esa tugma aytadi
class FakeObserver {
static all = new Set();
constructor(callback) {
this.callback = callback;
this.targets = new Set();
FakeObserver.all.add(this);
}
observe(el) {
this.targets.add(el);
}
disconnect() {
this.targets.clear();
FakeObserver.all.delete(this);
}
static show() {
for (const o of FakeObserver.all) {
const entries = [...o.targets].map((target) => ({
target,
isIntersecting: true,
}));
o.callback(entries, o);
}
}
}
// Qorovul uchun ref callback: element DOM'ga tushganda kuzatish,
// olib tashlanganda — disconnect (React 19 ref cleanup)
function useSentinel(onVisible, Observer) {
const latest = useRef(onVisible);
latest.current = onVisible;
return useCallback(
(el) => {
const io = new Observer(([entry]) => {
if (entry.isIntersecting) latest.current();
});
io.observe(el);
console.log("kuzatuv boshlandi");
return () => {
io.disconnect();
console.log("kuzatuv tugadi");
};
},
[Observer],
);
}
const REVIEWS = Array.from(
{ length: 9 },
(_, i) => `Fikr №${101 + i}`,
);
function fetchPage(cursor) {
console.log("GET cursor =", cursor);
return new Promise((resolve) =>
setTimeout(() => {
const items = REVIEWS.slice(cursor, cursor + 3);
const end = cursor + 3 >= REVIEWS.length;
const next = end ? undefined : cursor + 3;
resolve({ items, next });
}, 200),
);
}
const queryClient = new QueryClient();
function Feed() {
const q = useInfiniteQuery({
queryKey: ["reviews"],
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 0,
getNextPageParam: (last) => last.next,
});
const sentinel = useSentinel(() => {
if (q.hasNextPage && !q.isFetchingNextPage) q.fetchNextPage();
}, FakeObserver);
if (q.isPending) return <p>Yuklanmoqda…</p>;
return (
<div>
<ul>
{q.data.pages
.flatMap((p) => p.items)
.map((text) => (
<li key={text}>{text}</li>
))}
{q.hasNextPage && (
<li ref={sentinel} aria-hidden="true">
…
</li>
)}
</ul>
<p role="status">
{q.isFetchingNextPage
? "Yuklanmoqda…"
: q.hasNextPage
? ""
: "Hammasi shu"}
</p>
<button onClick={() => FakeObserver.show()}>
Qorovul ko'rindi
</button>
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Feed />
</QueryClientProvider>
);
}Konsolda:
GET cursor = 0
kuzatuv boshlandi
GET cursor = 3
GET cursor = 6
kuzatuv tugadiEkranda:
Fikr №101
Fikr №102
Fikr №103
Fikr №104
Fikr №105
Fikr №106
Fikr №107
Fikr №108
Fikr №109
Hammasi shu
Qorovul ko'rindiKonsolni qadamma-qadam o'qiymiz:
| Qadam | Nima bo'ldi | Konsol |
|---|---|---|
| ochilish | birinchi bo'lak so'raldi | GET cursor = 0 |
| 3 ta fikr keldi | qorovul li chizildi, ref callback |
kuzatuv boshlandi |
| «Qorovul ko'rindi» | hasNextPage — ha, yuklanmayapti — so'rov |
GET cursor = 3 |
| yana | uchinchi bo'lak — oxirgisi (next yo'q) |
GET cursor = 6 |
hasNextPage — yo'q |
qorovul chizilmadi, React cleanup'ni chaqirdi | kuzatuv tugadi |
E'tibor bering: kuzatuvchi bir marta yaratildi va butun lenta davomida yashadi. Qorovul ko'rinish shartidan chiqqanda (hasNextPage yolg'on) React uni DOM'dan olib tashladi va bizning cleanup'imiz kuzatuvchini uzdi. Hech qanday useEffect va bog'liqlik ro'yxati yo'q.
static all = new Set() — klassning statik maydoni (static a'zolar): hamma qo'g'irchoqlar ro'yxati, show() ularning hammasiga "ko'rindi" deydi.
Tekshirib ko'ring:
onVisibleichidagi!q.isFetchingNextPageshartini olib tashlasak, «Qorovul ko'rindi» ni ketma-ket ikki marta tez bossak, nima bo'ladi?
Javob
Ikkinchi bosish ham fetchNextPage() ni chaqiradi. Query'da fetchNextPage standart holatda oldingi yuklashni bekor qilib, so'rovni qaytadan yuboradi — konsolda ikki marta GET cursor = 3 chiqadi. Haqiqiy lentada bu tez aylantirganda bo'ladi: qorovul ko'rindi, yashirindi, yana ko'rindi — tarmoqqa ortiqcha so'rov. Shart (va hasNextPage) — oddiy, lekin majburiy himoya. Buni 2-mashqda o'lchaysiz.
3.3 Haqiqiy kuzatuvchi bilan
Endi o'sha hook — haqiqiy IntersectionObserver bilan. Bu blok natija oynasida (brauzerda) ishlaydi, tekshiruv muhitida emas. «Ishga tushir» ni bosing va ro'yxatni aylantiring:
import { useCallback, useRef } from "react";
import {
QueryClient,
QueryClientProvider,
useInfiniteQuery,
} from "@tanstack/react-query";
function useSentinel(onVisible, rootMargin = "100px") {
const latest = useRef(onVisible);
latest.current = onVisible;
return useCallback(
(el) => {
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) latest.current();
},
// root — o'rab turgan aylantiriladigan blok: iframe ichida
// rootMargin faqat aniq root bilan ishlaydi
{ root: el.closest("[data-scroll]"), rootMargin },
);
io.observe(el);
return () => io.disconnect();
},
[rootMargin],
);
}
const REVIEWS = Array.from(
{ length: 40 },
(_, i) => `Fikr №${101 + i}`,
);
function fetchPage(cursor) {
console.log("GET cursor =", cursor);
return new Promise((resolve) =>
setTimeout(() => {
const items = REVIEWS.slice(cursor, cursor + 5);
const end = cursor + 5 >= REVIEWS.length;
const next = end ? undefined : cursor + 5;
resolve({ items, next });
}, 300),
);
}
const queryClient = new QueryClient();
function Feed() {
const q = useInfiniteQuery({
queryKey: ["reviews"],
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 0,
getNextPageParam: (last) => last.next,
});
const sentinel = useSentinel(() => {
if (q.hasNextPage && !q.isFetchingNextPage) q.fetchNextPage();
});
if (q.isPending) return <p>Yuklanmoqda…</p>;
const items = q.data.pages.flatMap((p) => p.items);
return (
<div
data-scroll
style={{
height: 200,
overflowY: "auto",
border: "1px solid #ccd",
}}
>
<ul style={{ margin: 0 }}>
{items.map((text) => (
<li key={text} style={{ height: 36 }}>
{text}
</li>
))}
</ul>
{q.hasNextPage && (
// key — har bo'lakdan keyin yangi element (pastda: nega)
<p key={q.data.pages.length} ref={sentinel}>
{q.isFetchingNextPage ? "Yuklanmoqda…" : "…"}
</p>
)}
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Feed />
</QueryClientProvider>
);
}Biz uni Chrome 155 da ishga tushirdik: ochilgach 1.5 soniya kutdik, keyin blokni ikki marta oxirigacha aylantirdik. Konsolda (haqiqiy chiqish):
GET cursor = 0
GET cursor = 5
GET cursor = 10
GET cursor = 15Ro'yxatdagi fikrlar soni: ochilgandan keyin 10, birinchi aylantirishdan keyin 15, ikkinchisidan keyin 20. Birinchi qatorga qarang: hech kim aylantirmasdan ikki bo'lak yuklandi. Nega — keyingi ikki bo'limda.
3.4 rootMargin — oldinroq yuklash
rootMargin: "100px" — ildiz (aylantiriladigan blok) chegarasini 100 px "kengaytiradi". Qorovul ko'rinishiga 100 px qolganda yuklash boshlanadi, foydalanuvchi oxiriga yetganda yangi fikrlar allaqachon bor. Birinchi bo'lak 5 × 36 = 180 px — 200 px lik blokka sig'di, qorovul esa "kengaytirilgan" hududda edi. Shuning uchun ikkinchi bo'lak darhol so'raldi.
Kodda root: el.closest("[data-scroll]") ga e'tibor bering. IntersectionObserver darsidagi tuzoq: root berilmasa, ildiz — brauzer oynasi, sahifa esa iframe ichida bo'lsa (natija oynasi aynan shunday), rootMargin e'tiborsiz qoldiriladi. Aniq root bilan ishlaydi. closest() ref callback paytida ota blokni DOM'dan topadi — u allaqachon u yerda.
3.5 Tuzoq: ko'rinib turgan qorovul
Kodda <p key={q.data.pages.length} ref={sentinel}> qatori bor. key nega kerak?
Kuzatuvchi faqat holat o'zgarganda xabar beradi: "ko'rinmas → ko'rinadi" yoki aksincha. Ikkinchi bo'lak keldi, lekin u ham kalta bo'lsa — qorovul hali ham ko'rinib turibdi. Holat o'zgarmadi ("ko'rinadi → ko'rinadi"), xabar yo'q, yuklash to'xtadi. Lenta yarim bo'sh qoladi va foydalanuvchi aylantira olmaydi — aylantiradigan narsa yo'q.
key har bo'lakdan keyin o'zgaradi. React uchun bu — yangi element (key darsi): eskisi olib tashlanadi (cleanup — kuzatuvchi uziladi), yangisi qo'yiladi (ref callback — yangi kuzatuvchi). Yangi kuzatuvchi esa observe() dan keyin darhol boshlang'ich holatni xabar qiladi: "qorovul ko'rinadi". Yana yuklash. Blok to'lguncha shunday davom etadi — o'lchovdagi "ochilishda 10 ta" aynan shu.
Tekshirib ko'ring: Qo'g'irchoq misolda (3.2)
keyyo'q edi, lekin u ishladi. Nega?
Javob
Qo'g'irchoq kuzatuvchi o'zi hech qachon xabar bermaydi — faqat tugma bosilganda. "Holat o'zgarmasa xabar yo'q" qoidasi haqiqiy kuzatuvchida bor, qo'g'irchoqda esa biz uni yozmadik. Bu qo'g'irchoqning chegarasi: u mantiqni tekshiradi, brauzer xulqini emas. Shuning uchun haqiqiy kuzatuvchili blok ham kerak — key tuzog'ini u topdi.
4. Tayyor yo'l: react-intersection-observer
4.1 useInView
Har loyihada hook yozmaslik uchun react-intersection-observer kutubxonasi bor (11.0.1). U React uchun eng ko'p ishlatiladigan IntersectionObserver o'rami:
npm i -E react-intersection-observer@11.0.1import { useInView } from "react-intersection-observer";
function Sentinel({ onVisible }) {
const { ref } = useInView({
rootMargin: "200px",
onChange: (inView) => {
if (inView) onVisible();
},
});
return <li ref={ref} aria-hidden="true" />;
}useInView uch narsa qaytaradi: ref (elementga), inView (hozir ko'rinadimi — boolean, o'zgarganda qayta render) va entry (kuzatuvchining xom yozuvi). Sozlamalari:
| Sozlama | Nima qiladi |
|---|---|
threshold |
elementning qancha qismi ko'rinsa (0…1) |
rootMargin |
ildizni kengaytirish ("200px") |
root |
aylantiriladigan blok (standart — oyna) |
triggerOnce |
bir marta ko'ringach kuzatishni to'xtatish |
skip |
vaqtincha kuzatmaslik (masalan, !hasNextPage) |
onChange |
qayta render qilmasdan, holat o'zgarganda funksiya |
Cheksiz lenta uchun onChange qulayroq: inView ni state qilib, keyin effektda yuklash — ortiqcha qayta render va effekt. Kutubxonada <InView> komponenti ham bor (render props, 18-qismdagi naqsh) — hook ishlata olmaydigan joylar uchun.
4.2 Biz sinab ko'rdik: narxi
Kanon kutubxonani bir xil sharoitda o'lchadi (tajriba/kitlar/src/k/io.tsx, kanondan aynan, qatorlar o'qish uchun bo'lingan):
// #16 react-intersection-observer 11.0.1: useInView
// (cheksiz scroll sentinel)
function Royxat() {
const [n, setN] = useState(20);
const { ref } = useInView({
rootMargin: "200px",
onChange: (k) => {
if (k) setN((x) => x + 20);
},
});
return (
<ul>
{Array.from({ length: n }, (_, i) => (
<li key={i}>Vazifa {i + 1}</li>
))}
<li ref={ref} aria-hidden="true">…</li>
</ul>
);
}Natija (natijalar/tajriba/kutubxonalar.txt, aynan):
asos JS 67719 (+ 0.0 KB) CSS 0 | CSP 0 | xato: yo'q
io JS 69217 (+ 1.5 KB) CSS 0 | CSP 0 | xato: yo'q+1.5 KB gzip, CSP 0. Kichik kutubxona: uning ichida asosan bitta g'oya — bir xil sozlamali elementlar uchun bitta umumiy kuzatuvchi. 100 ta kartochkani kuzatsangiz, 100 ta emas, bitta IntersectionObserver yaratiladi. O'z hookimizda har chaqiruv o'z kuzatuvchisini yaratadi — bitta qorovul uchun farqi yo'q, ko'p element uchun kutubxona tejamkorroq.
Qachon qaysi? Bitta qorovul — o'z hookingiz (15 qator, 0 KB). Ko'p element (rasmlar, kartochkalar, analitika) — kutubxona.
5. Hamma uchun cheksiz lenta
5.1 Narxi
TanStack Query IV darsida ogohlantirish bor edi: cheksiz lentaning qulaylik narxi bor. Endi uni aniq yozamiz:
| Kim | Muammo |
|---|---|
| hamma | sahifa pastidagi footer (aloqa, manzil) hech qachon ko'rinmaydi |
| Bobur (klaviatura) | Tab bilan lentadan chiqib bo'lmaydi — har safar yangi elementlar qo'shiladi |
| Malika (ekran o'quvchi) | nechta element borligini va yangilari kelganini bilmaydi |
| hamma | orqaga qaytganda joy yo'qoladi: 5-bo'lakdan batafsil sahifaga o'tib qaytsa — yana boshida |
Bularning hammasining yechimi bor. Asosiysi — ularni loyihalashda oldindan o'ylash.
5.2 role="feed"
WAI-ARIA'da aynan cheksiz lenta uchun maxsus rol bor — feed (lenta). Uning qoidalari:
<div
role="feed"
aria-busy={isFetchingNextPage}
aria-label="Mijozlar fikrlari"
>
{reviews.map((r, i) => (
<article
key={r.id}
tabIndex={0}
aria-posinset={i + 1}
aria-setsize={hasNextPage ? -1 : reviews.length}
aria-labelledby={`review-${r.id}`}
>
<h3 id={`review-${r.id}`}>{r.author}</h3>
<p>{r.text}</p>
</article>
))}
</div>role="feed"ichida har element —<article>, fokus oladi (tabIndex={0}).aria-busy— yangi bo'lak qo'shilayotgandatrue: ekran o'quvchi o'zgarishni o'qishni kutadi, keyin bir marta e'lon qiladi.aria-posinset/aria-setsize— "15 tadan 3-maqola". Jami noma'lum bo'lsa (yana bor) —aria-setsize="-1". Virtualizatsiya darsidagi juftlik.- WAI-ARIA APG Feed naqshi klaviaturani ham belgilaydi: lenta ichida Page Down / Page Up — keyingi/oldingi maqolaga, Ctrl+End — lentadan keyingi elementga (chiqish yo'li!). Bularni o'zingiz yozasiz — brauzer bermaydi.
5.3 «Yana yuklash» — gibrid
Ko'p jamoalar sof cheksiz lenta o'rniga gibrid tanlaydi: dastlabki bir-ikki bo'lak avtomatik yuklanadi, keyin pastda «Yana yuklash» tugmasi chiqadi. Uchala muammo bir zarbada yechiladi: footer ko'rinadi, Bobur Tab bilan tugmaga yetib, undan keyingi elementlarga o'tadi, Malika tugmani topadi va bosadi. Buni 3-mashqda yozasiz.
Uch qo'shimcha qoida:
- Fokus. «Yana yuklash» bosilgach, fokus yangi kelgan birinchi elementga o'tsin — aks holda Bobur tugmada qoladi, tugma esa pastga "ketdi".
- E'lon.
role="status"da: "10 ta yangi fikr yuklandi". - Joyni saqlash. Batafsil sahifadan qaytganda lenta o'sha joyda ochilsin: yuklangan bo'laklar Query keshida bor (
useInfiniteQueryularni saqlaydi), aylantirish joyini esa router tiklaydi (Scroll va sahifa o'tishlari).
6. Ko'p element: virtualizatsiya bilan
Cheksiz lenta o'sib boradi: 50-bo'lakda DOM'da 500 ta maqola. O'tgan darsdagi o'lchov — 8 000 qatorni chizish 1 soniyadan oshdi. Uzun lentada cheksiz yuklash va virtualizatsiya birga ishlatiladi. Unda qorovul ham kerak emas: virtualizator qaysi qator ko'rinayotganini allaqachon biladi.
const items = virtualizer.getVirtualItems();
const last = items.at(-1);
useEffect(() => {
// oxirgi ko'rinayotgan qator — yuklanganlarning oxiriga
// 5 ta qolganda
const nearEnd = last && last.index >= rows.length - 5;
if (nearEnd && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [last?.index, rows.length, hasNextPage, isFetchingNextPage]);Kuzatuvchi o'rniga — indeks: "ko'rinayotgan oxirgi qator yuklanganlarning oxiriga yaqinmi?". TanStack Table hujjatidagi "virtualized infinite scrolling" misoli aynan shunday qurilgan.
7. IntersectionObserver boshqa ishlarda
Qorovul — kuzatuvchining yagona ishi emas. React ilovalarida yana uchtasi tez-tez uchraydi:
- Og'ir komponentni kechiktirib chizish. Sahifa pastidagi grafik yoki xarita ekranga yaqinlashgandagina chizilsin:
useInView({ triggerOnce: true, rootMargin: "300px" })vainView && <Chart />. lazy bilan birga — kodi ham shu paytda yuklanadi. - Ko'rildi hisobi (impression). Reklama yoki mahsulot kartochkasi "haqiqatan ko'rildimi" —
threshold: 0.5va 1 soniya turishi (IntersectionObserver darsining 3-mashqi). - Aylantirganda paydo bo'lish (reveal) animatsiyasi. Element ko'ringanda silliq paydo bo'lsin. Buni keyingi darslarda CSS va Motion bilan qilamiz — Motion'da hatto tayyor
whileInViewbor (Motion IV: scroll animatsiya). Qoida hozirdan:prefers-reduced-motionda animatsiyasiz.
8. Tajriba: vazifalar da cheksiz lenta bormi?
Bu darsda vazifalar kodi o'zgarmaydi.
Nega kanonda yo'q. Sabab TanStack Query IV va Jadvallar II darslaridagi bilan bir xil: vazifalar API'si butun ro'yxatni bitta javobda beradi, chunki qidiruv, statistika va teglar butun ro'yxatga muhtoj. Uzun ro'yxatni 18-qismdagi virtual ro'yxat chizadi. Cheksiz yuklash bu yerda yechadigan muammo yo'q.
Biz sinab ko'rdik — narxi yuqorida: react-intersection-observer +1.5 KB, CSP 0. O'z hookimiz 0 KB. Agar vazifalar kelajakda serverdan bo'lib-bo'lib yuklasa, o'z hook yetadi: lentada bitta qorovul.
9. Ko'p uchraydigan xatolar
| Belgi | Sabab | Davosi |
|---|---|---|
| bitta bo'lak ikki marta so'raldi | qorovul yana ko'rindi, yuklanish tekshirilmagan | hasNextPage && !isFetchingNextPage |
| lenta yarim bo'sh, aylantirib bo'lmaydi | qorovul ko'rinib turibdi — holat o'zgarmadi, xabar yo'q | qorovulga key={pages.length} |
rootMargin ishlamaydi |
iframe ichida, root berilmagan |
aniq root (aylantiriladigan blok) |
| har renderda kuzatuvchi qayta yaratiladi | ref callback har renderda yangi funksiya | useCallback |
kuzatuvchi eski hasNextPage ni ko'radi |
callback ichida eskirgan qiymat | latest ref (latest.current) |
| sahifa yopilgandan keyin ham ishlaydi | disconnect yo'q |
ref cleanup yoki effekt cleanup |
| footer'ga yetib bo'lmaydi, Tab tuzog'i | sof cheksiz lenta | gibrid: «Yana yuklash» |
scroll da sekinlik |
har hodisada o'lchov | IntersectionObserver |
IntersectionObserver is not defined (testda, SSR'da) |
jsdom/Node'da yo'q | qo'g'irchoq yoki typeof IntersectionObserver tekshiruvi |
10. Mashqlar
Mashqlar kurs/mashqlar/20/16-cheksiz-scroll/ da. Har yechimni «Ishga tushir» bilan sinang.
1-mashq (oson): qaysi sozlama?
- Qorovul ekranga kirishidan 300 px oldin yuklash boshlansin →
rootMargin: - Element yarmi ko'ringandagina hisoblansin →
threshold: - Bir marta ko'ringach kuzatish to'xtasin (react-intersection-observer) →
- Jami elementlar soni noma'lum:
aria-setsizeqiymati →
Yechim
rootMargin: "300px"— satr, CSS'dagimarginkabi yoziladi.threshold: 0.5— 0 dan 1 gacha;[0, 0.5, 1]massiv ham bo'ladi.triggerOnce: true— og'ir komponentni bir marta chizish uchun.aria-setsize="-1"— ARIA'dagi "noma'lum" qiymati.
2-mashq (o'rta): ikki marta so'rov
Qo'g'irchoq kuzatuvchili lentada himoya shartini olib tashladik. «Qorovul ko'rindi» ketma-ket ikki marta bosilganda konsolda bitta bo'lak uchun ikkita so'rov chiqdi (pastda). Nega shunday bo'lganini tushuntiring va kodni tuzating: ikki bosishda bitta so'rov ketsin.
Ishora: «Qo'g'irchoq kuzatuvchi» bo'limidagi «Tekshirib ko'ring» savoli.
import { useCallback, useRef } from "react";
import {
QueryClient,
QueryClientProvider,
useInfiniteQuery,
} from "@tanstack/react-query";
class FakeObserver {
static all = new Set();
constructor(callback) {
this.callback = callback;
this.targets = new Set();
FakeObserver.all.add(this);
}
observe(el) {
this.targets.add(el);
}
disconnect() {
FakeObserver.all.delete(this);
}
static show() {
for (const o of FakeObserver.all) {
const entries = [...o.targets].map(() => ({
isIntersecting: true,
}));
o.callback(entries);
}
}
}
function useSentinel(onVisible) {
const latest = useRef(onVisible);
latest.current = onVisible;
return useCallback((el) => {
const io = new FakeObserver(([entry]) => {
if (entry.isIntersecting) latest.current();
});
io.observe(el);
return () => io.disconnect();
}, []);
}
function fetchPage(cursor) {
console.log("GET cursor =", cursor);
return new Promise((resolve) =>
setTimeout(
() => resolve({ items: [cursor], next: cursor + 1 }),
200,
),
);
}
const queryClient = new QueryClient();
function Feed() {
const q = useInfiniteQuery({
queryKey: ["reviews"],
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 0,
getNextPageParam: (last) => last.next,
});
const sentinel = useSentinel(() => {
q.fetchNextPage(); // himoya yo'q!
});
if (q.isPending) return <p>Yuklanmoqda…</p>;
return (
<div>
<p ref={sentinel}>
Bo'laklar: {q.data.pages.length}
</p>
<button onClick={() => FakeObserver.show()}>
Qorovul ko'rindi
</button>
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Feed />
</QueryClientProvider>
);
}Konsolda:
GET cursor = 0
GET cursor = 1
GET cursor = 1Yechim
import { useCallback, useRef } from "react";
import {
QueryClient,
QueryClientProvider,
useInfiniteQuery,
} from "@tanstack/react-query";
class FakeObserver {
static all = new Set();
constructor(callback) {
this.callback = callback;
this.targets = new Set();
FakeObserver.all.add(this);
}
observe(el) {
this.targets.add(el);
}
disconnect() {
FakeObserver.all.delete(this);
}
static show() {
for (const o of FakeObserver.all) {
const entries = [...o.targets].map(() => ({
isIntersecting: true,
}));
o.callback(entries);
}
}
}
function useSentinel(onVisible) {
const latest = useRef(onVisible);
latest.current = onVisible;
return useCallback((el) => {
const io = new FakeObserver(([entry]) => {
if (entry.isIntersecting) latest.current();
});
io.observe(el);
return () => io.disconnect();
}, []);
}
function fetchPage(cursor) {
console.log("GET cursor =", cursor);
return new Promise((resolve) =>
setTimeout(
() => resolve({ items: [cursor], next: cursor + 1 }),
200,
),
);
}
const queryClient = new QueryClient();
function Feed() {
const q = useInfiniteQuery({
queryKey: ["reviews"],
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 0,
getNextPageParam: (last) => last.next,
});
const sentinel = useSentinel(() => {
if (q.hasNextPage && !q.isFetchingNextPage) q.fetchNextPage();
});
if (q.isPending) return <p>Yuklanmoqda…</p>;
return (
<div>
<p ref={sentinel}>
Bo'laklar: {q.data.pages.length}
</p>
<button onClick={() => FakeObserver.show()}>
Qorovul ko'rindi
</button>
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Feed />
</QueryClientProvider>
);
}Konsolda:
GET cursor = 0
GET cursor = 1Ekranda:
Bo'laklar: 2
Qorovul ko'rindiBirinchi bosish fetchNextPage() ni chaqirdi, isFetchingNextPage — true. Ikkinchi bosish 60 ms dan keyin keldi, so'rov hali yo'lda (200 ms) — shart uni to'xtatdi. Himoyasiz kodda esa Query birinchi so'rovni bekor qilib, cursor = 1 ni qaytadan yubordi: tarmoq ikki marta ishladi, natija — bitta bo'lak.
3-mashq (qiyin): ikki marta avtomatik, keyin tugma
«Mijozlar fikrlari» lentasi (15 ta fikr, bo'lakda 3 ta). Talablar:
- Qorovul ko'rinsa — keyingi bo'lak, lekin faqat ikki marta avtomatik.
- Ikki avtomatik yuklashdan keyin qorovul o'rniga «Yana yuklash» tugmasi chiqadi.
- Hammasi yuklansa — «Hammasi shu».
Qo'g'irchoq kuzatuvchidan foydalaning (2-mashqdagi). Tekshiruv: uchinchi «Qorovul ko'rindi» hech narsa yuklamasin, «Yana yuklash» esa yuklasin.
Ishora: avtomatik yuklashlar sonini useRef da sanang (useRef I — render chaqirmaydigan xotira). Tugma ko'rinishi esa ekranni o'zgartiradi — uni bo'laklar sonidan hisoblang: q.data.pages.length > 2.
Yechim
import { useCallback, useRef } from "react";
import {
QueryClient,
QueryClientProvider,
useInfiniteQuery,
} from "@tanstack/react-query";
class FakeObserver {
static all = new Set();
constructor(callback) {
this.callback = callback;
this.targets = new Set();
FakeObserver.all.add(this);
}
observe(el) {
this.targets.add(el);
}
disconnect() {
FakeObserver.all.delete(this);
}
static show() {
for (const o of FakeObserver.all) {
const entries = [...o.targets].map(() => ({
isIntersecting: true,
}));
o.callback(entries);
}
}
}
function useSentinel(onVisible) {
const latest = useRef(onVisible);
latest.current = onVisible;
return useCallback((el) => {
const io = new FakeObserver(([entry]) => {
if (entry.isIntersecting) latest.current();
});
io.observe(el);
return () => io.disconnect();
}, []);
}
const AUTO_LIMIT = 2;
const REVIEWS = Array.from(
{ length: 15 },
(_, i) => `Fikr №${101 + i}`,
);
function fetchPage(cursor) {
console.log("GET cursor =", cursor);
return new Promise((resolve) =>
setTimeout(() => {
const end = cursor + 3 >= REVIEWS.length;
const next = end ? undefined : cursor + 3;
resolve({ items: REVIEWS.slice(cursor, cursor + 3), next });
}, 200),
);
}
const queryClient = new QueryClient();
function Feed() {
const q = useInfiniteQuery({
queryKey: ["reviews"],
queryFn: ({ pageParam }) => fetchPage(pageParam),
initialPageParam: 0,
getNextPageParam: (last) => last.next,
});
const autoLoads = useRef(0);
const canLoad = q.hasNextPage && !q.isFetchingNextPage;
const sentinel = useSentinel(() => {
if (!canLoad || autoLoads.current >= AUTO_LIMIT) return;
autoLoads.current += 1;
q.fetchNextPage();
});
if (q.isPending) return <p>Yuklanmoqda…</p>;
const auto = q.data.pages.length <= AUTO_LIMIT;
return (
<div>
<p role="status">
Fikrlar: {q.data.pages.flatMap((p) => p.items).length}
</p>
{q.hasNextPage && auto && (
<p ref={sentinel} aria-hidden="true">
…
</p>
)}
{q.hasNextPage && !auto && (
<button onClick={() => q.fetchNextPage()} disabled={!canLoad}>
Yana yuklash
</button>
)}{" "}
{!q.hasNextPage && <p>Hammasi shu</p>}
<button onClick={() => FakeObserver.show()}>
Qorovul ko'rindi
</button>
</div>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Feed />
</QueryClientProvider>
);
}Konsolda:
GET cursor = 0
GET cursor = 3
GET cursor = 6
GET cursor = 9Ekranda:
Fikrlar: 12
Yana yuklash Qorovul ko'rindiKonsolga qarang: uchta «Qorovul ko'rindi» dan faqat ikkitasi so'rov yubordi (cursor = 3 va 6). Ikkinchi avtomatik yuklashdan keyin bo'laklar soni 3 bo'ldi, auto — yolg'on, qorovul DOM'dan olib tashlandi va uning kuzatuvchisi uzildi. Uchinchi «ko'rindi» ni eshitadigan hech kim qolmadi. cursor = 9 — «Yana yuklash» tugmasidan.
Ikki joyda ikki xil xotira: autoLoads — ref (ekranni o'zgartirmaydi, faqat qaror uchun), tugma ko'rinishi esa bo'laklar sonidan hisoblanadi (ekranni o'zgartiradi). Ekrandagi hamma narsa state yoki hisobdan — ref'dan emas (useRef I qoidasi).
11. Real ishda
- Ijtimoiy tarmoqlar va marketpleyslar — lentalar, "sizga yoqishi mumkin", sharhlar. Ko'pincha gibrid: mobil ilovada cheksiz, saytda «Yana ko'rsatish».
- Qidiruv natijalari — ko'p saytlar cheksiz lentadan sahifalarga qaytgan: foydalanuvchi "3-sahifadagi o'sha natija" ga qaytishni xohlaydi, SEO ham sahifali URL'ni yaxshi ko'radi.
- Performans ishlari — kartochkalar, rasmlar va og'ir vidjetlarni ko'ringanda yuklash; analitikada "ko'rildi" hodisasi.
- Intervyu savollari: "Cheksiz scroll'ni qanday yozasiz?", "Nega
scrollhodisasi emas?", "Qorovul ko'rinib tursa nima bo'ladi?", "Cheksiz lentaning a11y muammolari?", "role=\"feed\"nima?".
Xulosa
- Cheksiz lenta — ro'yxat oxiridagi qorovul ko'ringanda keyingi bo'lak.
scrollemas,IntersectionObserver: o'lchovda ~71 ga qarshi 2. - React'da kuzatuvchi — ref callback ichida, React 19 cleanup'i bilan uziladi;
useCallback+ latest ref. - Himoya:
hasNextPage && !isFetchingNextPage. "Ko'rinib turgan qorovul" —key={pages.length}bilan qayta yaratish. iframe ichidarootMargin— faqat aniqrootbilan. - Mantiqni qo'g'irchoq kuzatuvchi bilan tekshirasiz, brauzer xulqini — haqiqiy brauzerda.
react-intersection-observer:useInView(onChange,triggerOnce,skip), +1.5 KB, ko'p elementga bitta kuzatuvchi.- A11y:
role="feed",aria-busy,aria-posinset/aria-setsize="-1", gibrid «Yana yuklash», fokus va e'lon; uzun lentada — virtualizatsiya.
Keyingi dars: Drag and drop: dnd-kit — sudrab tartiblash: sensorlar, klaviatura bilan sudrash, ekran o'quvchi e'lonlari va vazifalar dagi «Tartiblash» rejimi (kerak bo'lganda yuklanadi — INP o'lchovi bilan).
Manbalar
- MDN: Intersection Observer API (
root,rootMargin,threshold) — developer.mozilla.org/docs/Web/API/Intersection_Observer_API - react-intersection-observer (11.0.1) — github.com/thebuilder/react-intersection-observer
- React: ref callback cleanup (React 19) — react.dev/reference/react-dom/components/common#ref-callback
- TanStack Query: Infinite Queries — tanstack.com/query (5.104.1)
- WAI-ARIA APG: Feed Pattern — w3.org/WAI/ARIA/apg/patterns/feed
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!