RTK Query

מבוא

RTK Query הוא כלי ייעודי לשליפת נתונים (data fetching) ולניהול מטמון (caching), המגיע כחלק מ-Redux Toolkit. הוא הופך את הדפוס המוכר של useState + useEffect + createAsyncThunk למיותר: מגדירים את נקודות הקצה (endpoints) פעם אחת, ומקבלים Hooks מוכנים שמטפלים עבורכם באופן אוטומטי ב:

  • מצבי טעינה ושגיאה (Loading / Error)
  • שמירת תוצאות במטמון ושיתופן בין קומפוננטות (Caching)
  • רענון נתונים אוטומטי בעת הצורך (Re-fetching)
  • עדכונים אופטימיים (Optimistic updates)

הגדרת API

נקודת המוצא היא הגדרת "פרוסת API" בעזרת createApi. בתוכה מגדירים את כתובת הבסיס של השרת ואת נקודות הקצה, המחולקות לשני סוגים: query לקריאת נתונים (GET) ו-mutation לשינוי נתונים (POST/PUT/DELETE). מנגנון ה-tags מאפשר ל-RTK Query לדעת אילו נתונים במטמון להתייתר ולרענן לאחר שינוי. מכל אלה נוצרים אוטומטית ה-Hooks שבהם נשתמש בקומפוננטות:

// store/api/usersApi.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const usersApi = createApi({
    reducerPath: 'usersApi',

    baseQuery: fetchBaseQuery({ 
        baseUrl: '/api',
        prepareHeaders: (headers, { getState }) => {
            const token = getState().auth.token;
            if (token) {
                headers.set('Authorization', `Bearer ${token}`);
            }
            return headers;
        }
    }),

    tagTypes: ['User', 'Users'],

    endpoints: (builder) => ({
        // GET all users
        getUsers: builder.query({
            query: () => '/users',
            providesTags: ['Users']
        }),

        // GET single user
        getUserById: builder.query({
            query: (id) => `/users/${id}`,
            providesTags: (result, error, id) => [{ type: 'User', id }]
        }),

        // POST new user
        addUser: builder.mutation({
            query: (newUser) => ({
                url: '/users',
                method: 'POST',
                body: newUser
            }),
            invalidatesTags: ['Users']
        }),

        // PUT update user
        updateUser: builder.mutation({
            query: ({ id, ...patch }) => ({
                url: `/users/${id}`,
                method: 'PUT',
                body: patch
            }),
            invalidatesTags: (result, error, { id }) => [
                { type: 'User', id },
                'Users'
            ]
        }),

        // DELETE user
        deleteUser: builder.mutation({
            query: (id) => ({
                url: `/users/${id}`,
                method: 'DELETE'
            }),
            invalidatesTags: ['Users']
        })
    })
});

// ייצוא Hooks אוטומטיים
export const {
    useGetUsersQuery,
    useGetUserByIdQuery,
    useAddUserMutation,
    useUpdateUserMutation,
    useDeleteUserMutation
} = usersApi;

הגדרת Store

// store/store.js
import { configureStore } from '@reduxjs/toolkit';
import { usersApi } from './api/usersApi';

const store = configureStore({
    reducer: {
        [usersApi.reducerPath]: usersApi.reducer,
        // reducers נוספים...
    },
    middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware().concat(usersApi.middleware)
});

export default store;

שימוש - Query (GET)

ה-Hooks מסוג query משמשים לקריאת נתונים. הם מחזירים אובייקט עשיר הכולל את הנתונים עצמם (data), דגלי מצב (isLoading, isError) ופונקציית refetch לרענון ידני - כך שאין צורך לנהל את כל אלה בעצמנו:

import { useGetUsersQuery, useGetUserByIdQuery } from './store/api/usersApi';

function UserList() {
    const { 
        data: users, 
        isLoading, 
        isError, 
        error,
        isFetching,
        refetch 
    } = useGetUsersQuery();

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

    return (
        <div>
            <button onClick={refetch} disabled={isFetching}>
                {isFetching ? 'מרענן...' : 'רענן'}
            </button>
            <ul>
                {users.map(user => (
                    <li key={user.id}>{user.name}</li>
                ))}
            </ul>
        </div>
    );
}

function UserProfile({ userId }) {
    const { data: user, isLoading } = useGetUserByIdQuery(userId, {
        // אפשרויות
        skip: !userId, // דלג אם אין userId
        pollingInterval: 30000, // רענון כל 30 שניות
        refetchOnMountOrArgChange: true
    });

    if (isLoading) return <p>טוען פרופיל...</p>;
    if (!user) return <p>משתמש לא נמצא</p>;

    return (
        <div>
            <h1>{user.name}</h1>
            <p>{user.email}</p>
        </div>
    );
}

שימוש - Mutation (POST/PUT/DELETE)

ה-Hooks מסוג mutation משמשים לשינוי נתונים בשרת. בשונה מ-query, הם אינם רצים אוטומטית אלא מחזירים פונקציית הפעלה שקוראים לה בעת הצורך. שרשור .unwrap() מאפשר לתפוס הצלחה או שגיאה בעזרת try/catch רגיל:

import {
    useAddUserMutation, 
    useUpdateUserMutation, 
    useDeleteUserMutation 
} from './store/api/usersApi';

