Polski

Odkryj zaawansowane wzorce React Context API, w tym komponenty złożone, dynamiczne konteksty i techniki optymalizacji wydajności dla złożonego zarządzania stanem.

Zaawansowane wzorce React Context API do zarządzania stanem

React Context API dostarcza potężny mechanizm do współdzielenia stanu w całej aplikacji bez konieczności przekazywania właściwości (prop drilling). Chociaż podstawowe użycie jest proste, wykorzystanie pełnego potencjału wymaga zrozumienia zaawansowanych wzorców, które radzą sobie ze złożonymi scenariuszami zarządzania stanem. W tym artykule omówiono kilka z tych wzorców, oferując praktyczne przykłady i użyteczne wskazówki, które podniosą poziom Twoich umiejętności w React.

Zrozumienie ograniczeń podstawowego Context API

Przed zagłębieniem się w zaawansowane wzorce, kluczowe jest uświadomienie sobie ograniczeń podstawowego Context API. Chociaż nadaje się ono do prostego, globalnie dostępnego stanu, może stać się nieporęczne i nieefektywne w przypadku złożonych aplikacji z często zmieniającym się stanem. Każdy komponent konsumujący kontekst jest ponownie renderowany za każdym razem, gdy wartość kontekstu się zmienia, nawet jeśli komponent nie korzysta z konkretnej części stanu, która została zaktualizowana. Może to prowadzić do wąskich gardeł wydajnościowych.

Wzorzec 1: Komponenty złożone (Compound Components) z Context

Wzorzec komponentów złożonych rozszerza Context API, tworząc zestaw powiązanych komponentów, które niejawnie współdzielą stan i logikę poprzez kontekst. Wzorzec ten promuje reużywalność i upraszcza API dla konsumentów. Pozwala to na zamknięcie złożonej logiki w prostej implementacji.

Przykład: Komponent zakładek (Tab)

Zilustrujmy to na przykładzie komponentu zakładek. Zamiast przekazywać właściwości przez wiele warstw, komponenty Tab komunikują się niejawnie poprzez wspólny kontekst.

// 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 ( ); };
// Użycie
import { TabProvider, TabList, Tab, TabPanel } from './components/Tabs';

function App() {
  return (
    
      
        Tab 1
        Tab 2
        Tab 3
      
      Zawartość dla Tab 1
      Zawartość dla Tab 2
      Zawartość dla Tab 3
    
  );
}

export default App;

Korzyści:

Wzorzec 2: Dynamiczne konteksty

W niektórych scenariuszach możesz potrzebować różnych wartości kontekstu w zależności od pozycji komponentu w drzewie komponentów lub innych dynamicznych czynników. Dynamiczne konteksty pozwalają tworzyć i dostarczać wartości kontekstu, które zmieniają się w zależności od określonych warunków.

Przykład: Tworzenie motywów (theming) z dynamicznymi kontekstami

Rozważmy system motywów, w którym chcesz dostarczać różne motywy w zależności od preferencji użytkownika lub sekcji aplikacji, w której się znajduje. Możemy stworzyć uproszczony przykład z jasnym i ciemnym motywem.

