עברית

גלו תבניות מתקדמות עבור React Context API, כולל רכיבים מורכבים (compound components), קונטקסטים דינמיים, וטכניקות למיטוב ביצועים לניהול מצב מורכב.

תבניות מתקדמות ב-React Context API לניהול מצב (State)

ה-Context API של React מספק מנגנון רב עוצמה לשיתוף מצב (state) ברחבי האפליקציה שלכם ללא צורך ב-"prop drilling". בעוד שהשימוש הבסיסי בו פשוט, ניצול מלא של הפוטנציאל שלו דורש הבנה של תבניות מתקדמות שיכולות להתמודד עם תרחישים מורכבים של ניהול מצב. מאמר זה יסקור מספר תבניות כאלו, ויציע דוגמאות מעשיות ותובנות ישימות כדי לשדרג את יכולות הפיתוח שלכם ב-React.

הבנת המגבלות של ה-Context API הבסיסי

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

תבנית 1: רכיבים מורכבים (Compound Components) עם קונטקסט

תבנית הרכיב המורכב (Compound Component) משפרת את ה-Context API על ידי יצירת סט של רכיבים קשורים החולקים באופן מרומז מצב ולוגיקה דרך קונטקסט. תבנית זו מקדמת שימוש חוזר ומפשטת את ה-API עבור הצרכנים. היא מאפשרת לכמוס לוגיקה מורכבת בתוך יישום פשוט.

דוגמה: רכיב טאבים (Tab)

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

// TabContext.js
import React, { createContext, useContext, useState, ReactNode } from 'react';

interface TabContextType {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabContext = createContext(undefined);

interface TabProviderProps {
  children: ReactNode;
  defaultTab: string;
}

export const TabProvider: React.FC = ({ children, defaultTab }) => {
  const [activeTab, setActiveTab] = useState(defaultTab);

  const value: TabContextType = {
    activeTab,
    setActiveTab,
  };

  return {children};
};

export const useTabContext = () => {
  const context = useContext(TabContext);
  if (!context) {
    throw new Error('useTabContext must be used within a TabProvider');
  }
  return context;
};

// TabList.js
import React, { ReactNode } from 'react';

interface TabListProps {
  children: ReactNode;
}

export const TabList: React.FC = ({ children }) => {
  return 
{children}
; }; // Tab.js import React, { ReactNode } from 'react'; import { useTabContext } from './TabContext'; interface TabProps { label: string; children: ReactNode; } export const Tab: React.FC = ({ label, children }) => { const { activeTab, setActiveTab } = useTabContext(); const isActive = activeTab === label; const handleClick = () => { setActiveTab(label); }; return ( ); }; // TabPanel.js import React, { ReactNode } from 'react'; import { useTabContext } from './TabContext'; interface TabPanelProps { label: string; children: ReactNode; } export const TabPanel: React.FC = ({ label, children }) => { const { activeTab } = useTabContext(); const isActive = activeTab === label; return ( ); };
// שימוש
import { TabProvider, TabList, Tab, TabPanel } from './components/Tabs';

function App() {
  return (
    
      
        Tab 1
        Tab 2
        Tab 3
      
      Content for Tab 1
      Content for Tab 2
      Content for Tab 3
    
  );
}

export default App;

יתרונות:

תבנית 2: קונטקסטים דינמיים

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

דוגמה: עיצוב (Theming) עם קונטקסטים דינמיים

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

// ThemeContext.js
import React, { createContext, useContext, useState, ReactNode } from 'react';

interface Theme {
  background: string;
  color: string;
}

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const defaultTheme: Theme = {
    background: 'white',
    color: 'black'
};

const darkTheme: Theme = {
    background: 'black',
    color: 'white'
};

const ThemeContext = createContext({
    theme: defaultTheme,
    toggleTheme: () => {}
});

interface ThemeProviderProps {
  children: ReactNode;
}

export const ThemeProvider: React.FC = ({ children }) => {
  const [isDarkTheme, setIsDarkTheme] = useState(false);
  const theme = isDarkTheme ? darkTheme : defaultTheme;

  const toggleTheme = () => {
    setIsDarkTheme(!isDarkTheme);
  };

  const value: ThemeContextType = {
    theme,
    toggleTheme,
  };

  return {children};
};

export const useTheme = () => {
  return useContext(ThemeContext);
};
// שימוש
import { useTheme, ThemeProvider } from './ThemeContext';

function MyComponent() {
  const { theme, toggleTheme } = useTheme();

  return (
    

This is a themed component.

); } function App() { return ( ); } export default App;

בדוגמה זו, ה-ThemeProvider קובע באופן דינמי את ערכת הנושא בהתבסס על המצב isDarkTheme. רכיבים המשתמשים ב-hook ‏useTheme יתרנדרו מחדש באופן אוטומטי כאשר ערכת הנושא משתנה.

תבנית 3: קונטקסט עם useReducer למצב מורכב

לניהול לוגיקת מצב מורכבת, שילוב של Context API עם useReducer הוא גישה מצוינת. useReducer מספק דרך מובנית לעדכון מצב בהתבסס על פעולות (actions), וה-Context API מאפשר לכם לשתף את המצב הזה ואת פונקציית ה-dispatch ברחבי האפליקציה.

דוגמה: רשימת מטלות פשוטה

// TodoContext.js
import React, { createContext, useContext, useReducer, ReactNode } from 'react';

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

interface TodoState {
  todos: Todo[];
}

type TodoAction = 
  | { type: 'ADD_TODO'; text: string } 
  | { type: 'TOGGLE_TODO'; id: number } 
  | { type: 'DELETE_TODO'; id: number };

interface TodoContextType {
  state: TodoState;
  dispatch: React.Dispatch;
}

const initialState: TodoState = {
  todos: [],
};

const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.text, completed: false }],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
        ),
      };
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    default:
      return state;
  }
};

