IlmHamroh
JavaScript Full-stack/19-qism. React: routing, server state, formalar16/36-dars21 daqiqa
Mundarija (23)

TanStack Query sahifalash: keepPreviousData, useInfiniteQuery va cheksiz ro'yxat

Qisqacha: Ma'lumot ko'p bo'lsa, server uni bo'laklab beradi. Raqamli sahifalarda useQuery ning kalitiga sahifa raqami qo'yiladi va placeholderData: keepPreviousData yangi sahifa kelguncha eskisini ekranda ushlab turadi. "Yana yuklash" va cheksiz aylantirishda — useInfiniteQuery: u hamma bo'laklarni bitta keshda pages massivi qilib saqlaydi, keyingisini getNextPageParam aytadi.

Bu darsda

  • Sahifa raqami kalitda bo'lgan useQuery yozasiz va "Yuklanmoqda…" miltillashini keepPreviousData bilan yo'qotasiz.
  • useInfiniteQuery bilan «Yana yuklash» tugmasini qurasiz: pages, fetchNextPage, hasNextPage.
  • Offset va kursorli sahifalashni solishtirasiz va offset nega takror element berishini ko'rasiz.
  • IntersectionObserver bilan ro'yxat oxiriga yetganda avtomatik yuklashni yozasiz.
  • vazifalar da nega sahifalash yo'qligini va tajriba nima ko'rsatganini bilasiz.

Oldin bilishingiz kerak: TanStack Query III: mutatsiyalar, TanStack Query II: kesh hayot sikli, IntersectionObserver, URL — holat saqlash joyi.

1. Nega bu kerak?

«Bahor» endi faqat restoran emas — yetkazib berish ham qiladi. Sharhlar sahifasida 2 000 ta sharh to'plandi. Hammasini bitta so'rovda yuborish — telefonda bir necha megabayt va sekundlab kutish. Mehmon esa birinchi o'ntasini o'qiydi, xolos.

Shuning uchun server ma'lumotni bo'laklab beradi. Har bo'lak — sahifa (page). Buni sahifalash (pagination) deyishadi. Siz buni Algoritmlar real frontend va backend ishida darsida ham ko'rgansiz: offset va kursor.

Interfeysda ikki xil ko'rinish bor:

  • raqamli sahifalar — "1 2 3 … Keyingi". Qidiruv natijalari, admin jadvallari. Foydalanuvchi aniq sahifaga qaytishi mumkin.
  • cheksiz ro'yxat — pastga aylantirasiz, yangilari qo'shilib boradi. Telegram kanali, Instagram lentasi. Yoki «Yana yuklash» tugmasi.

O'xshatish: kutubxonadagi katalog. Raqamli sahifalar — kitob katalogining varaqlari: 5-varaqni ochasiz, 4-varaq yopiladi. Cheksiz ro'yxat — o'ralgan qog'oz: tortgan sari uzayadi, boshi qo'lingizda qoladi.

2. Raqamli sahifalar

2.1 Sahifa — kalitning bir qismi

TanStack Query I dagi qoida: funksiya nimaga bog'liq bo'lsa, o'sha kalitda bo'ladi. Sahifa raqami ham shunday. Natija oynasida server — 7 ta taom, har sahifada 3 ta:

jsx
import { useState } from "react";
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

const MENU = [
  "Osh", "Lag'mon", "Manti", "Somsa",
  "Sho'rva", "Norin", "Chuchvara",
];
const SIZE = 3;

function fetchPage(page) {
  console.log("sahifa so'raldi:", page);
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve({
          items: MENU.slice((page - 1) * SIZE, page * SIZE),
          hasMore: page * SIZE < MENU.length,
        }),
      200,
    ),
  );
}

function Menu() {
  const [page, setPage] = useState(1);
  const { isPending, data } = useQuery({
    queryKey: ["menu", page],
    queryFn: () => fetchPage(page),
  });
  return (
    <>
      <p>{isPending ? "Yuklanmoqda…" : data.items.join(", ")}</p>
      <p>
        <button onClick={() => setPage(page - 1)}>
          Oldingi
        </button>{" "}
        {page}-sahifa{" "}
        <button onClick={() => setPage(page + 1)}>Keyingi</button>
      </p>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Menu />
    </QueryClientProvider>
  );
}

Konsolda:

text
sahifa so'raldi: 1
sahifa so'raldi: 2

Ekranda:

text
Somsa, Sho'rva, Norin
Oldingi 2-sahifa Keyingi

Ishlaydi. Lekin «Keyingi» ni bosganda ro'yxat butunlay yo'qolib, "Yuklanmoqda…" ga almashdi (oraliq nuqta buni tekshirdi). Kalit ["menu", 2] — yangi so'rov, keshda hali hech narsa yo'q, demak isPending. Har sahifada ekran "sakraydi": ro'yxat yo'qoladi, tugmalar yuqoriga ko'tariladi, keyin yana tushadi. Bu CLS ga ham yomon.

