Redux Hooks ופעולות אסינכרוניות

מבוא

לאחר שהקמנו את ה-store וה-slices, הגיע הזמן להעמיק בעבודה השוטפת מול Redux מתוך הקומפוננטות. שיעור זה מכסה:

  • שימוש מעמיק ב-useSelector וב-useDispatch
  • פעולות אסינכרוניות (כמו קריאות שרת) בעזרת createAsyncThunk
  • Middleware - שכבת ביניים בזרימת ה-actions
  • דפוסי עבודה מתקדמים ומומלצים

useSelector - קריאה מה-Store

ה-Hook בשם useSelector מאפשר לקומפוננטה לקרוא נתון מתוך ה-store. הוא מקבל פונקציית "בורר" (selector) שבוחרת את החלק הרלוונטי מה-State, והקומפוננטה תתרנדר מחדש רק כשאותו חלק ישתנה. כדי לא לגרום לרינדורים מיותרים, עדיף לבחור ערכים ספציפיים ולא להחזיר אובייקט חדש בכל קריאה:

import { useSelector } from 'react-redux';

function MyComponent() {
    // קריאה ישירה
    const count = useSelector(state => state.counter.value);

    // שימוש ב-selector מוגדר מראש
    const todos = useSelector(selectAllTodos);

    // Selector עם חישוב
    const total = useSelector(state => 
        state.cart.items.reduce((sum, item) => sum + item.price, 0)
    );

    // מספר ערכים (לא מומלץ - גורם לרינדורים מיותרים)
    const { user, isLoading } = useSelector(state => ({
        user: state.auth.user,
        isLoading: state.auth.isLoading
    }));
}

Selectors עם Reselect

import { createSelector } from '@reduxjs/toolkit';

// Selector בסיסי
const selectItems = state => state.cart.items;
const selectTaxRate = state => state.settings.taxRate;

// Selector מורכב עם memoization
const selectTotalWithTax = createSelector(
    [selectItems, selectTaxRate],
    (items, taxRate) => {
        const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
        return subtotal * (1 + taxRate);
    }
);

// שימוש
function CartTotal() {
    const total = useSelector(selectTotalWithTax);
    return <p>סה"כ: ₪{total.toFixed(2)}</p>;
}

useDispatch - שליחת Actions

אם useSelector משמש לקריאה מה-store, useDispatch משמש לכתיבה אליו. הוא מחזיר את פונקציית ה-dispatch, שבאמצעותה שולחים actions כדי לעדכן את ה-State:

import { useDispatch } from 'react-redux';
import { increment, addTodo } from './store';

function MyComponent() {
    const dispatch = useDispatch();

    // שליחה פשוטה
    const handleClick = () => {
        dispatch(increment());
    };

    // שליחה עם payload
    const handleAdd = (text) => {
        dispatch(addTodo(text));
    };

    // שליחה מרובה
    const handleReset = () => {
        dispatch(resetCart());
        dispatch(clearUser());
        dispatch(showNotification('הנתונים אופסו'));
    };
}

createAsyncThunk - פעולות אסינכרוניות

reducers רגילים חייבים להיות סינכרוניים, אך פעולות רבות (כמו שליפת נתונים מהשרת) לוקחות זמן. createAsyncThunk נועד בדיוק לכך: הוא עוטף פעולה אסינכרונית ומייצר עבורה שלושה actions אוטומטיים - pending (התחלה), fulfilled (הצלחה) ו-rejected (כישלון). את שלושתם מטפלים ב-slice דרך extraReducers, וכך מנהלים בקלות מצבי טעינה ושגיאה:

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// יצירת async thunk
export const fetchUsers = createAsyncThunk(
    'users/fetchUsers', // שם ה-action
    async (_, { rejectWithValue }) => {
        try {
            const response = await fetch('/api/users');
            if (!response.ok) {
                throw new Error('שגיאה בטעינה');
            }
            return await response.json();
        } catch (error) {
            return rejectWithValue(error.message);
        }
    }
);

// עם פרמטרים
export const fetchUserById = createAsyncThunk(
    'users/fetchById',
    async (userId, { rejectWithValue }) => {
        try {
            const response = await fetch(`/api/users/${userId}`);
            return await response.json();
        } catch (error) {
            return rejectWithValue(error.message);
        }
    }
);

