לדלג לתוכן

7.8 שליפת נתונים React Query הרצאה

שליפת נתונים - React Query (TanStack Query)

בשיעור הזה נלמד על TanStack Query (לשעבר React Query) - הספרייה המובילה לניהול שליפת נתונים מהשרת באפליקציות ריאקט.


הבעיה עם useEffect ו-fetch

// הגישה הנאיבית - הרבה בעיות
function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch("/api/users")
      .then((res) => res.json())
      .then((data) => {
        if (!cancelled) setUsers(data);
      })
      .catch((err) => {
        if (!cancelled) setError(err.message);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });
    return () => { cancelled = true; };
  }, []);

  // ...
}

בעיות בגישה הזו:
- צריך לנהל loading, error ו-data ידנית בכל מקום
- אין מטמון (cache) - כל מעבר לדף טוען מחדש
- אין ניהול של מצבי רשת (offline, reconnect)
- אין refetch אוטומטי
- טיפול ב-race conditions (cancelled) מסורבל
- שכפול קוד בכל קומפוננטה שטוענת נתונים


התקנה

npm install @tanstack/react-query

הגדרת ה-Provider:

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 דקות
      retry: 3,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
    </QueryClientProvider>
  );
}

שליפת נתונים - useQuery

שימוש בסיסי

import { useQuery } from "@tanstack/react-query";

interface User {
  id: number;
  name: string;
  email: string;
}

function UserList() {
  const { data, isLoading, isError, error, refetch } = useQuery<User[]>({
    queryKey: ["users"],
    queryFn: async () => {
      const response = await fetch("/api/users");
      if (!response.ok) throw new Error("Failed to fetch users");
      return response.json();
    },
  });

  if (isLoading) return <p>טוען...</p>;
  if (isError) return <p>שגיאה: {error.message}</p>;

  return (
    <div>
      <button onClick={() => refetch()}>רענן</button>
      <ul>
        {data?.map((user) => (
          <li key={user.id}>{user.name} - {user.email}</li>
        ))}
      </ul>
    </div>
  );
}
  • queryKey - מזהה ייחודי לנתונים (משמש גם למטמון)
  • queryFn - הפונקציה שמביאה את הנתונים
  • מוחזרים: data, isLoading, isError, error, refetch ועוד

מפתחות שאילתה - Query Keys

// מפתח פשוט
useQuery({ queryKey: ["users"], queryFn: fetchUsers });

// מפתח עם פרמטרים
useQuery({ queryKey: ["users", userId], queryFn: () => fetchUser(userId) });

// מפתח עם פילטרים
useQuery({
  queryKey: ["users", { role: "admin", page: 1 }],
  queryFn: () => fetchUsers({ role: "admin", page: 1 }),
});

// מפתח מקונן
useQuery({
  queryKey: ["users", userId, "posts"],
  queryFn: () => fetchUserPosts(userId),
});
  • המפתח חייב להיות ייחודי לנתונים
  • שינוי במפתח גורם לשליפה מחדש
  • המפתח הוא מערך - כל שינוי באחד האלמנטים מפעיל refetch

אפשרויות נפוצות

useQuery({
  queryKey: ["users"],
  queryFn: fetchUsers,
  staleTime: 5 * 60 * 1000, // נתונים "טריים" למשך 5 דקות
  gcTime: 30 * 60 * 1000,   // נתונים נשמרים במטמון 30 דקות
  refetchOnWindowFocus: true, // רענון כשחוזרים לחלון
  refetchInterval: 60000,     // רענון כל דקה
  enabled: !!userId,           // לא לשלוף עד שיש userId
  retry: 3,                    // נסיונות חוזרים בכישלון
  select: (data) => data.filter((u) => u.active), // טרנספורמציה
});

מוטציות - useMutation

useMutation משמש לפעולות שמשנות נתונים בשרת (POST, PUT, DELETE):

import { useMutation, useQueryClient } from "@tanstack/react-query";

function CreateUserForm() {
  const queryClient = useQueryClient();
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");

  const createUser = useMutation({
    mutationFn: async (newUser: { name: string; email: string }) => {
      const response = await fetch("/api/users", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(newUser),
      });
      if (!response.ok) throw new Error("Failed to create user");
      return response.json();
    },
    onSuccess: () => {
      // ביטול תוקף המטמון - יגרום לשליפה מחדש
      queryClient.invalidateQueries({ queryKey: ["users"] });
      setName("");
      setEmail("");
    },
    onError: (error) => {
      alert(`שגיאה: ${error.message}`);
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    createUser.mutate({ name, email });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} placeholder="שם" />
      <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="אימייל" />
      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? "יוצר..." : "צור משתמש"}
      </button>
    </form>
  );
}

עדכון ומחיקה

