Українська

Дослідіть просунуті патерни React Context API, включаючи складені компоненти, динамічні контексти та техніки оптимізації продуктивності для складного управління станом.

Просунуті патерни React Context API для управління станом

React Context API надає потужний механізм для обміну станом у вашому додатку без необхідності "прокидання" пропсів (prop drilling). Хоча базове використання є простим, для повного розкриття його потенціалу необхідно розуміти просунуті патерни, які можуть впоратися зі складними сценаріями управління станом. Ця стаття досліджує декілька таких патернів, пропонуючи практичні приклади та корисні поради для покращення вашої розробки на React.

Розуміння обмежень базового Context API

Перш ніж занурюватися у просунуті патерни, важливо визнати обмеження базового Context API. Хоча він підходить для простого, глобально доступного стану, він може стати громіздким та неефективним для складних додатків зі станом, що часто змінюється. Кожен компонент, що використовує контекст, перерендериться щоразу, коли значення контексту змінюється, навіть якщо компонент не залежить від тієї конкретної частини стану, яка була оновлена. Це може призвести до проблем з продуктивністю.

Патерн 1: Складені компоненти з контекстом

Патерн "Складений компонент" (Compound Component) розширює Context API, створюючи набір пов'язаних компонентів, які неявно діляться станом та логікою через контекст. Цей патерн сприяє повторному використанню та спрощує API для споживачів. Це дозволяє інкапсулювати складну логіку за простою реалізацією.

Приклад: Компонент вкладок (Tab)

Проілюструймо це на прикладі компонента вкладок. Замість передачі пропсів через кілька рівнів, компоненти 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: Динамічні контексти

У деяких сценаріях вам можуть знадобитися різні значення контексту залежно від позиції компонента в дереві компонентів або інших динамічних факторів. Динамічні контексти дозволяють створювати та надавати значення контексту, які змінюються залежно від конкретних умов.

Приклад: Тематизація за допомогою динамічних контекстів

Розглянемо систему тематизації, де ви хочете надавати різні теми залежно від уподобань користувача або розділу додатка, в якому він перебуває. Ми можемо створити спрощений приклад зі світлою та темною темами.

// 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. Компоненти, що використовують хук useTheme, автоматично перерендериться при зміні теми.

Патерн 3: Контекст з useReducer для складного стану

Для управління складною логікою стану поєднання Context API з useReducer є чудовим підходом. useReducer надає структурований спосіб оновлення стану на основі дій (actions), а Context API дозволяє ділитися цим станом та функцією dispatch у вашому додатку.

Приклад: Простий список справ (Todo List)

// 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;

Цей патерн централізує логіку управління станом у редьюсері, що полегшує її розуміння та тестування. Компоненти можуть відправляти дії для оновлення стану, не керуючи станом безпосередньо.

Патерн 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: Селектори контексту

Селектори контексту дозволяють компонентам підписуватися лише на певні частини значення контексту. Це запобігає непотрібним перерендерам, коли змінюються інші частини контексту. Для досягнення цього можна використовувати бібліотеки, такі як `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);
      }
    };

    // Зазвичай тут ви б підписалися на зміни контексту. Оскільки це спрощений
    // приклад, ми просто викликаємо підписку негайно для ініціалізації.
    subscription();

    return () => {
      didUnmount = true;
      // Відпишіться від змін контексту тут, якщо це застосовно.
    };
  }, [value]); // Повторно запускати ефект щоразу, коли змінюється значення контексту

  return selected;
}

export default useCustomContextSelector;
// ThemeContext.js (спрощено для стислості)
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: Відокремлення дій від стану

Для великих додатків варто розглянути можливість розділення значення контексту на два окремі контексти: один для стану, а інший для дій (функцій 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;

Таке розділення дозволяє компонентам підписуватися лише на потрібний їм контекст, зменшуючи кількість непотрібних перерендерів. Це також полегшує юніт-тестування редьюсера та кожного компонента окремо. Крім того, порядок обгортання провайдерів має значення. ActionProvider має обгортати StateProvider.

Найкращі практики та рекомендації

Висновок

React Context API є універсальним інструментом для управління станом. Розуміючи та застосовуючи ці просунуті патерни, ви зможете ефективно керувати складним станом, оптимізувати продуктивність та створювати більш підтримувані та масштабовані додатки на React. Не забувайте обирати правильний патерн для ваших конкретних потреб і ретельно враховувати наслідки використання контексту для продуктивності.

З розвитком React будуть розвиватися і найкращі практики, пов'язані з Context API. Будучи в курсі нових технік та бібліотек, ви будете готові до вирішення завдань управління станом у сучасній веб-розробці. Розгляньте можливість вивчення нових патернів, таких як використання контексту з сигналами для ще більш гранулярної реактивності.