الصفحات · فصل ٧

الصفحات اللي بتتلم

useInfiniteQuery · cursor · IntersectionObserver
🎯 الهدف: تزود صفحة من غير ما تمسح اللي فات، وتختار cursor لما القائمة بتتغير.

في التصفّح العادي بتجيب صفحة وترمي اللي قبلها. useInfiniteQuery يجمع الصفحات في مصفوفة مستمرة. «اعرض المزيد» يضيف، ومش بينسى.

ده اللي بيحصل وأنت بتنزل في خط زمني: القديم فضل، والجديد تلزق تحته.

السيرفر يقول فيه تالي ولا لأ

server-page.js
app.get("/api/students", async (req, res) => {  const page = parseInt(req.query.page) || 1;  const limit = 20;  const students = await prisma.student.findMany({    skip: (page - 1) * limit,    take: limit,  });  const total = await prisma.student.count();  const hasMore = page * limit < total;  res.json({    students,    nextPage: hasMore ? page + 1 : null,  });});

مهم إن الرد فيه إشارة للجاي. null هنا معناها مفيش صفحة بعد كده. راجع أسماء دوال Prisma في الدوكس، لأنها بتتغير.

الواجهة

StudentsInfinite.jsx
import { useInfiniteQuery } from "@tanstack/react-query";function getStudents({ pageParam }) {  return fetch(`/api/students?page=${pageParam}`).then((res) => res.json());}function StudentsList() {  const {    data,    fetchNextPage,    hasNextPage,    isFetchingNextPage,    isPending,  } = useInfiniteQuery({    queryKey: ["students"],    queryFn: getStudents,    initialPageParam: 1,    getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,  });  if (isPending) return <p>جاري التحميل...</p>;  return (    <div>      {data.pages.map((page, i) => (        <div key={i}>          {page.students.map((s) => (            <div key={s.id}>{s.name}</div>          ))}        </div>      ))}      <button        onClick={() => fetchNextPage()}        disabled={!hasNextPage || isFetchingNextPage}      >        {isFetchingNextPage          ? "بيحمّل..."          : hasNextPage            ? "عرض المزيد"            : "مفيش أكتر"}      </button>    </div>  );}

getNextPageParam بتتنفذ بعد كل صفحة. آخر صفحة هي اللي السيرفر رجّعها دلوقتي. المعامل التاني، لو احتجته، كل الصفحات لحد دلوقتي. لو رجّعت undefined، المكتبة تقفل hasNextPage.

fetchNextPage تاخد القيمة دي وتبعتها لوحدها.

data مش مصفوفة طلاب. فيها pages للردود، و pageParams للأرقام اللي اتبعتت. عشان كده فيه لف على الصفحات، وبعدين لف على طلاب كل صفحة.

isFetchingNextPage وقت الصفحة الزيادة بس. isPending لأول تحميل، قبل أي صفحة.

السكرول ينادي لوحده

حط عنصر في آخر القائمة. لما يظهر في الشاشة، نادِ الصفحة الجاية.

infinite-scroll.jsx
import { useEffect, useRef } from "react";function StudentsList() {  const {    data,    fetchNextPage,    hasNextPage,    isFetchingNextPage,    isPending,  } = useInfiniteQuery({    queryKey: ["students"],    queryFn: getStudents,    initialPageParam: 1,    getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,  });  const observerRef = useRef(null);  useEffect(() => {    if (!observerRef.current || !hasNextPage) return;    const observer = new IntersectionObserver(      (entries) => {        if (entries[0].isIntersecting && !isFetchingNextPage) {          fetchNextPage();        }      },      { threshold: 1 },    );    observer.observe(observerRef.current);    return () => observer.disconnect();  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);  if (isPending) return <p>جاري التحميل...</p>;  return (    <div>      {data.pages.map((page, i) => (        <div key={i}>          {page.students.map((s) => (            <div key={s.id}>{s.name}</div>          ))}        </div>      ))}      <div ref={observerRef} style={{ height: 1 }} />      {isFetchingNextPage && <p>بيحمّل المزيد...</p>}    </div>  );}

لورا كمان

الشات اللي بيطلّع رسائل أقدم محتاج اتجاهين. getPreviousPageParam على أول صفحة، وبعدين fetchPreviousPage و hasPreviousPage.

both-directions.jsx
useInfiniteQuery({  queryKey: ["messages"],  queryFn: getMessages,  initialPageParam: 0,  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,  getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,});

رقم الصفحة يتلخبط

لو حد ضاف طالب وأنت في نص السكرول، تخطي الصفحات بالأرقام يزوّد أو يكرّر صفوف. المؤشر أثبت: كمّل بعد آخر id شفته.

server-cursor.js
app.get("/api/students", async (req, res) => {  const cursor = req.query.cursor;  const students = await prisma.student.findMany({    take: 20,    skip: cursor ? 1 : 0,    cursor: cursor ? { id: cursor } : undefined,    orderBy: { id: "asc" },  });  res.json({    students,    nextCursor: students.length === 20 ? students.at(-1).id : null,  });});
client-cursor.jsx
useInfiniteQuery({  queryKey: ["students"],  queryFn: ({ pageParam }) =>    fetch(`/api/students?cursor=${pageParam ?? ""}`).then((r) => r.json()),  initialPageParam: null,  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,});

لقوائم الطلاب والدرجات الكبيرة، المؤشر أهدى من رقم الصفحة. skip: 1 عشان المؤشر نفسه ما يترجعش تاني مع الصفحة الجاية. راجع شكل المؤشر في دوكس Prisma قبل ما تثبّته.

خلّصت الفصل لما

🧠 اختبر نفسك
١) getNextPageParam رجّعت undefined. الزرار يعمل إيه؟
hasNextPage يبقى false، ومفيش صفحة تانية.
٢) شكل data في الاستعلام اللانهائي؟
كائن فيه pages للردود و pageParams للقيمة اللي اتبعتت كل مرة.
٣) ليه المؤشر أثبت من رقم الصفحة والناس بتضيف طلاب؟
لأنك بتكمل بعد آخر id، مش بتحسب إزاحة ممكن تتزحلق لما الصف يتغير.