Redux Toolkit עם TypeScript: המדריך המלא

סער טויטו

סער טויטו

Software & Web Development


במאמר זה אנו נדון מה זה Redux, למה אנחנו צריכים אותה ומתי משתמשים בה. לאחר מכן נראה כיצד ליישם Redux Toolkit (הגרסה החדשה) עם TypeScript ב-React.js.

מה זה Redux?

Redux היא ספריית JavaScript פופולרית שמשמשת לניהול מצב / מידע באפליקציה בצורה מסודרת ומובנת (state management). לדוגמה, באתרי E-commerce משתמשים ב-Redux כדי לשמור ולנהל מגוון פעולות שאנחנו עושים באתרים מסוג זה, לדוגמה ניהול המוצרים שהוספנו לסל, ניהול כמות המוצרים שיש לנו בסל, ניהול השילוח וכדומה.

הטרמינולוגיה של Redux:

Redux מורכבת משלושה יחידות שפועלות ביחד על מנת ליצור זרימה וניהול תקין של אותן הנתונים / מצב:

  • Store:

    store היא אובייקט המחזיק את כל הנתונים ומצב האפליקציה. ה-store הוא ה-single source of truth. מכאן נשאב את הנתונים שאספנו מהאתר ונזרים אותם לשאר חלקי האפליקציה שצריכים את אותו מידע.

    Diagram illustrating the core principles of Redux
    By Mosh Hamedani
  • הסבר על Slice

    אז Slice מאפשר לנו לחלק את הנתונים (state) לחלקים לוגיים לפי ישויות (entities) כמו `user`, `order`, `product` וכדומה. כל Slice מרכז בתוכו את כל מה שקשור לאותה ישות – כמו הנתונים, הפעולות (actions) וגם את הלוגיקה (reducers) שמטפלת בהם.

    למרות שכל Slice מופרד לוגית, בפועל כולם נשמרים בתוך עץ נתונים אחד מרכזי (global state). היתרון בגישה הזו הוא שכשאנחנו משנים נתונים בסלייס מסוים, רק אותו Slice מתעדכן - ושאר הסלייסים שלא משתמשים באותם נתונים לא עוברים רינדור מחדש. זה משפר את הביצועים ושומר על אפליקציה יעילה ומודולרית.

  • הסבר על Actions

    Actions הם אובייקטים שמייצגים את הכוונה לשנות את מצב האפליקציה (state). כל action מכיל מאפיין בשם type, שמציין איזו פעולה יש לבצע. לעיתים קרובות, אנחנו גם יכולים להוסיף נתונים נוספים תחת payload אובייקט, שמספק מידע רלוונטי לפעולה.

    לדוגמה, אם אנחנו רוצים להוסיף מספר למונה (counter), נשלח action כזה:

    { type: 'counter/incrementByAmount', payload: 5 }

    בגרסת Redux Toolkit אין צורך להגדיר את ה-type באופן ידני - הוא נוצר אוטומטית על סמך שם ה-slice (למשל counter) ושם הפעולה (למשל incrementByAmount). כך הקוד נעשה ברור יותר, וקל יותר לתחזוקה ולניהול.

  • הסבר על Reducers

    Reducers הם פונקציות טהורות (pure functions) שמקבלות את הנתונים הנוכחיים (state) ואת הפעולה שאנחנו רוצים לבצע (action), ומחזירות גרסה חדשה של הנתונים בהתאם ללוגיקה שביצענו.

    המטרה של reducer היא לעדכן את הנתונים בלי לשנות את האובייקט המקורי, אלא להחזיר אובייקט חדש בזיכרון עם הערכים החדשים. כלומר, לא מבצעים שינוי ישיר על מה שכבר קיים, אלא יוצרים עותק חדש (אובייקט חדש) שמכיל את העדכון - וזה נקרא immutability ו-Redux Toolkit משתמשת ספרייה בשם Immer מאחורי הקלעים שדואגת לשמור על עקרון ה-immutability - על ידי כך שהיא יוצרת אובייקט חדש בזיכרון.

    לדוגמה, אם נרצה להוסיף 1 לערך כלשהו, הפונקציה לא תעדכן את המספר עצמו - אלא תחזיר אובייקט חדש שבו המספר גדל ב-1.

    ככה זה נראה בלי Immer (Redux רגיל):

    return { ...state, value: state.value + 1 };

    וככה זה נראה עם Immer (Redux Toolkit):

    state.value++;

    אבל מה קורה לאובייקט הישן?

    אבל מה קורה לאובייקט הישן? אז האובייקט הישן, נשאר בזיכרון כל עוד מישהו עדיין משתמש בו (למשל קומפוננטה שעדיין מציגה אותו). ברגע שאין בו צורך - מנגנון ה-Garbage Collector של JavaScript יזהה זאת וימחק אותו אוטומטית מהזיכרון.

