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 (
{isActive && children}
);
};
// 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:
- Uproszczone API dla konsumentów: Użytkownicy muszą martwić się tylko o
Tab
,TabList
iTabPanel
. - Niejawne współdzielenie stanu: Komponenty automatycznie uzyskują dostęp i aktualizują współdzielony stan.
- Poprawiona reużywalność: Komponent
Tab
może być łatwo ponownie użyty w różnych kontekstach.
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 (
);
}
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:
useCallback
memoizuje funkcjętoggleTheme
. Zapewnia to, że referencja do funkcji zmienia się tylko wtedy, gdy zmienia sięisDarkTheme
, zapobiegając niepotrzebnym ponownym renderowaniom komponentów, które zależą tylko od funkcjitoggleTheme
.useMemo
memoizuje wartość kontekstu. Zapewnia to, że wartość kontekstu zmienia się tylko wtedy, gdy zmienia siętheme
lub funkcjatoggleTheme
, co dodatkowo zapobiega niepotrzebnym ponownym renderowaniom.
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 (
);
}
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
- Kontekst nie powinien zastępować wszystkich bibliotek do zarządzania stanem: W przypadku bardzo dużych i złożonych aplikacji dedykowane biblioteki do zarządzania stanem, takie jak Redux czy Zustand, mogą być wciąż lepszym wyborem.
- Unikaj nadmiernego używania kontekstu: Nie każda część stanu musi znajdować się w kontekście. Używaj kontekstu rozważnie dla stanu, który jest naprawdę globalny lub szeroko współdzielony.
- Testowanie wydajności: Zawsze mierz wpływ użycia kontekstu na wydajność, zwłaszcza gdy masz do czynienia z często aktualizowanym stanem.
- Dzielenie kodu (Code Splitting): Używając Context API, rozważ podzielenie aplikacji na mniejsze części. Jest to szczególnie ważne, gdy niewielka zmiana w stanie powoduje ponowne renderowanie dużej części aplikacji.
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.