לדלג לתוכן

7.8 שליפת נתונים React Query פתרון

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


פתרון תרגיל 1 - שליפת נתונים בסיסית

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

const queryClient = new QueryClient();

interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

interface Comment {
  id: number;
  postId: number;
  name: string;
  email: string;
  body: string;
}

function PostList() {
  const [selectedPostId, setSelectedPostId] = useState<number | null>(null);

  const { data: posts, isLoading, isError, error, refetch } = useQuery<Post[]>({
    queryKey: ["posts"],
    queryFn: async () => {
      const response = await fetch("https://jsonplaceholder.typicode.com/posts");
      if (!response.ok) throw new Error("Failed to fetch posts");
      return response.json();
    },
  });

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

  return (
    <div style={{ display: "flex", gap: "20px" }}>
      <div style={{ flex: 1 }}>
        <div style={{ display: "flex", justifyContent: "space-between" }}>
          <h2>פוסטים ({posts?.length})</h2>
          <button onClick={() => refetch()}>רענן</button>
        </div>
        <ul style={{ listStyle: "none", padding: 0 }}>
          {posts?.slice(0, 20).map((post) => (
            <li
              key={post.id}
              onClick={() => setSelectedPostId(post.id)}
              style={{
                padding: "12px",
                cursor: "pointer",
                backgroundColor: selectedPostId === post.id ? "#e3f2fd" : "transparent",
                borderBottom: "1px solid #eee",
              }}
            >
              <strong>{post.title}</strong>
            </li>
          ))}
        </ul>
      </div>
      <div style={{ flex: 1 }}>
        {selectedPostId && <PostDetail postId={selectedPostId} />}
      </div>
    </div>
  );
}