Yana ikki kamchilik: 1-sahifada «Oldingi» va oxirgi sahifada «Keyingi» ishlayveradi. Ularni keyingi misolda tuzatamiz.

2.2 placeholderData: keepPreviousData

placeholderData — so'rov tugaguncha ekranda turadigan "vaqtinchalik" ma'lumot. U keshga yozilmaydi, faqat ko'rsatiladi. Unga funksiya berilsa, Query unga oldingi kalitning ma'lumotini beradi. keepPreviousData — Query'ning tayyor funksiyasi: "oldingisini qaytar".

Natija: 2-sahifa kelguncha 1-sahifa ekranda qoladi, isPending emas, isPlaceholderData rost bo'ladi:

jsx
import { useState } from "react";
import {
  keepPreviousData,
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

const MENU = [
  "Osh", "Lag'mon", "Manti", "Somsa",
  "Sho'rva", "Norin", "Chuchvara",
];
const SIZE = 3;

function fetchPage(page) {
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve({
          items: MENU.slice((page - 1) * SIZE, page * SIZE),
          hasMore: page * SIZE < MENU.length,
        }),
      200,
    ),
  );
}

function Menu() {
  const [page, setPage] = useState(1);
  const { isPending, data, isPlaceholderData } = useQuery({
    queryKey: ["menu", page],
    queryFn: () => fetchPage(page),
    placeholderData: keepPreviousData,
  });
  if (isPending) return <p>Yuklanmoqda…</p>;
  return (
    <>
      <p style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
        {data.items.join(", ")}
        {isPlaceholderData && " (yuklanmoqda…)"}
      </p>
      <p>
        <button
          disabled={page === 1}
          onClick={() => setPage(page - 1)}
        >
          Oldingi
        </button>{" "}
        {page}-sahifa{" "}
        <button
          disabled={isPlaceholderData || !data.hasMore}
          onClick={() => setPage(page + 1)}
        >
          Keyingi
        </button>
      </p>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Menu />
    </QueryClientProvider>
  );
}

Ekranda:

text
Chuchvara
Oldingi 3-sahifa Keyingi

«Keyingi» bosilgach 1-sahifaning taomlari xira holda va "(yuklanmoqda…)" bilan qoldi — sakrash yo'q. 3-sahifada faqat bitta taom va «Keyingi» o'chiq (hasMore: false).

Tugmadagi ikki shartga e'tibor bering:

  • !data.hasMore — server "boshqa yo'q" desa, keyingi sahifa yo'q. Bu ma'lumotni server beradi; mijoz MENU.length ni bilmaydi.
  • isPlaceholderData — ekranda hali eski sahifa. Bu paytda data.hasMore ham eski sahifaniki. Uni tekshirmasak, tez-tez bosgan foydalanuvchi mavjud bo'lmagan sahifaga o'tib ketishi mumkin.

placeholderData va oldingi darsdagi initialData farqi muhim:

initialData placeholderData
keshga yoziladimi ha yo'q
staleTime ga ta'siri hisoblanadi yo'q, so'rov doim ketadi
qachon ma'lumot haqiqatan bor (localStorage, server) "hozircha shuni ko'rsat"

vazifalar dagi initialData — localStorage'dagi haqiqiy ro'yxat. Sahifalashdagi eski sahifa esa yangi sahifaning ma'lumoti emas — shuning uchun placeholderData.

2.3 Sahifa raqami — URL'da

Hozir page — useState. Mehmon 3-sahifani do'stiga yubormoqchi bo'lsa — havolada sahifa yo'q. Yangilasa — 1-sahifaga qaytadi. URL — holat saqlash joyi darsidagi qoida: foydalanuvchi ulashishi yoki «Orqaga» bilan qaytishi mumkin bo'lgan holat — URL'da. Raqamli sahifalashda ?sahifa=3 — useSearchParams bilan:

jsx
const [params, setParams] = useSearchParams();
const page = Number(params.get("sahifa") ?? "1");
const { data } = useQuery({
  queryKey: ["menu", page],
  queryFn: () => fetchPage(page),
  placeholderData: keepPreviousData,
});
// «Keyingi»: setParams({ sahifa: String(page + 1) })

"Hujumchi nigohi" o'sha darsdagidek: ?sahifa=-5 yoki ?sahifa=abc ham keladi. Number.isInteger(page) && page >= 1 bilan tekshiring, aks holda 1.

Tekshirib ko'ring: keepPreviousData bilan 1-sahifadan 2-ga o'tdingiz, keyin 1-ga qaytdingiz. Qaytganda nima ko'rinadi va so'rov ketadimi?