const TodoContext = createContext(undefined);

interface TodoProviderProps {
  children: ReactNode;
}

export const TodoProvider: React.FC = ({ children }) => {
  const [state, dispatch] = useReducer(todoReducer, initialState);

  const value: TodoContextType = {
    state,
    dispatch,
  };

  return {children};
};

export const useTodo = () => {
  const context = useContext(TodoContext);
  if (!context) {
    throw new Error('useTodo must be used within a TodoProvider');
  }
  return context;
};
// שימוש
import { useTodo, TodoProvider } from './TodoContext';

function TodoList() {
  const { state, dispatch } = useTodo();

  return (
    
    {state.todos.map((todo) => (
  • {todo.text}
  • ))}
); } function AddTodo() { const { dispatch } = useTodo(); const [text, setText] = React.useState(''); const handleSubmit = (e) => { e.preventDefault(); dispatch({ type: 'ADD_TODO', text }); setText(''); }; return (
setText(e.target.value)} />
); } function App() { return ( ); } export default App;

תבנית זו מרכזת את לוגיקת ניהול המצב בתוך ה-reducer, מה שהופך אותה לקלה יותר להבנה ולבדיקה. רכיבים יכולים לשגר (dispatch) פעולות כדי לעדכן את המצב מבלי לנהל אותו ישירות.

תבנית 4: עדכוני קונטקסט ממוטבים עם `useMemo` ו-`useCallback`

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

דוגמה: מיטוב קונטקסט של ערכת נושא

// OptimizedThemeContext.js
import React, { createContext, useContext, useState, useMemo, useCallback, ReactNode } from 'react';

interface Theme {
  background: string;
  color: string;
}

interface ThemeContextType {
  theme: Theme;
  toggleTheme: () => void;
}

const defaultTheme: Theme = {
    background: 'white',
    color: 'black'
};

const darkTheme: Theme = {
    background: 'black',
    color: 'white'
};