לפני שנראה את התמונה של כולם ביחד, אנחנו רוצים לדבר אתכם על האפשרות של לבצע קריאות API עם Redux Toolkit.

createAsyncThunk:

באפשרותנו להשתמש (לא חובה) בפונקציה createAsyncThunk שמאפשרת טיפול פשוט יותר בבקשות לשרת וטיפול בשגיאות, ומשפר את ארגון הקוד. פונקציה זו באופן אוטומטי נותנת לכם גישה לשלושת המצבים בהם יכולה הבקשה שלכם להיות (pending, fulfilled and rejected).

יישום Redux Toolkit עם TypeScript:

לפני שאתם רואים את התמונות, אנחנו ממליצים לקורא את המאמר הזה לפחות פעמיים כדי להבין את Redux & Redux Toolkit. זה מנגנון שיכול להיות מסובך להבנה בפעם הראשונה, לכן קריאה שנייה בהחלט תתרום לכם. על כל שורת קוד, הוספנו הערה שתעזור לכם להבין טוב יותר את הקוד שלנו.

שלב ראשון - הורדת ה-packages:

  • npm i @reduxjs/toolkit
  • npm i react-redux

שלב שני - יצירת תיקיות וקבצים לניהול תקין של Redux Toolkit:

  • app\hooks.ts
  • app\store.ts
  • features\counter\slice.ts
  • features\counter\api.ts
  • index.tsx
  • features\counter\Counter.tsx

בקובץ הראשון: hooks.ts

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

// Custom useDispatch hook with AppDispatch type for type safety
export const useAppDispatch = () => useDispatch<AppDispatch>();

// Custom useSelector hook with RootState type for type safety
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

בקובץ השני: store.ts

// Import required functions and slices
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

// Configure Redux store with counter reducer
export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

// Define and export AppDispatch and RootState types
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;

בקובץ השלישי: slice.ts

// Import necessary functions and types from Redux Toolkit
import {
  createAsyncThunk,
  createSlice,
  PayloadAction
} from '@reduxjs/toolkit';
import { RootState } from '../../app/store';
import { fetchCount } from './counterAPI';

// Define the CounterState interface
export interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

// Define the initial state of the counter
const initialState: CounterState = {
  value: 0,
  status: 'idle',
};

// Create async thunk to increment counter.
export const incrementAsync = createAsyncThunk(
  'counter/fetchCount',
  async (amount: number) => {
    const response = await fetchCount(amount);
    return response.data;
  }
);

// Initialize counter slice with state, reducers, extraReducers.
export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1; // Increment the counter by 1
    },
    decrement: (state) => {
      state.value -= 1; // Decrement the counter by 1
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      // Increment the counter by the specified amount
      state.value += action.payload;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(incrementAsync.pending, (state) => {
        // Status 'loading' during pending async request.
        state.status = 'loading';
      })
      .addCase(incrementAsync.fulfilled, (state, action) => {
        // Status 'idle' during fulfilled async request.
        state.status = 'idle';
        // Increment the counter by the async response value
        state.value += action.payload;
      })
      .addCase(incrementAsync.rejected, (state) => {
        // Status 'failed' during rejected async request.
        state.status = 'failed';
      });
  },
});

// Export actions for use elsewhere in the app.
export const {
  increment,
  decrement,
  incrementByAmount
} = counterSlice.actions;

// Create selector for current count value from state.
export const selectCount = (state: RootState) => state.counter.value;

// Export the reducer for use in the store
export default counterSlice.reducer;

בקובץ הרביעי: api.ts

// Mock async data request function
export function fetchCount(amount = 1) {
  return new Promise<{ data: number }>((resolve) =>
    setTimeout(() => resolve({ data: amount }), 1000)
  );
}

בקובץ החמישי: index.tsx

import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
import reportWebVitals from './reportWebVitals';
import './index.css';

const container = document.getElementById('root')!;
const root = createRoot(container);