Javob

1-sahifa darhol, xira emas — ["menu", 1] keshda bor (gcTime 5 daqiqa), isPlaceholderData yolg'on. So'rov esa ketadi: staleTime sukutda 0, ma'lumot eskirgan — fonda yangilanadi. staleTime ni oshirsangiz, sahifalar orasida yurish so'rovsiz bo'ladi.

3. Cheksiz ro'yxat: useInfiniteQuery

3.1 Nega oddiy useQuery yetmaydi

«Yana yuklash» da 1-sahifa yo'qolmasligi kerak: ekranda 1 + 2 + 3-sahifa birga. useQuery bilan har sahifa alohida kalitda — ularni qo'lda yig'ish, tartibini saqlash, qaysi biri oxirgi ekanini bilish kerak bo'lardi.

useInfiniteQuery — hamma bo'laklarni bitta kalitda saqlaydi. Keshdagi ma'lumot shakli:

flowchart LR
  K["kalit: ['feed']"] --> D["data"]
  D --> P["pages"]
  D --> PP["pageParams"]
  P --> P1["1-bo'lak: 3 ta"]
  P --> P2["2-bo'lak: 3 ta"]
  PP --> X1["0"]
  PP --> X2["3"]

pages — kelgan bo'laklar tartib bilan. pageParams — har bo'lak qaysi parametr bilan so'ralgani. Parametr — offset, sahifa raqami yoki kursor, qaysi biri ekanini o'zingiz hal qilasiz.

3.2 «Yana yuklash»

Uch yangi sozlama:

  • initialPageParam — birinchi bo'lak parametri (bu yerda offset 0).
  • queryFn: ({ pageParam }) => … — Query parametrni kontekstda beradi.
  • getNextPageParam(oxirgiBolak, hammaBolaklar) — keyingi parametrni hisoblaydi. undefined yoki null qaytarsa — boshqa bo'lak yo'q.
jsx
import {
  QueryClient,
  QueryClientProvider,
  useInfiniteQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

const MENU = [
  "Osh", "Lag'mon", "Manti", "Somsa",
  "Sho'rva", "Norin", "Chuchvara",
];

function fetchChunk(offset) {
  console.log("offset:", offset);
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve({ items: MENU.slice(offset, offset + 3), total: 7 }),
      200,
    ),
  );
}

function Feed() {
  const query = useInfiniteQuery({
    queryKey: ["feed"],
    queryFn: ({ pageParam }) => fetchChunk(pageParam),
    initialPageParam: 0,
    getNextPageParam: (lastPage, allPages) => {
      const loaded = allPages.reduce((n, p) => n + p.items.length, 0);
      return loaded < lastPage.total ? loaded : undefined;
    },
  });
  if (query.isPending) return <p>Yuklanmoqda…</p>;
  const items = query.data.pages.flatMap((p) => p.items);
  return (
    <>
      <ol>
        {items.map((name) => (
          <li key={name}>{name}</li>
        ))}
      </ol>
      <button
        disabled={!query.hasNextPage || query.isFetchingNextPage}
        onClick={() => query.fetchNextPage()}
      >
        {query.isFetchingNextPage
          ? "Yuklanmoqda…"
          : query.hasNextPage
            ? "Yana yuklash"
            : "Hammasi ko'rsatildi"}
      </button>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Feed />
    </QueryClientProvider>
  );
}

Konsolda:

text
offset: 0
offset: 3
offset: 6

Ekranda:

text
Osh
Lag'mon
Manti
Somsa
Sho'rva
Norin
Chuchvara
Hammasi ko'rsatildi

Qatorma-qator:

Bosish pageParam pages hasNextPage
ochilish 0 1 bo'lak (3) rost: 3 < 7
1-«Yana» 3 2 bo'lak (6) rost: 6 < 7
2-«Yana» 6 3 bo'lak (7) yolg'on: undefined

Yangi maydonlar:

  • data.pages — bo'laklar massivi. Ekranga bitta ro'yxat kerak — flatMap (flat va flatMap) bilan yoyamiz.
  • fetchNextPage() — keyingi bo'lakni so'raydi.
  • hasNextPage — getNextPageParam undefined qaytarmadimi.
  • isFetchingNextPage — aynan keyingi bo'lak yo'lda. isFetching dan farqi: isFetching fonda butun ro'yxat yangilanganda ham rost.

Teskari yo'nalish ham bor: getPreviousPageParam, fetchPreviousPage, hasPreviousPage. Chat ilovasida kerak — yuqoriga aylantirsangiz, eski xabarlar yuklanadi.

3.3 Tuzoq: cheksiz ro'yxatni qayta yuklash