const ThemeContext = createContext({
    theme: defaultTheme,
    toggleTheme: () => {}
});

interface ThemeProviderProps {
  children: ReactNode;
}

export const ThemeProvider: React.FC = ({ children }) => {
  const [isDarkTheme, setIsDarkTheme] = useState(false);
  const theme = isDarkTheme ? darkTheme : defaultTheme;

  const toggleTheme = useCallback(() => {
    setIsDarkTheme(!isDarkTheme);
  }, [isDarkTheme]);

  const value: ThemeContextType = useMemo(() => ({
    theme,
    toggleTheme,
  }), [theme, toggleTheme]);

  return {children};
};

export const useTheme = () => {
  return useContext(ThemeContext);
};

הסבר:

ללא useCallback, פונקציית toggleTheme הייתה נוצרת מחדש בכל רינדור של ה-ThemeProvider, מה שהיה גורם ל-value להשתנות ומפעיל רינדורים מחדש בכל הרכיבים המשתמשים, גם אם ערכת הנושא עצמה לא השתנתה. useMemo מבטיח ש-value חדש ייווצר רק כאשר התלויות שלו (theme או toggleTheme) משתנות.

תבנית 5: בוררי קונטקסט (Context Selectors)

בוררי קונטקסט מאפשרים לרכיבים להירשם רק לחלקים ספציפיים של ערך הקונטקסט. הדבר מונע רינדורים מחדש מיותרים כאשר חלקים אחרים של הקונטקסט משתנים. ניתן להשתמש בספריות כמו `use-context-selector` או במימושים מותאמים אישית כדי להשיג זאת.

דוגמה באמצעות בורר קונטקסט מותאם אישית

// useCustomContextSelector.js
import { useContext, useState, useRef, useEffect } from 'react';

function useCustomContextSelector(
  context: React.Context,
  selector: (value: T) => S
): S {
  const value = useContext(context);
  const [selected, setSelected] = useState(() => selector(value));
  const latestSelector = useRef(selector);
  latestSelector.current = selector;

  useEffect(() => {
    let didUnmount = false;
    let lastSelected = selected;

    const subscription = () => {
      if (didUnmount) {
        return;
      }
      const nextSelected = latestSelector.current(value);
      if (!Object.is(lastSelected, nextSelected)) {
        lastSelected = nextSelected;
        setSelected(nextSelected);
      }
    };

    // You would typically subscribe to context changes here. Since this is a simplified
    // example, we'll just call subscription immediately to initialize.
    subscription();

    return () => {
      didUnmount = true;
      // Unsubscribe from context changes here, if applicable.
    };
  }, [value]); // Re-run effect whenever the context value changes

  return selected;
}

export default useCustomContextSelector;
// ThemeContext.js (Simplified for brevity)
import React, { createContext, useState, ReactNode } from 'react';

interface Theme {
  background: string;
  color: string;
}

interface ThemeContextType {
  theme: Theme;
  setTheme: (newTheme: Theme) => void; 
}

const ThemeContext = createContext(undefined);

interface ThemeProviderProps {
  children: ReactNode;
  initialTheme: Theme;
}

export const ThemeProvider: React.FC = ({ children, initialTheme }) => {
  const [theme, setTheme] = useState(initialTheme);

  const value: ThemeContextType = {
    theme,
    setTheme
  };

  return {children};
};

export const useThemeContext = () => {
    const context = React.useContext(ThemeContext);
    if (!context) {
        throw new Error("useThemeContext must be used within a ThemeProvider");
    }
    return context;
};

export default ThemeContext;
// שימוש
import useCustomContextSelector from './useCustomContextSelector';
import ThemeContext, { ThemeProvider, useThemeContext } from './ThemeContext';

