สำรวจรูปแบบขั้นสูงสำหรับ React Context API รวมถึง compound components, dynamic contexts และเทคนิคการเพิ่มประสิทธิภาพสำหรับจัดการ state ที่ซับซ้อน
รูปแบบขั้นสูงของ React Context API สำหรับการจัดการ State
React Context API เป็นกลไกที่ทรงพลังสำหรับการแชร์ state ทั่วทั้งแอปพลิเคชันของคุณโดยไม่ต้องทำ prop drilling แม้ว่าการใช้งานพื้นฐานจะตรงไปตรงมา แต่การใช้ประโยชน์จากศักยภาพสูงสุดของมันจำเป็นต้องมีความเข้าใจในรูปแบบขั้นสูงที่สามารถจัดการกับสถานการณ์การจัดการ state ที่ซับซ้อนได้ บทความนี้จะสำรวจรูปแบบเหล่านี้หลายรูปแบบ พร้อมนำเสนอตัวอย่างที่ใช้งานได้จริงและข้อมูลเชิงลึกที่นำไปปฏิบัติได้เพื่อยกระดับการพัฒนา React ของคุณ
ทำความเข้าใจข้อจำกัดของ Context API พื้นฐาน
ก่อนที่จะไปดูรูปแบบขั้นสูง สิ่งสำคัญคือต้องเข้าใจข้อจำกัดของ Context API พื้นฐาน แม้ว่าจะเหมาะสำหรับ state ที่เข้าถึงได้จากทั่วโลกและไม่ซับซ้อน แต่มันอาจจะจัดการได้ยากและไม่มีประสิทธิภาพสำหรับแอปพลิเคชันที่ซับซ้อนซึ่งมี state ที่เปลี่ยนแปลงบ่อยครั้ง ทุก component ที่ใช้ context จะ re-render ใหม่ทุกครั้งที่ค่า context เปลี่ยนแปลง แม้ว่า component นั้นจะไม่ได้ใช้ส่วนของ state ที่ถูกอัปเดตก็ตาม สิ่งนี้อาจนำไปสู่ปัญหาคอขวดด้านประสิทธิภาพได้
รูปแบบที่ 1: Compound Components ร่วมกับ Context
รูปแบบ Compound Component ช่วยเสริมการทำงานของ Context API โดยการสร้างชุดของ components ที่เกี่ยวข้องกันซึ่งแชร์ state และ logic ผ่าน context โดยปริยาย รูปแบบนี้ส่งเสริมการนำกลับมาใช้ใหม่และทำให้ API สำหรับผู้ใช้งานง่ายขึ้น ซึ่งช่วยให้สามารถห่อหุ้ม logic ที่ซับซ้อนไว้ในการใช้งานที่เรียบง่าย
ตัวอย่าง: Tab Component
เรามาดูตัวอย่างนี้ด้วย Tab component แทนที่จะส่ง props ลงไปหลายๆ ชั้น Tab
components จะสื่อสารกันโดยปริยายผ่าน context ที่ใช้ร่วมกัน
// 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}
);
};
// Usage
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;
ประโยชน์:
- API ที่ง่ายสำหรับผู้ใช้งาน: ผู้ใช้เพียงแค่ต้องสนใจ
Tab
,TabList
, และTabPanel
เท่านั้น - การแชร์ state โดยปริยาย: Components จะเข้าถึงและอัปเดต state ที่ใช้ร่วมกันโดยอัตโนมัติ
- ปรับปรุงการนำกลับมาใช้ใหม่:
Tab
component สามารถนำกลับมาใช้ใหม่ใน contexts ที่แตกต่างกันได้อย่างง่ายดาย
รูปแบบที่ 2: Dynamic Contexts
ในบางสถานการณ์ คุณอาจต้องการค่า context ที่แตกต่างกันขึ้นอยู่กับตำแหน่งของ component ใน component tree หรือปัจจัยไดนามิกอื่นๆ Dynamic contexts ช่วยให้คุณสามารถสร้างและให้ค่า context ที่แตกต่างกันไปตามเงื่อนไขเฉพาะได้
ตัวอย่าง: การทำ Theming ด้วย Dynamic Contexts
ลองพิจารณาระบบการทำ a theming ที่คุณต้องการให้มี theme ที่แตกต่างกันตามความชอบของผู้ใช้หรือส่วนของแอปพลิเคชันที่พวกเขากำลังใช้งานอยู่ เราสามารถสร้างตัวอย่างง่ายๆ ด้วย light และ dark theme
// 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);
};
// Usage
import { useTheme, ThemeProvider } from './ThemeContext';
function MyComponent() {
const { theme, toggleTheme } = useTheme();
return (
This is a themed component.
);
}
function App() {
return (
);
}
export default App;
ในตัวอย่างนี้ ThemeProvider
จะกำหนด theme แบบไดนามิกตาม state ของ isDarkTheme
Components ที่ใช้ hook useTheme
จะ re-render ใหม่โดยอัตโนมัติเมื่อ theme เปลี่ยนแปลง
รูปแบบที่ 3: Context ร่วมกับ useReducer สำหรับ State ที่ซับซ้อน
สำหรับการจัดการ logic ของ state ที่ซับซ้อน การรวม Context API กับ useReducer
เป็นแนวทางที่ยอดเยี่ยม useReducer
ให้วิธีที่เป็นโครงสร้างในการอัปเดต state ตาม actions และ Context API ช่วยให้คุณสามารถแชร์ state นี้และฟังก์ชัน 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;
};
// Usage
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;
รูปแบบนี้จะรวมศูนย์ logic การจัดการ state ไว้ใน reducer ทำให้ง่ายต่อการทำความเข้าใจและทดสอบ Components สามารถ dispatch actions เพื่ออัปเดต state ได้โดยไม่ต้องจัดการ state โดยตรง
รูปแบบที่ 4: การอัปเดต Context อย่างมีประสิทธิภาพด้วย `useMemo` และ `useCallback`
ดังที่ได้กล่าวไว้ก่อนหน้านี้ ข้อควรพิจารณาที่สำคัญด้านประสิทธิภาพของ Context API คือการ re-render ที่ไม่จำเป็น การใช้ useMemo
และ useCallback
สามารถป้องกันการ re-render เหล่านี้ได้โดยทำให้แน่ใจว่ามีการอัปเดตเฉพาะส่วนที่จำเป็นของค่า context และ reference ของฟังก์ชันยังคงเสถียร
ตัวอย่าง: การเพิ่มประสิทธิภาพ Theme Context
// 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
จะทำการ memoize ฟังก์ชันtoggleTheme
ซึ่งทำให้มั่นใจได้ว่า reference ของฟังก์ชันจะเปลี่ยนแปลงก็ต่อเมื่อisDarkTheme
เปลี่ยนแปลงเท่านั้น เป็นการป้องกันการ re-render ที่ไม่จำเป็นของ components ที่ขึ้นอยู่กับฟังก์ชันtoggleTheme
เพียงอย่างเดียวuseMemo
จะทำการ memoize ค่าของ context ซึ่งทำให้มั่นใจได้ว่าค่าของ context จะเปลี่ยนแปลงก็ต่อเมื่อtheme
หรือฟังก์ชันtoggleTheme
เปลี่ยนแปลงเท่านั้น ซึ่งช่วยป้องกันการ re-render ที่ไม่จำเป็นเพิ่มเติม
หากไม่มี useCallback
ฟังก์ชัน toggleTheme
จะถูกสร้างขึ้นใหม่ทุกครั้งที่มีการ render ThemeProvider
ซึ่งจะทำให้ value
เปลี่ยนแปลงและกระตุ้นให้เกิดการ re-render ใน components ที่ใช้งาน แม้ว่า theme เองจะไม่ได้เปลี่ยนแปลงก็ตาม useMemo
ช่วยให้มั่นใจว่า value
ใหม่จะถูกสร้างขึ้นก็ต่อเมื่อ dependencies ของมัน (theme
หรือ toggleTheme
) เปลี่ยนแปลงเท่านั้น
รูปแบบที่ 5: Context Selectors
Context selectors ช่วยให้ components สามารถติดตาม (subscribe) เฉพาะบางส่วนของค่า context ได้ ซึ่งจะช่วยป้องกันการ re-render ที่ไม่จำเป็นเมื่อส่วนอื่นๆ ของ context เปลี่ยนแปลง สามารถใช้ไลบรารีอย่าง `use-context-selector` หรือการสร้างขึ้นเองเพื่อให้ได้ผลลัพธ์นี้
ตัวอย่างการใช้ Custom 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;
// Usage
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
จะ re-render ใหม่ก็ต่อเมื่อ property background
ของ theme เปลี่ยนแปลงเท่านั้น และ ColorComponent
จะ re-render ใหม่ก็ต่อเมื่อ property color
เปลี่ยนแปลงเท่านั้น ซึ่งจะหลีกเลี่ยงการ re-render ที่ไม่จำเป็นเมื่อค่า context ทั้งหมดเปลี่ยนแปลง
รูปแบบที่ 6: การแยก Actions ออกจาก State
สำหรับแอปพลิเคชันขนาดใหญ่ ควรพิจารณาแยกค่า context ออกเป็นสอง context ที่แตกต่างกัน: context หนึ่งสำหรับ state และอีก context หนึ่งสำหรับ actions (ฟังก์ชัน dispatch) ซึ่งจะช่วยปรับปรุงการจัดระเบียบโค้ดและความสามารถในการทดสอบ
ตัวอย่าง: Todo List ที่มีการแยก State และ Action Contexts
// 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;
}
};
// Usage
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;
การแยกส่วนนี้ช่วยให้ components สามารถติดตามเฉพาะ context ที่ต้องการได้ ซึ่งช่วยลดการ re-render ที่ไม่จำเป็น และยังทำให้การทำ unit test สำหรับ reducer และแต่ละ component แยกจากกันง่ายขึ้นด้วย นอกจากนี้ ลำดับการครอบ Provider ก็มีความสำคัญ โดย ActionProvider
จะต้องครอบ StateProvider
แนวทางปฏิบัติที่ดีที่สุดและข้อควรพิจารณา
- Context ไม่ควรมาแทนที่ไลบรารีการจัดการ state ทั้งหมด: สำหรับแอปพลิเคชันที่มีขนาดใหญ่และซับซ้อนมาก ไลบรารีการจัดการ state โดยเฉพาะอย่าง Redux หรือ Zustand อาจยังคงเป็นตัวเลือกที่ดีกว่า
- หลีกเลี่ยงการใช้ context มากเกินไป: ไม่ใช่ทุก state ที่จำเป็นต้องอยู่ใน context ควรใช้ context อย่างรอบคอบสำหรับ state ที่เป็น global หรือใช้ร่วมกันอย่างแพร่หลายจริงๆ
- การทดสอบประสิทธิภาพ: ควรวัดผลกระทบด้านประสิทธิภาพของการใช้ context ของคุณเสมอ โดยเฉพาะเมื่อต้องจัดการกับ state ที่มีการอัปเดตบ่อยครั้ง
- Code Splitting: เมื่อใช้ Context API ควรพิจารณาการทำ code-splitting แอปพลิเคชันของคุณเป็นส่วนเล็กๆ ซึ่งมีความสำคัญอย่างยิ่งเมื่อการเปลี่ยนแปลงเล็กน้อยใน state ทำให้ส่วนใหญ่ของแอปพลิเคชันต้อง re-render ใหม่
สรุป
React Context API เป็นเครื่องมือที่หลากหลายสำหรับการจัดการ state ด้วยความเข้าใจและการประยุกต์ใช้รูปแบบขั้นสูงเหล่านี้ คุณจะสามารถจัดการ state ที่ซับซ้อน เพิ่มประสิทธิภาพ และสร้างแอปพลิเคชัน React ที่บำรุงรักษาและขยายขนาดได้ง่ายขึ้น อย่าลืมเลือกรูปแบบที่เหมาะสมกับความต้องการเฉพาะของคุณ และพิจารณาผลกระทบด้านประสิทธิภาพของการใช้ context อย่างรอบคอบ
เมื่อ React พัฒนาขึ้น แนวทางปฏิบัติที่ดีที่สุดเกี่ยวกับ Context API ก็จะพัฒนาไปด้วย การติดตามข่าวสารเกี่ยวกับเทคนิคและไลบรารีใหม่ๆ จะช่วยให้คุณพร้อมรับมือกับความท้าทายในการจัดการ state ของการพัฒนาเว็บสมัยใหม่ ลองพิจารณาสำรวจรูปแบบใหม่ๆ ที่เกิดขึ้น เช่น การใช้ context กับ signals เพื่อให้ได้ reactivity ที่ละเอียดมากยิ่งขึ้น