Cheksiz ro'yxat eskirsa (oynaga qaytdingiz, invalidatsiya), Query uni qanday yangilaydi? Har bo'lakni alohida emas — birinchisidan boshlab ketma-ket, getNextPageParam ni qayta hisoblab. Nega: o'rtada element qo'shilgan bo'lsa, eski parametrlar endi to'g'ri emas.

Oqibati — 20 ta bo'lak yuklangan bo'lsa, oynaga qaytish 20 ta ketma-ket so'rov. Ikki himoya bor:

  • staleTime ni oshirish — lenta har tabga qaytganda yangilanmasin.
  • maxPages — keshda nechta bo'lak saqlansin. maxPages: 5 — oltinchisi kelganda birinchisi tashlanadi. Xotira ham, qayta yuklash ham cheklanadi. Shart — getPreviousPageParam ham yozilgan bo'lishi: foydalanuvchi yuqoriga qaytsa, tashlangan bo'lak qayta so'raladi.

4. Offset yoki kursor

4.1 Offset tuzog'i — takror element

Offset (siljish) — "nechtasini o'tkazib yubor": ?offset=3&limit=3. Oddiy va tushunarli. Lekin ro'yxat o'zgarib tursa, muammo bor.

Sharhlar yangisidan eskisiga tartiblangan. Mehmon 1-bo'lakni (3 ta) ko'rdi. Shu payt boshqa mehmon yangi sharh yozdi — u ro'yxat boshiga tushdi. Endi "3 tani o'tkazib yubor" — eski 3-sharh 4-o'ringa surildi va ikkinchi bo'lakda yana keladi:

jsx
import {
  QueryClient,
  QueryClientProvider,
  useInfiniteQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

// Server: yangisi boshida
const reviews = [
  "Sharh 6", "Sharh 5", "Sharh 4", "Sharh 3", "Sharh 2",
];

function fetchByOffset(offset) {
  return new Promise((resolve) =>
    setTimeout(() => resolve(reviews.slice(offset, offset + 3)), 200),
  );
}

function Reviews() {
  const query = useInfiniteQuery({
    queryKey: ["reviews"],
    queryFn: ({ pageParam }) => fetchByOffset(pageParam),
    initialPageParam: 0,
    getNextPageParam: (last, all) =>
      last.length === 3 ? all.length * 3 : undefined,
  });
  if (query.isPending) return <p>Yuklanmoqda…</p>;
  const items = query.data.pages.flat();
  return (
    <>
      <p>{items.join(", ")}</p>
      <button onClick={() => reviews.unshift("Sharh 7")}>
        Yangi sharh yozildi
      </button>{" "}
      <button onClick={() => query.fetchNextPage()}>
        Yana yuklash
      </button>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Reviews />
    </QueryClientProvider>
  );
}

Ekranda:

text
Sharh 6, Sharh 5, Sharh 4, Sharh 4, Sharh 3, Sharh 2
Yangi sharh yozildi Yana yuklash

"Sharh 4" ikki marta. Teskari holat ham bor: sharh o'chirilsa, bitta element hech qachon ko'rinmaydi. React'da bir xil key bilan ikkita element — konsolda ogohlantirish va noto'g'ri yangilanishlar (Ro'yxatlar va key). Bu misolda kalit sifatida indeks emas, matn ishlatsak — React xato beradi.

4.2 Kursor

Kursor (cursor) — "mana shu elementdan keyingilarini ber": ?keyin=sharh-4. Server oxirgi ko'rilgan elementning belgisini (id, sana) oladi va undan keyingilarni qaytaradi. Boshiga qo'shilgan element kursorga ta'sir qilmaydi.