// עם גישה ל-state
export const addToCart = createAsyncThunk(
    'cart/addItem',
    async (product, { getState, rejectWithValue }) => {
        const { auth } = getState();

        if (!auth.user) {
            return rejectWithValue('יש להתחבר תחילה');
        }

        const response = await fetch('/api/cart', {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${auth.token}`,
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(product)
        });

        return await response.json();
    }
);

טיפול ב-Slice

const usersSlice = createSlice({
    name: 'users',

    initialState: {
        items: [],
        selectedUser: null,
        status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
        error: null
    },

    reducers: {
        clearError: (state) => {
            state.error = null;
        }
    },

    // טיפול ב-async actions
    extraReducers: (builder) => {
        builder
            // fetchUsers
            .addCase(fetchUsers.pending, (state) => {
                state.status = 'loading';
                state.error = null;
            })
            .addCase(fetchUsers.fulfilled, (state, action) => {
                state.status = 'succeeded';
                state.items = action.payload;
            })
            .addCase(fetchUsers.rejected, (state, action) => {
                state.status = 'failed';
                state.error = action.payload || action.error.message;
            })

            // fetchUserById
            .addCase(fetchUserById.pending, (state) => {
                state.status = 'loading';
            })
            .addCase(fetchUserById.fulfilled, (state, action) => {
                state.status = 'succeeded';
                state.selectedUser = action.payload;
            })
            .addCase(fetchUserById.rejected, (state, action) => {
                state.status = 'failed';
                state.error = action.payload;
            });
    }
});

שימוש בקומפוננטה

function UserList() {
    const dispatch = useDispatch();
    const { items: users, status, error } = useSelector(state => state.users);

    useEffect(() => {
        if (status === 'idle') {
            dispatch(fetchUsers());
        }
    }, [status, dispatch]);

    if (status === 'loading') {
        return <p>טוען משתמשים...</p>;
    }

    if (status === 'failed') {
        return <p>שגיאה: {error}</p>;
    }

    return (
        <ul>
            {users.map(user => (
                <li key={user.id}>{user.name}</li>
            ))}
        </ul>
    );
}

Middleware

Middleware הוא שכבת ביניים שכל action עובר דרכה בדרך אל ה-reducer. זו נקודת הידוק אידיאלית לפעולות רוחביות כמו רישום ללוג (logging), דיווח שגיאות או ניתוב פעולות אסינכרוניות:

// store/middleware/logger.js
const loggerMiddleware = (store) => (next) => (action) => {
    console.group(action.type);
    console.log('dispatching:', action);
    console.log('prev state:', store.getState());

    const result = next(action);

    console.log('next state:', store.getState());
    console.groupEnd();

    return result;
};

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

const store = configureStore({
    reducer: { /* ... */ },
    middleware: (getDefaultMiddleware) => 
        getDefaultMiddleware().concat(loggerMiddleware)
});

Middleware לדיווח שגיאות

const errorReportingMiddleware = (store) => (next) => (action) => {
    try {
        return next(action);
    } catch (error) {
        console.error('Error in reducer:', error);
        // שליחה לשירות דיווח שגיאות
        reportError(error, action);
        throw error;
    }
};

Typed Hooks (TypeScript)

// store/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

// Typed hooks
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

// שימוש
function MyComponent() {
    const dispatch = useAppDispatch();
    const user = useAppSelector(state => state.auth.user); // עם autocomplete!
}

דפוסי שימוש מתקדמים

Action Creator עם לוגיקה

// Thunk ידני (לא createAsyncThunk)
export const conditionalIncrement = () => (dispatch, getState) => {
    const { value } = getState().counter;

    if (value < 10) {
        dispatch(increment());
    }
};

export const incrementIfOdd = (amount) => (dispatch, getState) => {
    const { value } = getState().counter;

    if (value % 2 === 1) {
        dispatch(incrementByAmount(amount));
    }
};

Batch Updates

import { batch } from 'react-redux';

function handleSubmit() {
    // כל ה-dispatches יגרמו לרינדור אחד בלבד
    batch(() => {
        dispatch(updateUser(userData));
        dispatch(updateSettings(settings));
        dispatch(showNotification('נשמר בהצלחה'));
    });
}

סיכום

API שימוש
useSelector(selector) קריאה מה-store
useDispatch() קבלת dispatch
createAsyncThunk async actions
extraReducers טיפול ב-thunk
createSelector memoized selectors
Middleware לוגיקה בין dispatch ל-reducer

הקודם: ← Redux Basics
הבא: RTK Query →

בדקו את עצמכם

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

  1. איך קוראים נתון מה-store בקומפוננטה?

    1. useState(store)
    2. useStore()
    3. props.store
    4. useSelector(state => state.cart.items)
    הצגת התשובה

    תשובה ד. useSelector בוחר חלק מה-state ומרנדר מחדש כשהוא משתנה.

  2. איך שולחים action?

    1. dispatch(addItem(item))
    2. setStore(item)
    3. action(item)
    4. store.items.push(item)
    הצגת התשובה

    תשובה א. useDispatch מחזיר את dispatch, ושולחים לו action.