function AddUserForm() {
    const [addUser, { isLoading, isSuccess, isError, error }] = useAddUserMutation();
    const [name, setName] = useState('');
    const [email, setEmail] = useState('');

    const handleSubmit = async (e) => {
        e.preventDefault();

        try {
            await addUser({ name, email }).unwrap();
            setName('');
            setEmail('');
            alert('המשתמש נוסף בהצלחה!');
        } catch (err) {
            alert('שגיאה: ' + err.message);
        }
    };

    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={isLoading}>
                {isLoading ? 'שומר...' : 'הוסף משתמש'}
            </button>
        </form>
    );
}

function UserItem({ user }) {
    const [updateUser] = useUpdateUserMutation();
    const [deleteUser, { isLoading: isDeleting }] = useDeleteUserMutation();

    const handleUpdate = async () => {
        const newName = prompt('שם חדש:', user.name);
        if (newName) {
            await updateUser({ id: user.id, name: newName });
        }
    };

    const handleDelete = async () => {
        if (confirm('למחוק את המשתמש?')) {
            await deleteUser(user.id);
        }
    };

    return (
        <li>
            {user.name}
            <button onClick={handleUpdate}>✏️ ערוך</button>
            <button onClick={handleDelete} disabled={isDeleting}>
                {isDeleting ? '...' : '❌ מחק'}
            </button>
        </li>
    );
}

Tags ו-Cache Invalidation

מנגנון ה-Tags הוא שמחבר בין קריאות לשינויים. כל query "מספקת" תגית (providesTags), וכל mutation יכולה "לבטל" תגית (invalidatesTags). כשמבטלים תגית, RTK Query מרענן אוטומטית כל query שסיפקה אותה - כך התצוגה נשארת מסונכרנת עם השרת ללא רענון ידני:

// Tags מאפשרים לקשר בין queries ל-mutations
endpoints: (builder) => ({
    getPosts: builder.query({
        query: () => '/posts',
        providesTags: ['Posts']  // ה-query מספקת תגית 'Posts'
    }),

    addPost: builder.mutation({
        query: (post) => ({ url: '/posts', method: 'POST', body: post }),
        invalidatesTags: ['Posts']  // מבטלת את הקאש של 'Posts'
    })
})
// כשנקרא addPost, getPosts יתרענן אוטומטית!

Tags מתקדמים

endpoints: (builder) => ({
    getPosts: builder.query({
        query: () => '/posts',
        providesTags: (result) => 
            result
                ? [
                    ...result.map(({ id }) => ({ type: 'Post', id })),
                    { type: 'Post', id: 'LIST' }
                  ]
                : [{ type: 'Post', id: 'LIST' }]
    }),

    updatePost: builder.mutation({
        query: ({ id, ...patch }) => ({
            url: `/posts/${id}`,
            method: 'PATCH',
            body: patch
        }),
        // מבטל רק את הפוסט הספציפי
        invalidatesTags: (result, error, { id }) => [{ type: 'Post', id }]
    })
})

Optimistic Updates

בעדכון אופטימי מעדכנים את התצוגה מיד, עוד לפני שהשרת הספיק להשיב, כדי שהממשק ירגיש מהיר ותגובתי. אם השרת מחזיר שגיאה, מבטלים את השינוי וחוזרים למצב הקודם. RTK Query מספק לכך את onQueryStarted יחד עם undo:

updatePost: builder.mutation({
    query: ({ id, ...patch }) => ({
        url: `/posts/${id}`,
        method: 'PATCH',
        body: patch
    }),

    async onQueryStarted({ id, ...patch }, { dispatch, queryFulfilled }) {
        // עדכון אופטימיסטי - לפני התשובה מהשרת
        const patchResult = dispatch(
            postsApi.util.updateQueryData('getPosts', undefined, (draft) => {
                const post = draft.find(p => p.id === id);
                if (post) {
                    Object.assign(post, patch);
                }
            })
        );

        try {
            await queryFulfilled;
        } catch {
            // אם נכשל - חזור לערך הקודם
            patchResult.undo();
        }
    }
})

סיכום

Hook סוג שימוש
useGetXQuery Query קריאת נתונים
useXMutation Mutation שינוי נתונים
providesTags - הגדרת תגיות קאש
invalidatesTags - ביטול קאש

תכונות אוטומטיות

  • ✅ Loading/Error states
  • ✅ Caching
  • ✅ Automatic refetch
  • ✅ Deduplication
  • ✅ Polling
  • ✅ Optimistic updates

הקודם: ← Redux Hooks
הבא: Testing →

בדקו את עצמכם

נסו לענות לבד לפני שאתם פותחים את התשובה.

  1. מה RTK Query חוסך?

    1. את React
    2. את כל ה-CSS
    3. כתיבה ידנית של fetch, מצבי טעינה ושגיאה, ומטמון
    4. את Redux
    הצגת התשובה

    תשובה ג. הוק כמו useGetPostsQuery מחזיר data, isLoading ו-error מוכנים.

  2. מה עושה invalidatesTags במוטציה?

    1. מסתיר שגיאות
    2. מבטל את הבקשה
    3. גורם לשאילתות קשורות להיטען מחדש אחרי שינוי
    4. מוחק את השרת
    הצגת התשובה

    תשובה ג. כך הרשימה מתעדכנת אוטומטית אחרי הוספה או מחיקה.