Triggered · فصل ٣

Scroll Triggered و Infinite Scroll — بيحصل لما يدخل

IntersectionObserver · root · rootMargin · threshold · sentinel · useInfiniteQuery
🎯 الهدف: تستخدم IntersectionObserver تظهّر عناصر وهي داخلة، وتعمل infinite scroll بـ sentinel، وتعرف عيوبه.

IntersectionObserver في سطرين

بدل ما تسأل كل شوية “العنصر ده ظاهر؟”، بتقول للمتصفح “قولّي لما يظهر”:

observe.ts
const io = new IntersectionObserver(  (entries) => {    for (const entry of entries) {      if (entry.isIntersecting) {        // العنصر دخل      }    }  },  {    root: null,              // null = الشاشة. أو عنصر scroller معيّن    rootMargin: "0px 0px -10% 0px", // كبّر أو صغّر حدود الـ root    threshold: 0.25,         // يتنده لما ربع العنصر يبان  },);io.observe(element);
threshold و rootMargin
  1. العنصر ← حد الـ root: scroll لحد ما يعدّي
  2. حد الـ root ← الـ callback: عدّى الـ threshold

Scroll Triggered: يظهر لما يدخل

الكروت بتظهر مرة واحدة أول ما تدخل

انزل… الكروت بتظهر أول ما تدخل

كارت 1
كارت 2
كارت 3
كارت 4
كارت 5
كارت 6
reveal.ts
const io = new IntersectionObserver(  (entries) => {    for (const e of entries) {      if (!e.isIntersecting) continue;      e.target.classList.add("is-in");      io.unobserve(e.target); // مرة واحدة: مش هيختفي لو رجعت لفوق    }  },  { threshold: 0.35 },);document.querySelectorAll(".reveal").forEach((el) => io.observe(el));
reveal.css
.reveal { opacity: 0; transform: translateY(18px); transition: opacity .5s, transform .5s; }.reveal.is-in { opacity: 1; transform: none; }@media (prefers-reduced-motion: reduce) {  .reveal { opacity: 1; transform: none; transition: none; }}

Infinite Scroll: هات أكتر قرب الآخر

انزل لتحت… بيجيب بوستات جديدة لوحده
بوست 1
بوست 2
بوست 3
بوست 4
بوست 5
بوست 6
بنحمّل…

الفكرة: عنصر صغير مخفي في آخر اللستة اسمه sentinel (الحارس). أول ما يقرب من الشاشة، بنجيب الصفحة اللي بعدها.

InfiniteList.tsx
import { useEffect, useRef } from "react";import { useInfiniteQuery } from "@tanstack/react-query";export function InfiniteList() {  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({    queryKey: ["posts"],    queryFn: ({ pageParam }) => fetch(`/api/posts?cursor=${pageParam}`).then((r) => r.json()),    initialPageParam: 0,    getNextPageParam: (last) => last.nextCursor ?? undefined,  });  const sentinel = useRef<HTMLDivElement>(null);  useEffect(() => {    const el = sentinel.current;    if (!el) return;    const io = new IntersectionObserver(      ([e]) => {        if (e?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();      },      { rootMargin: "0px 0px 400px 0px" }, // ابدأ تجيب قبل الآخر بـ 400px    );    io.observe(el);    return () => io.disconnect();  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);  return (    <>      {data?.pages.flatMap((p) => p.items).map((post) => <article key={post.id}>{post.title}</article>)}      <div ref={sentinel} aria-hidden="true" />      {isFetchingNextPage && <p role="status">بنحمّل…</p>}    </>  );}

rootMargin الموجب من تحت بيخلّي الطلب يبدأ قبل ما المستخدم يوصل للآخر، فميشوفش loading خالص لو النت كويس. ولو عايز تفاصيل useInfiniteQuery، في فصل كامل عنه في مذكرة TanStack Query.

عيوب الـ infinite scroll

الـ footer مبيتوصلّوشزرار back بيرجعك لأول اللستةصعب على قارئ الشاشة يعرف إن في جديدالـ DOM بيكبر لحد ما الصفحة تتقل
المشكلةالحل
الـ footer مستخبيزرار “حمّل أكتر” بدل التحميل التلقائي، أو حط اللينكات المهمة فوق
back بيضيّع المكانخزّن الصفحات في الكاش (TanStack Query بيعمل ده) و scrollRestoration
قارئ الشاشةrole="status" للـ loading، وأعلن عدد العناصر الجديدة
الـ DOM التقيلvirtualization (@tanstack/react-virtual) بعد كام مية عنصر

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

🧠 اختبر نفسك
١) ليه unobserve في الـ reveal؟
عشان الـ animation تحصل مرة. من غيره ممكن تتكرر أو تشغّل كود من غير لازمة.
٢) rootMargin بـ 400px من تحت بيعمل إيه؟
بيعتبر العنصر ظاهر قبل ما يوصل للشاشة بـ 400px، فالتحميل يبدأ بدري.
٣) امتى زرار حمّل أكتر أحسن من التلقائي؟
لما في footer مهم، أو المستخدم محتاج يحس بالتحكم، أو قارئ الشاشة.