root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
);

reportWebVitals();

בקובץ השישי: Counter.tsx

import { useState } from 'react';

// Import custom hooks and actions from app and counterSlice
import { useAppSelector, useAppDispatch } from '../../app/hooks';
import {
  decrement,
  increment,
  incrementByAmount,
  incrementAsync,
  selectCount,
} from './counterSlice';
import styles from './Counter.module.css';

export function Counter() {
  // Use custom selector hook to get the current count value
  const count = useAppSelector(selectCount);

  // Use custom dispatch hook for dispatching actions
  const dispatch = useAppDispatch();

  // Use state to manage the increment amount
  const [incrementAmount, setIncrementAmount] = useState('2');

  // Convert incrementAmount to a number
  const incrementValue = Number(incrementAmount) || 0;

  return (
    <div>
      <div className={styles.row}>
        {/* Decrement button */}
        <button
          className={styles.button}
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())}
        >
          -
        </button>

        {/* Display the current count */}
        <span className={styles.value}>{count}</span>

        {/* Increment button */}
        <button
          className={styles.button}
          aria-label="Increment value"
          onClick={() => dispatch(increment())}
        >
          +
        </button>
      </div>

      <div className={styles.row}>
        {/* Input field for setting the increment amount */}
        <input
          className={styles.textbox}
          aria-label="Set increment amount"
          value={incrementAmount}
          onChange={(e) => setIncrementAmount(e.target.value)}
        />

        {/* Button for incrementing by the specified amount */}
        <button
          className={styles.button}
          onClick={() => dispatch(incrementByAmount(incrementValue))}
        >
          Add Amount
        </button>

        {/* Button for incrementing asynchronously */}
        <button
          className={styles.asyncButton}
          onClick={() => dispatch(incrementAsync(incrementValue))}
        >
          Add Async
        </button>
      </div>
    </div>
  );
}

בקובץ הזה אנחנו רוצים שתשימו לב לשני דברים:

  • useAppSelector - נועד כדי למשוך פיסת מידע מה-store שלנו.
  • useAppDispatch - מחזיר פונקציה (dispatch) שנועדה לבצע קריאה ל-actions שהגדרנו.

סיכום מערכת Redux:

  • כאשר אנו רוצים להשתמש בנתונים מסוימים מה-Store, נשתמש ב-hook בשם useAppSelector, ונקבע בתוכו את ה-selectorState הרלוונטי, במקרה שלנו – selectCount.
  • על מנת לעדכן את הנתונים הקיימים ב-Store, נשתמש ב-hook בשם useAppDispatch, שמחזיר פונקציה בשם dispatch.
  • כפי שניתן לראות בשורות 34, 44, 60 ו-67, אנו משתמשים ב-dispatch ומעבירים לה את ה-action המתאים, הכולל שני תכונות – 'type' ו-'payload'.
  • כל action כולל את תכונת 'type' המתארת את הפעולה שיש לבצע(אם לא מגדירים type, יש type ברירת מחדל), ותכונה (אופציונלית) בשם 'payload', המכילה נתונים נוספים הדרושים לביצוע הפעולה.
  • לאחר שקראנו ל-action המתאים, התהליך ממשיך ל-reducer, שבוחר את ה-action הרלוונטי ומבצע את העדכון המבוקש.
  • כשהעדכון הושלם, הנתונים המעודכנים מגיעים לכל קומפוננטה המשתמשת באותם הנתונים, ולאחר מכן מתבצע רינדור (ריענון) של הקומפוננטות המעורבות.

Context API VS Redux

לפעמים עולה שיח למה אנחנו צריכים להשתמש ב-Redux Toolkit כשיש לנו Context API. בואו ננסה לענות על השאלה הזו בקצרה:

  • Context API פשוט וקל יותר לשימוש והקמה, בעוד Redux דורש יותר קוד ועקומת למידה תלולה יותר.
  • Context API מובנה בתוך React, בעוד ש-Redux היא ספרייה נפרדת שצריך להתקין ולהגדיר.
  • Context API מתאים יותר לאפליקציות קטנות עד בינוניים, בעוד ש-Redux מיועד לאפליקציות גדולות ומורכבות עם יותר זרימה של נתונים.
  • ל-Redux יש מספר אופטימיזציות מובנות של ביצועים כדי למזער רינדורים מיותרים, בעוד של-Context API אין אופטימיזציות כאלו.