function PostDetail({ postId }: { postId: number }) {
  const { data: post, isLoading: postLoading } = useQuery<Post>({
    queryKey: ["posts", postId],
    queryFn: async () => {
      const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`);
      return response.json();
    },
  });

  const { data: comments, isLoading: commentsLoading } = useQuery<Comment[]>({
    queryKey: ["posts", postId, "comments"],
    queryFn: async () => {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/posts/${postId}/comments`
      );
      return response.json();
    },
  });

  if (postLoading) return <p>טוען פוסט...</p>;

  return (
    <div>
      <h2>{post?.title}</h2>
      <p>{post?.body}</p>
      <h3>תגובות</h3>
      {commentsLoading ? (
        <p>טוען תגובות...</p>
      ) : (
        <ul>
          {comments?.map((comment) => (
            <li key={comment.id} style={{ marginBottom: "12px" }}>
              <strong>{comment.name}</strong>
              <p>{comment.body}</p>
              <small>{comment.email}</small>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

הסבר:
- שתי שאילתות נפרדות עבור פרטי הפוסט והתגובות
- Query keys כוללים את ה-postId, כך שכל פוסט מטמון בנפרד
- מעבר בין פוסטים מציג נתונים ממטמון מיד (אם כבר נטענו)


פתרון תרגיל 2 - CRUD מלא עם Mutations

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

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

// API מדומה
let mockTodos: Todo[] = [
  { id: 1, text: "לכתוב קוד", completed: false },
  { id: 2, text: "לקרוא תיעוד", completed: true },
];

const api = {
  getTodos: async (): Promise<Todo[]> => {
    await new Promise((r) => setTimeout(r, 500));
    return [...mockTodos];
  },
  createTodo: async (text: string): Promise<Todo> => {
    await new Promise((r) => setTimeout(r, 500));
    const todo = { id: Date.now(), text, completed: false };
    mockTodos.push(todo);
    return todo;
  },
  updateTodo: async (id: number, updates: Partial<Todo>): Promise<Todo> => {
    await new Promise((r) => setTimeout(r, 500));
    const index = mockTodos.findIndex((t) => t.id === id);
    mockTodos[index] = { ...mockTodos[index], ...updates };
    return mockTodos[index];
  },
  deleteTodo: async (id: number): Promise<void> => {
    await new Promise((r) => setTimeout(r, 500));
    mockTodos = mockTodos.filter((t) => t.id !== id);
  },
};

function TodoApp() {
  const queryClient = useQueryClient();
  const [newTodoText, setNewTodoText] = useState("");
  const [editingId, setEditingId] = useState<number | null>(null);
  const [editText, setEditText] = useState("");

  const { data: todos, isLoading } = useQuery({
    queryKey: ["todos"],
    queryFn: api.getTodos,
  });

  const createMutation = useMutation({
    mutationFn: api.createTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
      setNewTodoText("");
    },
  });

  const updateMutation = useMutation({
    mutationFn: ({ id, updates }: { id: number; updates: Partial<Todo> }) =>
      api.updateTodo(id, updates),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
      setEditingId(null);
    },
  });

  const deleteMutation = useMutation({
    mutationFn: api.deleteTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

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

  return (
    <div>
      <h2>משימות</h2>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (newTodoText.trim()) createMutation.mutate(newTodoText);
        }}
      >
        <input
          value={newTodoText}
          onChange={(e) => setNewTodoText(e.target.value)}
          placeholder="משימה חדשה..."
        />
        <button type="submit" disabled={createMutation.isPending}>
          {createMutation.isPending ? "מוסיף..." : "הוסף"}
        </button>
      </form>

      <ul>
        {todos?.map((todo) => (
          <li key={todo.id} style={{ padding: "8px 0" }}>
            {editingId === todo.id ? (
              <div>
                <input value={editText} onChange={(e) => setEditText(e.target.value)} />
                <button
                  onClick={() =>
                    updateMutation.mutate({ id: todo.id, updates: { text: editText } })
                  }
                  disabled={updateMutation.isPending}
                >
                  שמור
                </button>
                <button onClick={() => setEditingId(null)}>בטל</button>
              </div>
            ) : (
              <div>
                <span
                  onClick={() =>
                    updateMutation.mutate({
                      id: todo.id,
                      updates: { completed: !todo.completed },
                    })
                  }
                  style={{
                    textDecoration: todo.completed ? "line-through" : "none",
                    cursor: "pointer",
                  }}
                >
                  {todo.text}
                </span>
                <button
                  onClick={() => {
                    setEditingId(todo.id);
                    setEditText(todo.text);
                  }}
                >
                  ערוך
                </button>
                <button
                  onClick={() => deleteMutation.mutate(todo.id)}
                  disabled={deleteMutation.isPending}
                >
                  מחק
                </button>
              </div>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

הסבר:
- API מדומה עם setTimeout לסימולציה של latency
- כל mutation מבטל את תוקף המטמון (invalidateQueries) אחרי הצלחה
- isPending מאפשר להציג מצב loading לכל פעולה בנפרד


פתרון תרגיל 3 - עדכונים אופטימיסטיים

const toggleMutation = useMutation({
  mutationFn: async (todoId: number) => {
    await new Promise((r) => setTimeout(r, 2000)); // השהייה של 2 שניות
    // שגיאה אקראית - 1 מתוך 5
    if (Math.random() < 0.2) throw new Error("שגיאת שרת!");
    return api.updateTodo(todoId, {
      completed: !mockTodos.find((t) => t.id === todoId)?.completed,
    });
  },

  onMutate: async (todoId) => {
    await queryClient.cancelQueries({ queryKey: ["todos"] });
    const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);

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

    return { previousTodos };
  },

  onError: (_err, _todoId, context) => {
    queryClient.setQueryData(["todos"], context?.previousTodos);
    // הצגת הודעת שגיאה
    alert("שגיאה! המצב חזר לקדמותו");
  },

  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ["todos"] });
  },
});

הסבר:
- onMutate שומר snapshot, מעדכן את המטמון מיד
- onError משחזר את ה-snapshot אם השרת מחזיר שגיאה
- onSettled מרענן מהשרת בכל מקרה (הצלחה או כישלון)
- ההשהייה של 2 שניות מדגימה שה-UI מתעדכן מיד


פתרון תרגיל 4 - דפדוף

import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";

const ITEMS_PER_PAGE = 10;

async function fetchUsers(page: number) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users?_page=${page}&_limit=${ITEMS_PER_PAGE}`
  );
  const total = Number(response.headers.get("x-total-count")) || 10;
  const users = await response.json();
  return {
    users,
    totalPages: Math.ceil(total / ITEMS_PER_PAGE),
    currentPage: page,
  };
}

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

  const { data, isLoading, isPlaceholderData } = useQuery({
    queryKey: ["users", "paginated", page],
    queryFn: () => fetchUsers(page),
    placeholderData: (prev) => prev,
  });

  // Prefetch העמוד הבא
  useEffect(() => {
    if (data && page < data.totalPages) {
      queryClient.prefetchQuery({
        queryKey: ["users", "paginated", page + 1],
        queryFn: () => fetchUsers(page + 1),
      });
    }
  }, [page, data, queryClient]);

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

  return (
    <div>
      <h2>משתמשים</h2>
      <ul style={{ opacity: isPlaceholderData ? 0.5 : 1, transition: "opacity 0.2s" }}>
        {data?.users.map((user: any) => (
          <li key={user.id} style={{ padding: "8px 0" }}>
            <strong>{user.name}</strong> - {user.email}
          </li>
        ))}
      </ul>
      <div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
        <button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
          הקודם
        </button>
        <span>
          עמוד {page} מתוך {data?.totalPages}
        </span>
        <button
          onClick={() => setPage((p) => p + 1)}
          disabled={page >= (data?.totalPages ?? 1)}
        >
          הבא
        </button>
      </div>
    </div>
  );
}

הסبר:
- placeholderData שומר את הנתונים הקודמים בזמן טעינת עמוד חדש
- isPlaceholderData אומר אם מוצגים נתונים ישנים (בזמן טעינה)
- prefetchQuery טוען את העמוד הבא ברקע כשהמשתמש בעמוד הנוכחי


פתרון תרגיל 5 - גלילה אינסופית

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

interface Product {
  id: number;
  name: string;
  price: number;
}

async function fetchProducts(page: number) {
  await new Promise((r) => setTimeout(r, 800));
  const products = Array.from({ length: 20 }, (_, i) => ({
    id: (page - 1) * 20 + i + 1,
    name: `מוצר ${(page - 1) * 20 + i + 1}`,
    price: Math.floor(Math.random() * 500) + 50,
  }));
  return {
    products,
    nextPage: page < 10 ? page + 1 : undefined,
    total: 200,
  };
}

function InfiniteProducts() {
  const loadMoreRef = useRef<HTMLDivElement>(null);

  const {
    data,
    isLoading,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ["products", "infinite"],
    queryFn: ({ pageParam }) => fetchProducts(pageParam),
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });

  const handleIntersection = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
        fetchNextPage();
      }
    },
    [fetchNextPage, hasNextPage, isFetchingNextPage]
  );

  useEffect(() => {
    const observer = new IntersectionObserver(handleIntersection, {
      threshold: 0.5,
    });
    if (loadMoreRef.current) observer.observe(loadMoreRef.current);
    return () => observer.disconnect();
  }, [handleIntersection]);

  const scrollToTop = () => {
    window.scrollTo({ top: 0, behavior: "smooth" });
  };

  if (isLoading) return <p>טוען מוצרים...</p>;

  const allProducts = data?.pages.flatMap((p) => p.products) ?? [];
  const total = data?.pages[0]?.total ?? 0;

  return (
    <div>
      <h2>מוצרים ({allProducts.length} מתוך {total})</h2>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "16px" }}>
        {allProducts.map((product) => (
          <div key={product.id} style={{ border: "1px solid #ddd", padding: "16px" }}>
            <h3>{product.name}</h3>
            <p>{product.price} ש"ח</p>
          </div>
        ))}
      </div>

      <div ref={loadMoreRef} style={{ padding: "20px", textAlign: "center" }}>
        {isFetchingNextPage && <p>טוען עוד...</p>}
        {!hasNextPage && allProducts.length > 0 && <p>הגעת לסוף הרשימה</p>}
      </div>

      {allProducts.length > 20 && (
        <button
          onClick={scrollToTop}
          style={{
            position: "fixed",
            bottom: "20px",
            left: "20px",
            padding: "12px",
            borderRadius: "50%",
          }}
        >
          למעלה
        </button>
      )}
    </div>
  );
}

הסبר:
- Intersection Observer מזהה כשהמשתמש מגיע לתחתית ומפעיל fetchNextPage
- isFetchingNextPage מונע טעינה כפולה
- hasNextPage מבוסס על getNextPageParam שמחזיר undefined כשאין עוד


פתרון תרגיל 6 - אפליקציה מלאה עם React Query + Zustand

// store.ts - Zustand ל-UI state
import { create } from "zustand";
import { persist } from "zustand/middleware";

interface UIStore {
  theme: "light" | "dark";
  sidebarOpen: boolean;
  draftPost: { title: string; content: string; category: string } | null;
  toggleTheme: () => void;
  toggleSidebar: () => void;
  saveDraft: (draft: { title: string; content: string; category: string }) => void;
  clearDraft: () => void;
}

export const useUIStore = create<UIStore>()(
  persist(
    (set) => ({
      theme: "light",
      sidebarOpen: true,
      draftPost: null,
      toggleTheme: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
      toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
      saveDraft: (draft) => set({ draftPost: draft }),
      clearDraft: () => set({ draftPost: null }),
    }),
    { name: "ui-store" }
  )
);

// hooks.ts - React Query hooks
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

interface Post {
  id: number;
  title: string;
  content: string;
  category: string;
}

// API מדומה
let posts: Post[] = [
  { id: 1, title: "פוסט ראשון", content: "תוכן...", category: "tech" },
  { id: 2, title: "פוסט שני", content: "תוכן...", category: "design" },
];

const api = {
  getPosts: async (params?: { search?: string; category?: string }) => {
    await new Promise((r) => setTimeout(r, 500));
    let result = [...posts];
    if (params?.search) {
      result = result.filter((p) => p.title.includes(params.search!));
    }
    if (params?.category && params.category !== "all") {
      result = result.filter((p) => p.category === params.category);
    }
    return result;
  },
  getPost: async (id: number) => {
    await new Promise((r) => setTimeout(r, 300));
    return posts.find((p) => p.id === id);
  },
  createPost: async (post: Omit<Post, "id">) => {
    await new Promise((r) => setTimeout(r, 500));
    const newPost = { ...post, id: Date.now() };
    posts.push(newPost);
    return newPost;
  },
  deletePost: async (id: number) => {
    await new Promise((r) => setTimeout(r, 500));
    posts = posts.filter((p) => p.id !== id);
  },
};

export function usePosts(search?: string, category?: string) {
  return useQuery({
    queryKey: ["posts", { search, category }],
    queryFn: () => api.getPosts({ search, category }),
    staleTime: 5 * 60 * 1000,
  });
}

export function usePost(id: number) {
  return useQuery({
    queryKey: ["posts", id],
    queryFn: () => api.getPost(id),
  });
}

export function useCreatePost() {
  const queryClient = useQueryClient();
  const clearDraft = useUIStore((s) => s.clearDraft);

  return useMutation({
    mutationFn: api.createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
      clearDraft();
    },
  });
}

export function useDeletePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: api.deletePost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });
}

// שימוש
function BlogApp() {
  const [search, setSearch] = useState("");
  const [category, setCategory] = useState("all");
  const [debouncedSearch, setDebouncedSearch] = useState("");
  const theme = useUIStore((s) => s.theme);

  // Debounce
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedSearch(search), 300);
    return () => clearTimeout(timer);
  }, [search]);

  const { data: postsList, isLoading } = usePosts(debouncedSearch, category);
  const createPost = useCreatePost();
  const deletePost = useDeletePost();

  return (
    <div className={theme}>
      <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="חפש..." />
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="all">הכל</option>
        <option value="tech">טכנולוגיה</option>
        <option value="design">עיצוב</option>
      </select>

      {isLoading ? (
        <p>טוען...</p>
      ) : (
        postsList?.map((post) => (
          <div key={post.id}>
            <h3>{post.title}</h3>
            <p>{post.content}</p>
            <button onClick={() => deletePost.mutate(post.id)}>מחק</button>
          </div>
        ))
      )}
    </div>
  );
}

הסבר:
- React Query מנהל server state (פוסטים, נתונים מ-API)
- Zustand מנהל client state (theme, sidebar, draft)
- החיפוש עם debounce משנה את ה-queryKey ומפעיל שליפה חדשה
- staleTime של 5 דקות מונע שליפות מיותרות


תשובות לשאלות

  1. staleTime מול gcTime: staleTime קובע כמה זמן נתונים נחשבים "טריים" - כל עוד הם טריים, React Query לא ישלוף מחדש גם אם הקומפוננטה עולה מחדש. gcTime קובע כמה זמן נתונים נשמרים במטמון אחרי שאין קומפוננטה שמשתמשת בהם. נתונים stale עדיין מוצגים מהמטמון, אבל refetch רץ ברקע.

  2. queryKey נכון: ה-queryKey הוא המזהה של הנתונים במטמון. אם שני queries חולקים אותו key, הם ישתפו מטמון - מה שיכול לגרום לבאגים אם הם שולפים נתונים שונים. queryKey צריך להכיל את כל הפרמטרים שמשפיעים על הנתונים (id, page, filters).

  3. React Query מול useFetch: React Query מספק: מטמון אוטומטי, deduplication (לא שולף פעמיים אותם נתונים), background refetch, retry, optimistic updates, infinite scroll, devtools, ועוד. useFetch מותאם אישית יצטרך לממש את כל אלה מאפס.

  4. invalidateQueries מול setQueryData: invalidateQueries מסמן נתונים כ-stale ומפעיל refetch מהשרת - בטוח יותר כי מקבלים את הנתונים העדכניים מהשרת. setQueryData מעדכן את המטמון ישירות ללא פנייה לשרת - מהיר יותר אבל הנתונים עלולים להיות לא מסונכרנים עם השרת. setQueryData שימושי בעיקר לעדכונים אופטימיסטיים.

  5. React Query + Zustand: React Query מנהל "server state" - נתונים שמקורם בשרת (משתמשים, פוסטים, מוצרים). Zustand מנהל "client state" - מצב שקיים רק בצד הלקוח (theme, sidebar, טפסים, UI state). ההפרדה הזו מבהירה את המטרה של כל חלק ומפשטת את הקוד.