function BackgroundComponent() {
  const background = useCustomContextSelector(ThemeContext, (context) => context.theme.background);
  return 
Background
; } function ColorComponent() { const color = useCustomContextSelector(ThemeContext, (context) => context.theme.color); return
Color
; } function App() { const { theme, setTheme } = useThemeContext(); const toggleTheme = () => { setTheme({ background: theme.background === 'white' ? 'black' : 'white', color: theme.color === 'black' ? 'white' : 'black' }); }; return ( ); } export default App;

בדוגמה זו, BackgroundComponent מתרנדר מחדש רק כאשר המאפיין background של ערכת הנושא משתנה, ו-ColorComponent מתרנדר מחדש רק כאשר המאפיין color משתנה. הדבר מונע רינדורים מיותרים כאשר ערך הקונטקסט כולו משתנה.

תבנית 6: הפרדת פעולות (Actions) מהמצב (State)

עבור אפליקציות גדולות יותר, שקלו להפריד את ערך הקונטקסט לשני קונטקסטים נפרדים: אחד למצב ואחר לפעולות (פונקציות dispatch). הדבר יכול לשפר את ארגון הקוד ואת יכולת הבדיקה.

דוגמה: רשימת מטלות עם קונטקסטים נפרדים למצב ולפעולות

// TodoStateContext.js
import React, { createContext, useContext, useReducer, ReactNode } from 'react';

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

interface TodoState {
  todos: Todo[];
}

const initialState: TodoState = {
  todos: [],
};

const TodoStateContext = createContext(initialState);

interface TodoStateProviderProps {
  children: ReactNode;
}

export const TodoStateProvider: React.FC = ({ children }) => {
  const [state] = useReducer(todoReducer, initialState);

  return {children};
};

export const useTodoState = () => {
  return useContext(TodoStateContext);
};

// TodoActionContext.js
import React, { createContext, useContext, Dispatch, ReactNode } from 'react';

type TodoAction = 
  | { type: 'ADD_TODO'; text: string } 
  | { type: 'TOGGLE_TODO'; id: number } 
  | { type: 'DELETE_TODO'; id: number };

const TodoActionContext = createContext | undefined>(undefined);

interface TodoActionProviderProps {
    children: ReactNode;
}

export const TodoActionProvider: React.FC = ({children}) => {
    const [, dispatch] = useReducer(todoReducer, initialState);

    return {children};
};


export const useTodoDispatch = () => {
  const dispatch = useContext(TodoActionContext);
  if (!dispatch) {
    throw new Error('useTodoDispatch must be used within a TodoActionProvider');
  }
  return dispatch;
};

// todoReducer.js
export const todoReducer = (state: TodoState, action: TodoAction): TodoState => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.text, completed: false }],
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map((todo) =>
          todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
        ),
      };
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter((todo) => todo.id !== action.id),
      };
    default:
      return state;
  }
};
// שימוש
import { useTodoState, TodoStateProvider } from './TodoStateContext';
import { useTodoDispatch, TodoActionProvider } from './TodoActionContext';

function TodoList() {
  const state = useTodoState();

  return (
    
    {state.todos.map((todo) => (
  • {todo.text}
  • ))}
); } function TodoActions({ todo }) { const dispatch = useTodoDispatch(); return ( <> ); } function AddTodo() { const dispatch = useTodoDispatch(); const [text, setText] = React.useState(''); const handleSubmit = (e) => { e.preventDefault(); dispatch({ type: 'ADD_TODO', text }); setText(''); }; return (
setText(e.target.value)} />
); } function App() { return ( ); } export default App;

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

שיטות עבודה מומלצות ושיקולים

סיכום

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

ככל ש-React מתפתחת, כך גם שיטות העבודה המומלצות סביב ה-Context API יתפתחו. הישארות מעודכנת לגבי טכניקות וספריות חדשות תבטיח שאתם מצוידים להתמודד עם אתגרי ניהול המצב של פיתוח ווב מודרני. שקלו לחקור תבניות מתפתחות כמו שימוש בקונטקסט עם סיגנלים (signals) לתגובתיות (reactivity) מדויקת עוד יותר.