IlmHamroh
JavaScript Full-stack/20-qism. React: UI kutubxonalar va animatsiya16/30-dars24 daqiqa
Mundarija (27)

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 scroll hodisasi emas, IntersectionObserver aytadi — o'lchovimizda bitta aylantirishda ~71 ta scroll hodisasi va atigi 2 ta kuzatuvchi chaqiruvi. React'da kuzatuvchi ref callback ichida yaratiladi va React 19 ref cleanup'i bilan uziladi; rootMargin yuklashni 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 uchun role="feed", aria-busy va «Yana yuklash» tugmasi.

Bu darsda

  • scroll hodisasi va IntersectionObserver farqini 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 key bilan yechasiz va rootMargin iframe ichida nega ishlamasligini bilasiz.
  • react-intersection-observer ning useInView va InView ini 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:

html
<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:

Bitta silliq aylantirish (1 001 px)
  • 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.

jsx
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, lekin onVisible har renderda yangi (u hasNextPage kabi yangi qiymatlarni ko'radi). Ref orqali kuzatuvchi doim eng yangisini chaqiradi — eskirgan qiymat muammosi yo'q.
  • useEffect emas. 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.

jsx
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:

text
GET cursor = 0
kuzatuv boshlandi
GET cursor = 3
GET cursor = 6
kuzatuv tugadi

Ekranda:

text
Fikr №101
Fikr №102
Fikr №103
Fikr №104
Fikr №105
Fikr №106
Fikr №107
Fikr №108
Fikr №109
Hammasi shu
Qorovul ko'rindi

Konsolni 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: onVisible ichidagi !q.isFetchingNextPage shartini 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:

jsx
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):

text
GET cursor = 0
GET cursor = 5
GET cursor = 10
GET cursor = 15

Ro'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) key yo'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:

bash
npm i -E react-intersection-observer@11.0.1
jsx
import { 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):

tsx
// #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):

text
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:

jsx
<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'shilayotganda true: 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 (useInfiniteQuery ularni 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.

jsx
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" }) va inView && <Chart />. lazy bilan birga — kodi ham shu paytda yuklanadi.
  • Ko'rildi hisobi (impression). Reklama yoki mahsulot kartochkasi "haqiqatan ko'rildimi" — threshold: 0.5 va 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 whileInView bor (Motion IV: scroll animatsiya). Qoida hozirdan: prefers-reduced-motion da 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-setsize qiymati →
Yechim
  • rootMargin: "300px" — satr, CSS'dagi margin kabi 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.

jsx
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:

text
GET cursor = 0
GET cursor = 1
GET cursor = 1
Yechim
jsx
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:

text
GET cursor = 0
GET cursor = 1

Ekranda:

text
Bo'laklar: 2
Qorovul ko'rindi

Birinchi 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:

  1. Qorovul ko'rinsa — keyingi bo'lak, lekin faqat ikki marta avtomatik.
  2. Ikki avtomatik yuklashdan keyin qorovul o'rniga «Yana yuklash» tugmasi chiqadi.
  3. 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
jsx
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:

text
GET cursor = 0
GET cursor = 3
GET cursor = 6
GET cursor = 9

Ekranda:

text
Fikrlar: 12
Yana yuklash Qorovul ko'rindi

Konsolga 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 scroll hodisasi 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. scroll emas, 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 ichida rootMargin — faqat aniq root bilan.
  • 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
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Cheksiz scroll va IntersectionObserver React'da: qorovul hook, react-intersection-observer va a11y — IlmHamroh