jsx
import {
  QueryClient,
  QueryClientProvider,
  useInfiniteQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

const reviews = [
  { id: 6, text: "Sharh 6" },
  { id: 5, text: "Sharh 5" },
  { id: 4, text: "Sharh 4" },
  { id: 3, text: "Sharh 3" },
  { id: 2, text: "Sharh 2" },
];

// Server: kursor — oxirgi ko'rilgan sharhning id'si
function fetchAfter(cursor) {
  const start =
    cursor === null
      ? 0
      : reviews.findIndex((r) => r.id === cursor) + 1;
  const items = reviews.slice(start, start + 3);
  const next = start + 3 < reviews.length ? items.at(-1).id : null;
  return new Promise((resolve) =>
    setTimeout(() => resolve({ items, next }), 200),
  );
}

function Reviews() {
  const query = useInfiniteQuery({
    queryKey: ["reviews"],
    queryFn: ({ pageParam }) => fetchAfter(pageParam),
    initialPageParam: null,
    getNextPageParam: (last) => last.next,
  });
  if (query.isPending) return <p>Yuklanmoqda…</p>;
  const items = query.data.pages.flatMap((p) => p.items);
  return (
    <>
      <p>{items.map((r) => r.text).join(", ")}</p>
      <button
        onClick={() => reviews.unshift({ id: 7, text: "Sharh 7" })}
      >
        Yangi sharh yozildi
      </button>{" "}
      <button
        disabled={!query.hasNextPage}
        onClick={() => query.fetchNextPage()}
      >
        Yana yuklash
      </button>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Reviews />
    </QueryClientProvider>
  );
}

Ekranda:

text
Sharh 6, Sharh 5, Sharh 4, Sharh 3, Sharh 2
Yangi sharh yozildi Yana yuklash

Takror yo'q. Server "Sharh 4 dan keyingilar" ni berdi — yangi "Sharh 7" boshida, u kursorga xalaqit bermaydi. getNextPageParam endi hisoblamaydi, serverning next maydonini qaytaradi, xolos. null — "boshqa yo'q", hasNextPage yolg'on.

Offset Kursor
so'rov ?offset=20&limit=10 ?keyin=a8f3&limit=10
ixtiyoriy sahifaga sakrash bor ("5-sahifa") yo'q
ro'yxat o'zgarsa takror yoki tushib qolish barqaror
katta jadvalda tezlik sekinlashadi (OFFSET 100000) tez (indeks bo'yicha)
qayerda admin jadvali, qidiruv natijalari lenta, chat, bildirishnomalar

Oxirgi qatordagi "sekinlashadi" — server tomoni: ma'lumotlar bazasi 100 000 ta qatorni o'qib, tashlab yuborishi kerak. Buni Keyset pagination va katta OFFSET darsida o'lchaymiz. Frontendchi sifatida bilishingiz kerak bo'lgan qism: API kursor bersa — getNextPageParam: (last) => last.nextCursor ?? undefined.

Tekshirib ko'ring: Telegram kanali uchun qaysi sahifalash to'g'ri va nega?

Javob

Kursor. Kanalga doim yangi postlar qo'shiladi va eskilari ba'zan o'chiriladi. Offset bilan aylantirib o'tirgan foydalanuvchi takror postlarni yoki tushib qolganlarni ko'rardi. "5-sahifaga sakrash" esa lentada kerak emas.

5. Cheksiz aylantirish: IntersectionObserver

«Yana yuklash» tugmasi o'rniga — ro'yxat oxiriga yetganda o'zi yuklasin. IntersectionObserver darsidan bilasiz: u element ekranga kirganini xabar qiladi. Ro'yxat oxiriga ko'rinmas "qorovul" element qo'yamiz. U ko'rindi — fetchNextPage().

Natija oynasi brauzerda ishlaydi — IntersectionObserver bor. Lekin kursning tekshiruv muhitida (jsdom) u yo'q, shuning uchun bu blok tekshirilmagan: «Ishga tushir» ni bosing va natija oynasini pastga aylantiring.

jsx
import { useEffect, useRef } from "react";
import {
  QueryClient,
  QueryClientProvider,
  useInfiniteQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

function fetchChunk(offset) {
  return new Promise((resolve) =>
    setTimeout(() => {
      const items = Array.from(
        { length: 10 },
        (_, i) => `Buyurtma №${offset + i + 1}`,
      );
      resolve({ items, next: offset < 40 ? offset + 10 : undefined });
    }, 300),
  );
}

function Orders() {
  const query = useInfiniteQuery({
    queryKey: ["orders"],
    queryFn: ({ pageParam }) => fetchChunk(pageParam),
    initialPageParam: 0,
    getNextPageParam: (last) => last.next,
  });
  const sentinel = useRef(null);
  const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;

  useEffect(() => {
    const el = sentinel.current;
    if (el === null || !hasNextPage) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { rootMargin: "200px" },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (query.isPending) return <p>Yuklanmoqda…</p>;
  return (
    <>
      <ul>
        {query.data.pages
          .flatMap((p) => p.items)
          .map((name) => (
            <li key={name} style={{ padding: 8 }}>
              {name}
            </li>
          ))}
      </ul>
      <p ref={sentinel}>
        {isFetchingNextPage
          ? "Yuklanmoqda…"
          : hasNextPage
            ? ""
            : "Hammasi shu"}
      </p>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Orders />
    </QueryClientProvider>
  );
}

Kodda uch muhim joy:

  • rootMargin: "200px" — qorovul ekranga kirishidan 200 px oldin yuklash boshlanadi. Foydalanuvchi oxiriga yetganda yangi qatorlar allaqachon bor.
  • !isFetchingNextPage — bo'lak yo'lda bo'lsa, yana so'ramaymiz. Aks holda tez aylantirishda bitta bo'lak ikki marta so'raladi.
  • Cleanup — observer.disconnect() (useEffect III: cleanup). Bog'liqliklar o'zgarganda eski kuzatuvchi o'chadi, yangisi ulanadi.

Diqqat: Cheksiz aylantirishning qulaylik narxi bor. Sahifa pastida turgan narsalar (footer, aloqa havolalari) hech qachon ko'rinmaydi. Klaviatura bilan yuradigan Bobur Tab bosib ro'yxatdan chiqa olmaydi — har safar yangi qatorlar qo'shiladi. Ekran o'quvchi (Malika) qancha element borligini bilmaydi. Ko'p jamoalar shuning uchun «Yana yuklash» tugmasini afzal ko'radi yoki bir necha avtomatik yuklashdan keyin tugmaga o'tadi.

Juda uzun ro'yxatda (minglab qator) DOM'dagi elementlar soni ham muammo. Buning davosi — virtualizatsiya: ekranda faqat ko'rinadigan qatorlar chiziladi. Cheksiz so'rov va virtualizatsiya ko'pincha birga ishlatiladi.

6. Tajriba: vazifalar da sahifalash bormi?

Bu darsda vazifalar kodi o'zgarmaydi. Sababi o'lchov va kanon qarorida.

Nega kanonda yo'q. vazifalar API'sida sahifalash yo'q: server butun ro'yxatni bitta javobda beradi. Undan muhimrog'i — ilovaning o'zi butun ro'yxatni talab qiladi. Qidiruv hamma vazifa bo'yicha ishlaydi, statistika hammasini sanaydi, teg daraxti hamma teglardan quriladi. Bo'lak-bo'lak yuklansa, qidiruv faqat yuklangan qismda topardi — bu xato. Uzun ro'yxat muammosini esa 18-qismdagi virtual ro'yxat allaqachon hal qilgan.

Biz sinab ko'rdik. useInfiniteQuery ni «Bahor» mashq serverining menyusida tekshirdik — u yerda sahifalash bor: ?sahifa=N&hajm=2, javobda keyingi maydoni (kursorga o'xshash: server keyingi sahifa raqamini o'zi aytadi). Tajriba kodi (tajriba/variantlar/server-cheksiz.tsx, kanondan aynan, qatorlar o'qish uchun bo'lingan):

tsx
type Sahifa = {
  elementlar: { id: number; nom: string; narx: number }[];
  sahifa: number;
  keyingi: number | null;
  jami: number;
};

function Ilova() {
  const s = useInfiniteQuery({
    queryKey: ["menyu"],
    initialPageParam: 1,
    queryFn: async ({ pageParam, signal }): Promise<Sahifa> =>
      (await fetch(
        `https://ilmhamroh.uz/api/mashq/menyu?sahifa=${pageParam}&hajm=2`,
        { signal },
      )).json() as Promise<Sahifa>,
    getNextPageParam: (oxirgi) => oxirgi.keyingi ?? undefined,
  });
  // ... ro'yxat va «Yana» tugmasi
}

keyingi ?? undefined ga qarang: server oxirgi sahifada keyingi: null beradi. Query null ni ham "boshqa yo'q" deb tushunadi, lekin TypeScript turi uchun undefined aniqroq.

Chrome 155 da haqiqiy server bilan natija (natijalar/tajriba/16-17-server-ishlaydi.txt, aynan):

text
Chrome: Chrome/155.0.8059.40 | variant: server-cheksiz
menyu: ["Osh — 35000","Lag'mon — 28000","Manti — 30000","Ko'k choy — 5000"] | tugma: Hammasi
so'rovlar: GET ?sahifa=1&hajm=2, GET ?sahifa=2&hajm=2

Ikki sahifa, to'rtta taom, ikkinchi GET'dan keyin tugma "Hammasi" — hasNextPage yolg'on. To'plam hajmi (gzip, React bilan birga): 77 921 B. Oddiy useQuery varianti — 78 333 B. Ya'ni useInfiniteQuery Query ichida, alohida narx yo'q.

7. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
har sahifada "Yuklanmoqda…" va sakrash eski sahifa saqlanmaydi placeholderData: keepPreviousData
oxirgi sahifadan keyin bo'sh sahifa hasMore tekshirilmagan serverdan hasMore / next
«Yana» hech qachon o'chmaydi getNextPageParam undefined qaytarmaydi oxirgi bo'lakda undefined
bir xil element ikki marta offset + o'zgaruvchan ro'yxat kursor
Encountered two children with the same key takror element, key — matn yoki id kursor; vaqtincha — takrorni olib tashlash
bitta bo'lak ikki marta so'raldi qorovul isFetchingNextPage ni tekshirmaydi if (… && !isFetchingNextPage)
oynaga qaytganda 20 ta so'rov cheksiz ro'yxat boshidan qayta yuklanadi staleTime, maxPages
data.map is not a function useInfiniteQuery da data — { pages, pageParams } data.pages.flatMap(…)

Oxirgisi — useQuery dan useInfiniteQuery ga o'tganda eng birinchi uchraydigan xato. Ma'lumot shakli o'zgaradi: endi u bo'laklar massivi ichida.

8. Mashqlar

Mashqlar kurs/mashqlar/19/16-sahifalash/ da.

1-mashq (oson): offset yoki kursor?

Har holat uchun yozing: "offset" yoki "kursor".

  • Admin panel: 50 sahifali buyurtmalar jadvali, "17-sahifaga o'tish" maydoni bilan →
  • Chat: yuqoriga aylantirganda eski xabarlar →
  • Instagram lentasi →
  • Qidiruv natijalari, "1 2 3 … 10" →
Yechim

Ixtiyoriy sahifaga sakrash kerak bo'lsa — offset (admin jadvali, qidiruv natijalari). Ro'yxat doim o'zgaradigan va ketma-ket o'qiladigan joyda — kursor (chat, lenta): yangi element qo'shilganda takror va tushib qolish bo'lmaydi.

2-mashq (o'rta): sakramaydigan sahifalar

Kodda «Keyingi» bosilganda ekran "Yuklanmoqda…" ga sakraydi va oxirgi sahifada ham «Keyingi» ishlayveradi. Tuzating:

  1. placeholderData: keepPreviousData.
  2. Eski sahifa ko'rinayotganda — matn oxirida " (yuklanmoqda…)".
  3. «Keyingi» — isPlaceholderData yoki !data.hasMore da o'chiq.
jsx
import { useState } from "react";
import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

const DRINKS = ["Ko'k choy", "Qora choy", "Kompot", "Ayron"];

function fetchPage(page) {
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve({
          items: DRINKS.slice((page - 1) * 2, page * 2),
          hasMore: page * 2 < DRINKS.length,
        }),
      200,
    ),
  );
}

function Drinks() {
  const [page, setPage] = useState(1);
  const { isPending, data } = useQuery({
    queryKey: ["drinks", page],
    queryFn: () => fetchPage(page),
  });
  if (isPending) return <p>Yuklanmoqda…</p>;
  return (
    <>
      <p>{data.items.join(", ")}</p>
      <button onClick={() => setPage(page + 1)}>Keyingi</button>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Drinks />
    </QueryClientProvider>
  );
}

Ekranda:

text
Kompot, Ayron
Keyingi
Yechim
jsx
import { useState } from "react";
import {
  keepPreviousData,
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

const DRINKS = ["Ko'k choy", "Qora choy", "Kompot", "Ayron"];

function fetchPage(page) {
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve({
          items: DRINKS.slice((page - 1) * 2, page * 2),
          hasMore: page * 2 < DRINKS.length,
        }),
      200,
    ),
  );
}

function Drinks() {
  const [page, setPage] = useState(1);
  const { isPending, data, isPlaceholderData } = useQuery({
    queryKey: ["drinks", page],
    queryFn: () => fetchPage(page),
    placeholderData: keepPreviousData,
  });
  if (isPending) return <p>Yuklanmoqda…</p>;
  return (
    <>
      <p>
        {data.items.join(", ")}
        {isPlaceholderData && " (yuklanmoqda…)"}
      </p>
      <button
        disabled={isPlaceholderData || !data.hasMore}
        onClick={() => setPage(page + 1)}
      >
        Keyingi
      </button>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Drinks />
    </QueryClientProvider>
  );
}

Ekranda:

text
Kompot, Ayron
Keyingi

Oraliq nuqta "Ko'k choy, Qora choy (yuklanmoqda…)" ni tekshirdi — 2-sahifa kelguncha 1-sahifa turdi. Yakunda «Keyingi» o'chiq: hasMore yolg'on (4 ta ichimlikdan 4 tasi ko'rsatildi).

3-mashq (qiyin): kursorli «Yana yuklash»

«Bahor» buyurtmalar tarixi. Server kursor bilan ishlaydi: fetchOrders(cursor) → { items, nextCursor }, oxirgisida nextCursor: null. Yozing:

  1. useInfiniteQuery: initialPageParam: null, getNextPageParam — serverning nextCursor i.
  2. Ekranda: hamma buyurtmalar vergul bilan, ostida «Yana» (yo'lda — "Yuklanmoqda…", tugasa — "Tamom", o'chiq).

Server (boshlang'ich kod):

jsx
const ORDERS = ["#101", "#102", "#103", "#104", "#105"];

function fetchOrders(cursor) {
  console.log("kursor:", cursor);
  const start = cursor === null ? 0 : ORDERS.indexOf(cursor) + 1;
  const items = ORDERS.slice(start, start + 2);
  const nextCursor =
    start + 2 < ORDERS.length ? items.at(-1) : null;
  return new Promise((resolve) =>
    setTimeout(() => resolve({ items, nextCursor }), 200),
  );
}

Ishora: «Kursor» bo'limidagi misol. null ni undefined ga aylantirish shart emas — Query ikkalasini ham "boshqa yo'q" deb tushunadi.

Yechim
jsx
import {
  QueryClient,
  QueryClientProvider,
  useInfiniteQuery,
} from "@tanstack/react-query";

const queryClient = new QueryClient();

const ORDERS = ["#101", "#102", "#103", "#104", "#105"];

function fetchOrders(cursor) {
  console.log("kursor:", cursor);
  const start = cursor === null ? 0 : ORDERS.indexOf(cursor) + 1;
  const items = ORDERS.slice(start, start + 2);
  const nextCursor =
    start + 2 < ORDERS.length ? items.at(-1) : null;
  return new Promise((resolve) =>
    setTimeout(() => resolve({ items, nextCursor }), 200),
  );
}

function History() {
  const query = useInfiniteQuery({
    queryKey: ["history"],
    queryFn: ({ pageParam }) => fetchOrders(pageParam),
    initialPageParam: null,
    getNextPageParam: (last) => last.nextCursor,
  });
  if (query.isPending) return <p>Yuklanmoqda…</p>;
  let label = "Yana";
  if (query.isFetchingNextPage) label = "Yuklanmoqda…";
  else if (!query.hasNextPage) label = "Tamom";
  return (
    <>
      <p>{query.data.pages.flatMap((p) => p.items).join(", ")}</p>
      <button
        disabled={!query.hasNextPage || query.isFetchingNextPage}
        onClick={() => query.fetchNextPage()}
      >
        {label}
      </button>
    </>
  );
}

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <History />
    </QueryClientProvider>
  );
}

Konsolda:

text
kursor: null
kursor: #102
kursor: #104

Ekranda:

text
#101, #102, #103, #104, #105
Tamom

getNextPageParam faqat serverning javobini uzatadi — hisob yo'q. Kursor serverning ishi: u qayerdan davom etishni biladi. Uchinchi bo'lakda bitta buyurtma (#105) va nextCursor: null — hasNextPage yolg'on, tugma "Tamom".

9. Real ishda

  • Admin panellar — raqamli sahifalar + keepPreviousData + sahifa raqami URL'da. Ko'pincha jadval kutubxonasi bilan (TanStack Table kabi).
  • Lentalar va chat — useInfiniteQuery + kursor + IntersectionObserver + virtualizatsiya. Telegram, Instagram, X shunday ishlaydi.
  • API dizayni — backend dasturchi bilan kelishuv: javobda nextCursor (yoki hasMore) bo'lsin. "Jami nechta" (total) ni hisoblash katta jadvalda qimmat — ko'p API uni bermaydi.
  • Intervyu savollari: "offset va kursor farqi, qachon qaysi?", "keepPreviousData nima qiladi?", "useInfiniteQuery ma'lumotni qanday saqlaydi?", "cheksiz aylantirishni qanday qilasiz va a11y muammolari?".

Xulosa

  • Raqamli sahifalar: sahifa raqami — kalitda (va URL'da). placeholderData: keepPreviousData — yangi sahifa kelguncha eskisi ekranda, isPlaceholderData bilan.
  • useInfiniteQuery — hamma bo'laklar bitta kalitda: data.pages, pageParams. initialPageParam, getNextPageParam (undefined — tamom), fetchNextPage, hasNextPage, isFetchingNextPage.
  • Offset — sakrash mumkin, lekin o'zgaruvchan ro'yxatda takror va tushib qolish. Kursor — barqaror, lentalar uchun.
  • IntersectionObserver qorovuli — cheksiz aylantirish; rootMargin, isFetchingNextPage tekshiruvi, cleanup. A11y narxini unutmang.
  • vazifalar da sahifalash yo'q: API bermaydi, qidiruv va statistika butun ro'yxatni talab qiladi.

Keyingi dars: TanStack Query V: prefetch, Suspense, bekor qilish va SWR muqobili — oldindan yuklash, router loader bilan ensureQueryData, useSuspenseQuery, signal, DevTools va SWR bilan taqqos.

Manbalar

  • TanStack Query v5: Paginated Queries — tanstack.com/query/v5/docs/framework/react/guides/paginated-queries (2026-10, v5.104)
  • TanStack Query v5: Infinite Queries, useInfiniteQuery — …/guides/infinite-queries, …/reference/useInfiniteQuery
  • TanStack Query v5: Placeholder Query Data — …/guides/placeholder-query-data
  • MDN: Intersection Observer API — developer.mozilla.org/docs/Web/API/Intersection_Observer_API
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
TanStack Query sahifalash: keepPreviousData, useInfiniteQuery va cheksiz ro'yxat — IlmHamroh