// 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);
};
// Użycie
import { useTheme, ThemeProvider } from './ThemeContext';

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

  return (
    

To jest komponent z motywem.

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

W tym przykładzie ThemeProvider dynamicznie określa motyw na podstawie stanu isDarkTheme. Komponenty używające haka useTheme zostaną automatycznie ponownie renderowane, gdy motyw się zmieni.

Wzorzec 3: Kontekst z useReducer dla złożonego stanu

Do zarządzania złożoną logiką stanu, połączenie Context API z useReducer jest doskonałym podejściem. useReducer zapewnia ustrukturyzowany sposób aktualizacji stanu na podstawie akcji, a Context API pozwala na współdzielenie tego stanu i funkcji dispatch w całej aplikacji.

Przykład: Prosta lista zadań (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;
};
// Użycie
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;

Ten wzorzec centralizuje logikę zarządzania stanem wewnątrz reducera, co ułatwia jej zrozumienie i testowanie. Komponenty mogą wysyłać akcje w celu aktualizacji stanu bez konieczności bezpośredniego zarządzania stanem.

Wzorzec 4: Zoptymalizowane aktualizacje kontekstu z useMemo i useCallback

Jak wspomniano wcześniej, kluczowym zagadnieniem wydajnościowym w Context API są niepotrzebne ponowne renderowania. Użycie useMemo i useCallback może zapobiec tym ponownym renderowaniom, zapewniając, że tylko niezbędne części wartości kontekstu są aktualizowane, a referencje funkcji pozostają stabilne.

Przykład: Optymalizacja kontekstu motywu

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

Wyjaśnienie:

Bez useCallback, funkcja toggleTheme byłaby tworzona na nowo przy każdym renderowaniu ThemeProvider, co powodowałoby zmianę value i wyzwalało ponowne renderowanie we wszystkich konsumujących komponentach, nawet jeśli sam motyw się nie zmienił. useMemo zapewnia, że nowa value jest tworzona tylko wtedy, gdy zmienią się jej zależności (theme lub toggleTheme).

Wzorzec 5: Selektory kontekstu

Selektory kontekstu pozwalają komponentom subskrybować tylko określone części wartości kontekstu. Zapobiega to niepotrzebnym ponownym renderowaniom, gdy inne części kontekstu się zmieniają. Aby to osiągnąć, można użyć bibliotek takich jak `use-context-selector` lub niestandardowych implementacji.

Przykład użycia niestandardowego selektora kontekstu

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

    // Zazwyczaj w tym miejscu subskrybujesz zmiany kontekstu. Ponieważ jest to uproszczony
    // przykład, po prostu wywołamy subskrypcję natychmiast w celu inicjalizacji.
    subscription();

    return () => {
      didUnmount = true;
      // W tym miejscu anulujesz subskrypcję zmian kontekstu, jeśli ma to zastosowanie.
    };
  }, [value]); // Uruchom ponownie efekt za każdym razem, gdy wartość kontekstu się zmieni

  return selected;
}

export default useCustomContextSelector;
// ThemeContext.js (uproszczone dla zwięzłości)
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;
// Użycie
import useCustomContextSelector from './useCustomContextSelector';
import ThemeContext, { ThemeProvider, useThemeContext } from './ThemeContext';

function BackgroundComponent() {
  const background = useCustomContextSelector(ThemeContext, (context) => context.theme.background);
  return 
Tło
; } function ColorComponent() { const color = useCustomContextSelector(ThemeContext, (context) => context.theme.color); return
Kolor
; } 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;

W tym przykładzie BackgroundComponent jest ponownie renderowany tylko wtedy, gdy zmienia się właściwość background motywu, a ColorComponent jest ponownie renderowany tylko wtedy, gdy zmienia się właściwość color. Pozwala to uniknąć niepotrzebnych ponownych renderowań, gdy zmienia się cała wartość kontekstu.

Wzorzec 6: Oddzielanie akcji od stanu

W większych aplikacjach warto rozważyć rozdzielenie wartości kontekstu na dwa odrębne konteksty: jeden dla stanu, a drugi dla akcji (funkcji dispatch). Może to poprawić organizację kodu i jego testowalność.

Przykład: Lista zadań z oddzielnymi kontekstami dla stanu i akcji

// 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;
  }
};
// Użycie
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;

To rozdzielenie pozwala komponentom subskrybować tylko ten kontekst, którego potrzebują, redukując niepotrzebne ponowne renderowania. Ułatwia to również testowanie jednostkowe reducera i każdego komponentu z osobna. Ponadto, kolejność owijania providerów ma znaczenie. ActionProvider musi owijać StateProvider.

Dobre praktyki i uwagi

Podsumowanie

React Context API to wszechstronne narzędzie do zarządzania stanem. Rozumiejąc i stosując te zaawansowane wzorce, możesz skutecznie zarządzać złożonym stanem, optymalizować wydajność i budować bardziej łatwe w utrzymaniu i skalowalne aplikacje React. Pamiętaj, aby wybrać odpowiedni wzorzec do swoich konkretnych potrzeb i starannie rozważyć implikacje wydajnościowe związane z użyciem kontekstu.

W miarę ewolucji Reacta, zmieniać się będą również najlepsze praktyki dotyczące Context API. Bycie na bieżąco z nowymi technikami i bibliotekami zapewni, że będziesz przygotowany na wyzwania związane z zarządzaniem stanem w nowoczesnym tworzeniu stron internetowych. Rozważ eksplorację pojawiających się wzorców, takich jak użycie kontekstu z sygnałami (signals) w celu uzyskania jeszcze bardziej precyzyjnej reaktywności.

Zaawansowane wzorce React Context API do zarządzania stanem | MLOG