לדלג לתוכן

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

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

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


הבעיה עם useEffect ו-fetch

// The naive approach - lots of problems
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 minutes
      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 - מזהה ייחודי לנתונים (משמש גם לcache)
  • queryFn - הפונקציה שמביאה את הנתונים
  • מוחזרים: data, isLoading, isError, error, refetch ועוד

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

// Simple key
useQuery({ queryKey: ["users"], queryFn: fetchUsers });

// Key with parameters
useQuery({ queryKey: ["users", userId], queryFn: () => fetchUser(userId) });

// Key with filters
useQuery({
  queryKey: ["users", { role: "admin", page: 1 }],
  queryFn: () => fetchUsers({ role: "admin", page: 1 }),
});

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

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

useQuery({
  queryKey: ["users"],
  queryFn: fetchUsers,
  staleTime: 5 * 60 * 1000, // data is "fresh" for 5 minutes
  gcTime: 30 * 60 * 1000,   // data stays in the cache for 30 minutes
  refetchOnWindowFocus: true, // refetch when the window regains focus
  refetchInterval: 60000,     // refetch every minute
  enabled: !!userId,           // don't fetch until there's a userId
  retry: 3,                    // retry attempts on failure
  select: (data) => data.filter((u) => u.active), // transform the data
});

מוטציות - 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: () => {
      // invalidate the cache - triggers a refetch
      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();

// invalidate a specific query
queryClient.invalidateQueries({ queryKey: ["users"] });

// invalidate all queries starting with "users"
queryClient.invalidateQueries({ queryKey: ["users"], exact: false });

// invalidate everything
queryClient.invalidateQueries();

// update the cache directly
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) => {
    // cancel active queries so they don't overwrite our update
    await queryClient.cancelQueries({ queryKey: ["todos"] });

    // save the previous state
    const previousTodos = queryClient.getQueryData(["todos"]);

    // optimistic update
    queryClient.setQueryData(["todos"], (old: Todo[]) =>
      old.map((t) => (t.id === todoId ? { ...t, done: !t.done } : t))
    );

    return { previousTodos };
  },

  onError: (_err, _todoId, context) => {
    // restore the previous state on error
    queryClient.setQueryData(["todos"], context?.previousTodos);
  },

  onSettled: () => {
    // refetch from the server either way
    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, // keep previous data while loading
  });

  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 טוען את העמוד הבא

אסטרטגיות cache - Caching Strategies

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // data is "fresh" for 5 minutes - won't be refetched
      staleTime: 5 * 60 * 1000,

      // data stays in the cache for 30 minutes after no one uses it
      gcTime: 30 * 60 * 1000,

      // refetch when the window regains focus
      refetchOnWindowFocus: true,

      // don't refetch when the component remounts if the data is fresh
      refetchOnMount: "always",
    },
  },
});
  • staleTime: כמה זמן נתונים נחשבים "טריים" (לא צריך refetch)
  • gcTime: כמה זמן לשמור נתונים בcache אחרי שאין component שמשתמש בהם
  • כשנתונים stale, הם עדיין מוצגים מהcache בזמן ש-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 מנהל שליפת נתונים מהשרת עם cache, retry, ו-background refetch
  • useQuery לשליפת נתונים (GET), useMutation לשינויים (POST/PUT/DELETE)
  • Query Keys מזהים נתונים ייחודית ומפעילים refetch כשמשתנים
  • invalidateQueries מאפשר לרענן נתונים אחרי mutation
  • עדכונים אופטימיסטיים מספקים UX מהיר עם אפשרות חזרה בשגיאה
  • תמיכה מובנית בדפדוף וגלילה אינסופית
  • הcache החכם עם staleTime ו-gcTime
  • DevTools לדיבאג של שאילתות וcache