function UserItem({ user }: { user: User }) {
  const queryClient = useQueryClient();

  const updateUser = useMutation({
    mutationFn: async (updates: Partial<User>) => {
      const response = await fetch(`/api/users/${user.id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(updates),
      });
      return response.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });

  const deleteUser = useMutation({
    mutationFn: async () => {
      await fetch(`/api/users/${user.id}`, { method: "DELETE" });
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["users"] });
    },
  });

  return (
    <div>
      <span>{user.name}</span>
      <button onClick={() => updateUser.mutate({ name: "שם חדש" })}>
        ערוך
      </button>
      <button onClick={() => deleteUser.mutate()}>מחק</button>
    </div>
  );
}

ביטול תוקף ושליפה מחדש - Invalidation

const queryClient = useQueryClient();

// ביטול תוקף של שאילתה ספציפית
queryClient.invalidateQueries({ queryKey: ["users"] });

// ביטול תוקף של כל השאילתות שמתחילות ב-"users"
queryClient.invalidateQueries({ queryKey: ["users"], exact: false });

// ביטול תוקף של הכל
queryClient.invalidateQueries();

// עדכון ישיר של המטמון
queryClient.setQueryData(["users"], (oldData: User[]) => [
  ...oldData,
  newUser,
]);

עדכונים אופטימיסטיים - Optimistic Updates

עדכון ה-UI לפני שהשרת מאשר - מספק תחושת מהירות:

const toggleTodo = useMutation({
  mutationFn: async (todoId: number) => {
    const response = await fetch(`/api/todos/${todoId}/toggle`, {
      method: "PATCH",
    });
    return response.json();
  },

  onMutate: async (todoId) => {
    // ביטול שאילתות פעילות כדי שלא ידרסו את העדכון שלנו
    await queryClient.cancelQueries({ queryKey: ["todos"] });

    // שמירת ה-state הקודם
    const previousTodos = queryClient.getQueryData(["todos"]);

    // עדכון אופטימיסטי
    queryClient.setQueryData(["todos"], (old: Todo[]) =>
      old.map((t) => (t.id === todoId ? { ...t, done: !t.done } : t))
    );

    return { previousTodos };
  },

  onError: (_err, _todoId, context) => {
    // שחזור ה-state הקודם בשגיאה
    queryClient.setQueryData(["todos"], context?.previousTodos);
  },

  onSettled: () => {
    // רענון מהשרת בכל מקרה
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});

דפדוף - Pagination

function PaginatedUsers() {
  const [page, setPage] = useState(1);

  const { data, isLoading, isPreviousData } = useQuery({
    queryKey: ["users", page],
    queryFn: () => fetchUsers(page),
    placeholderData: (previousData) => previousData, // שמירת נתונים קודמים בזמן טעינה
  });

  return (
    <div>
      {isLoading ? (
        <p>טוען...</p>
      ) : (
        <ul style={{ opacity: isPreviousData ? 0.5 : 1 }}>
          {data?.users.map((user: User) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
      <div>
        <button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
          הקודם
        </button>
        <span>עמוד {page}</span>
        <button
          onClick={() => setPage((p) => p + 1)}
          disabled={!data?.hasMore}
        >
          הבא
        </button>
      </div>
    </div>
  );
}

גלילה אינסופית - useInfiniteQuery

import { useInfiniteQuery } from "@tanstack/react-query";

function InfiniteUserList() {
  const {
    data,
    isLoading,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ["users", "infinite"],
    queryFn: async ({ pageParam }) => {
      const response = await fetch(`/api/users?page=${pageParam}&limit=20`);
      return response.json();
    },
    initialPageParam: 1,
    getNextPageParam: (lastPage, allPages) => {
      return lastPage.hasMore ? allPages.length + 1 : undefined;
    },
  });

  if (isLoading) return <p>טוען...</p>;

  const allUsers = data?.pages.flatMap((page) => page.users) ?? [];

  return (
    <div>
      <ul>
        {allUsers.map((user: User) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? "טוען..." : "טען עוד"}
        </button>
      )}
    </div>
  );
}
  • getNextPageParam מחזיר את הפרמטר לעמוד הבא, או undefined אם אין עוד
  • data.pages הוא מערך של כל העמודים שנטענו
  • fetchNextPage טוען את העמוד הבא

אסטרטגיות מטמון - Caching Strategies

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // נתונים "טריים" למשך 5 דקות - לא ישלפו מחדש
      staleTime: 5 * 60 * 1000,

      // נתונים נשמרים במטמון 30 דקות אחרי שאין צורכים
      gcTime: 30 * 60 * 1000,

      // רענון כשחוזרים לחלון
      refetchOnWindowFocus: true,

      // לא לרענן כש-component עולה מחדש אם הנתונים טריים
      refetchOnMount: "always",
    },
  },
});
  • staleTime: כמה זמן נתונים נחשבים "טריים" (לא צריך refetch)
  • gcTime: כמה זמן לשמור נתונים במטמון אחרי שאין component שמשתמש בהם
  • כשנתונים stale, הם עדיין מוצגים מהמטמון בזמן ש-refetch רץ ברקע

DevTools

npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

סיכום

  • TanStack Query מנהל שליפת נתונים מהשרת עם מטמון, retry, ו-background refetch
  • useQuery לשליפת נתונים (GET), useMutation לשינויים (POST/PUT/DELETE)
  • Query Keys מזהים נתונים ייחודית ומפעילים refetch כשמשתנים
  • invalidateQueries מאפשר לרענן נתונים אחרי mutation
  • עדכונים אופטימיסטיים מספקים UX מהיר עם אפשרות חזרה בשגיאה
  • תמיכה מובנית בדפדוף וגלילה אינסופית
  • מטמון חכם עם staleTime ו-gcTime
  • DevTools לדיבאג של שאילתות ומטמון