在我們開發應用程式,常常會需要把父組件的狀態傳遞給子元件,然而,有時候需要傳遞的層級很深,這時候就會出現prop drilling的問題。
什麼是prop drilling?
首先,讓我們來看一個簡單的例子:
import React, { useState } from 'react';
const ParentComponent = () => {
const [value, setValue] = useState("Hello World");
return (
<ChildComponent value={value} />
);
};
const ChildComponent = ({ value } : {value: string}) => {
return (
<GrandChildComponent value={value} />
);
};
const GrandChildComponent = ({ value } : {value : string}) => {
return <div>{value}</div>;
};
在這個例子中,我們想把ParentComponent中的value傳遞給GrandChildComponent,但是我們需要先通過ChildComponent來傳遞。這樣的傳遞過程就叫做prop drilling。
這段程式碼中,ParentComponent需要將value傳遞給ChildComponent,然後再傳遞給GrandChildComponent。
如果有更多層的元件,這樣的傳遞會變得非常繁瑣,而且,這在typescript中,會變得更麻煩,因為你需要在每一個子元件中,幫他標上他的型態,要是今天這個在父型態變了,你的每個子元件型態都要自己去改。
解決方案
這邊會有己只種方法可以解決prop drilling的問題。
在接下來的程式,我們會大量使用這個程式碼,方便觀察 component的更新,請注意,這個component並沒有遵守。
import type React from "react";
export function RandomBackgroundColorBlock({ children }: { children: React.ReactNode; }) {
const colors = ["bg-red-900", "bg-green-900", "bg-blue-900", "bg-yellow-900", "bg-purple-900", "bg-pink-900", "bg-indigo-900", "bg-teal-900"];
const cn = colors[Math.floor(Math.random() * colors.length)] + " *:my-2 border p-4 *:h1:text-4xl";
return <div className={cn}>
{children}
</div>;
}
另外,這些程式的DEMO在這裡可以看到。
1. 使用Context API
Context 是 React 提供的一種機制,可以讓我們在組件樹中傳遞資料,而不需要手動地將 props 一層一層地傳遞下去。
import React from "react";
import { RandomBackgroundColorBlock } from "./RandomBackgroundColorBlock";
interface SharedContext {
themeValue: string;
setThemeValue: (value: string) => void;
}
const Context = React.createContext<SharedContext | undefined>(undefined);
// Custom hook to use the shared context, we do this to ensure that the context is always used within a provider
export function useSharedContext() {
const contextValue = React.useContext(Context);
if (!contextValue) {
throw new Error(
"useSharedContext must be used within a SharedProvider, please wrap your component tree with <SharedProvider>.",
);
}
return contextValue;
}
export function Parent() {
const [themeValue, setThemeValue] = React.useState("light");
return (
<Context.Provider value={{ themeValue, setThemeValue }}>
<RandomBackgroundColorBlock>
<h1>Parent - current value : {themeValue}</h1>
<Child />
</RandomBackgroundColorBlock>
</Context.Provider>
);
}
export function Child() {
const [counter, setCounter] = React.useState(0);
return (
<RandomBackgroundColorBlock>
<h1>Child Component</h1>
<p>Counter: {counter}</p>
<button onClick={() => setCounter(counter + 1)}>
Increment Counter
</button>
<GrandChild />
</RandomBackgroundColorBlock>
);
}
export function GrandChild() {
const { themeValue, setThemeValue } = useSharedContext();
return (
<RandomBackgroundColorBlock>
<h1>GrandChild Component</h1>
<p>Current Theme: {themeValue}</p>
<button
onClick={() =>
setThemeValue(themeValue === "light" ? "dark" : "light")
}
>
Toggle Theme
</button>
</RandomBackgroundColorBlock>
);
}
在這個例子中,我們使用了 React 的 Context API 來共享狀態。Parent component中,包含 了 Provider,這樣就可以讓 Child 和 GrandChild 直接使用這個 context,而不需要手動傳遞 props。
這樣的好處是,我們可以在任何需要使用這個 context 的地方,直接使用 useSharedContext hook (這邊我們包裝成一個 custom hook來避免 Parent中沒有用Provider包裹) 來獲取 context 的值,而不需要手動傳遞 props。
但是,Context 也有一些缺點。當 context 的值改變時,Provider底下的所有component都會重新渲染,這可能會導致性能問題,特別是當 context 的值經常改變時。
2. 使用Redux
Redux 是一個流行且歷史悠久的狀態管理lib,可以幫助我們管理應用程式的狀態。它提供了一個套件讓我們可以在應用程式中使用全域狀態,而不需要手動地將 props 一層一層地傳遞下去。
這邊我們使用 @reduxjs/toolkit 來簡化 Redux 的使用。
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { Provider, useSelector, useDispatch } from 'react-redux'
import { RandomBackgroundColorBlock } from './RandomBackgroundColorBlock';
// theme state
interface ThemeState {
themeValue: "light" | "dark";
}
const themeSlice = createSlice({
name: "theme",
initialState: { themeValue: "light" } as ThemeState,
reducers: {
switchTheme: (state) => {
state.themeValue = state.themeValue === "light" ? "dark" : "light";
},
switchToLight: (state, actions: PayloadAction<number>) => {
// an example of using payload, here we just log it
console.log("switchToLight action payload:", actions.payload);
state.themeValue = "light";
},
switchToDark: (state) => {
state.themeValue = "dark";
}
}
})
export const { switchTheme, switchToDark, switchToLight } = themeSlice.actions;
const store = configureStore({
reducer: {
theme: themeSlice.reducer
}
})
type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;
export default function Parent() {
return <Provider store={store}>
<RandomBackgroundColorBlock>
<h1>a redux example</h1>
<Child1 />
<Child2 />
</RandomBackgroundColorBlock>
</Provider>
}
export function Child1() {
const dispatch = useDispatch<AppDispatch>();
return <RandomBackgroundColorBlock>
<h1>Child1</h1>
<div className="flex gap-2 *:p-2">
<button onClick={()=>dispatch(switchToDark())} className="bg-amber-950 rounded">switch dark</button>
<button onClick={()=>dispatch(switchToLight(5))} className="bg-amber-200 rounded text-cyan-900">switch light</button>
<button onClick={()=>dispatch(switchTheme())} className="bg-amber-500 rounded text-cyan-900">switch theme</button>
</div>
</RandomBackgroundColorBlock>
}
export function Child2() {
const themeValue = useSelector((state: RootState) => state.theme.themeValue);
const dispatch = useDispatch<AppDispatch>();
const cn = themeValue === "dark" ? "bg-gray-800 text-amber-50 p-4" : "bg-white text-gray-900 p-4";
// generate a random color hex for the border
// this is just for checking that the component is re-rendering or not.
// when re-rendering, the border color will change.
const randomColorHex = Math.random().toString(16).slice(2, 8);
return <div className={cn} style={{ borderColor: `#${randomColorHex}`, borderWidth: "8px", borderStyle: "solid" }}>
<h1>Child2</h1>
<p>Current Theme: {themeValue}</p>
<button onClick={()=>dispatch(switchTheme())} className="bg-amber-500 rounded text-cyan-900 w-auto p-2">switch theme</button>
</div>
}
在這個例子中,我們使用了 Redux 來管理應用程式的狀態。我們創建了一個 themeSlice,它包含了 themeValue 的狀態和一些操作(action)來修改這個狀態。
然後,我們使用 configureStore 來創建一個 Redux store,並透過 Provider傳進去 React。
在 Child1 中,我們使用 useDispatch 來獲取 dispatch 函數,然後使用它來發送 action 來修改狀態。
在 Child2 中,我們使用 useSelector 來獲取 Redux store 中的狀態,然後根據這個狀態來渲染 component。
3. 使用Zustand
在Redux的例子中,相信我們都可以同意,Redux的使用有點繁瑣,有時候我們根本不需想要用 Redux之於 Reducer 的概念,這時候我們可以使用Zustand。
import { configureStore, createSlice, type PayloadAction } from '@reduxjs/toolkit'
import { Provider, useSelector, useDispatch } from 'react-redux'
import { RandomBackgroundColorBlock } from './RandomBackgroundColorBlock';
// theme state
interface ThemeState {
themeValue: "light" | "dark";
}
const themeSlice = createSlice({
name: "theme",
initialState: { themeValue: "light" } as ThemeState,
reducers: {
switchTheme: (state) => {
state.themeValue = state.themeValue === "light" ? "dark" : "light";
},
switchToLight: (state, actions: PayloadAction<number>) => {
console.log("switchToLight action payload:", actions.payload);
state.themeValue = "light";
},
switchToDark: (state) => {
state.themeValue = "dark";
}
}
})
export const { switchTheme, switchToDark, switchToLight } = themeSlice.actions;
const store = configureStore({
reducer: {
theme: themeSlice.reducer
}
})
type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;
export default function Parent() {
return <Provider store={store}>
<RandomBackgroundColorBlock>
<h1>a redux example</h1>
<Child1 />
<Child2 />
</RandomBackgroundColorBlock>
</Provider>
}
export function Child1() {
const dispatch = useDispatch<AppDispatch>();
return <RandomBackgroundColorBlock>
<h1>Child1</h1>
<div className="flex gap-2 *:p-2">
<button onClick={()=>dispatch(switchToDark())} className="bg-amber-950 rounded">switch dark</button>
<button onClick={()=>dispatch(switchToLight(5))} className="bg-amber-200 rounded text-cyan-900">switch light</button>
<button onClick={()=>dispatch(switchTheme())} className="bg-amber-500 rounded text-cyan-900">switch theme</button>
</div>
</RandomBackgroundColorBlock>
}
export function Child2() {
const themeValue = useSelector((state: RootState) => state.theme.themeValue);
const dispatch = useDispatch<AppDispatch>();
const cn = themeValue === "dark" ? "bg-gray-800 text-amber-50 p-4" : "bg-white text-gray-900 p-4";
// generate a random color hex for the border
// this is just for checking that the component is re-rendering or not.
// when re-rendering, the border color will change.
const randomColorHex = Math.random().toString(16).slice(2, 8);
return <div className={cn} style={{ borderColor: `#${randomColorHex}`, borderWidth: "8px", borderStyle: "solid" }}>
<h1>Child2</h1>
<p>Current Theme: {themeValue}</p>
<button onClick={()=>dispatch(switchTheme())} className="bg-amber-500 rounded text-cyan-900 w-auto p-2">switch theme</button>
</div>
}
4. 使用 useSyncExternalStore
如果你什麼第三方狀態管理庫都不想用,React 18 提供了一個新的 hook useSyncExternalStore,可以讓我們自己實現一個簡單的狀態管理。
useSyncExternalStore 是 React 18 新增的 hook,許多狀態管理庫都會使用這個 hook 來實現狀態的同步。
我們可以透過這個 hook 來實現一個簡單的狀態管理。
import { useSyncExternalStore } from 'react'
import { RandomBackgroundColorBlock } from './RandomBackgroundColorBlock'
type Theme = 'light' | 'dark'
interface ThemeState {
theme: Theme
switchToLight: () => void
switchToDark: () => void
toggleTheme: () => void
}
// Create external store
class ThemeStore {
private state: { theme: Theme } = { theme: 'light' }
private listeners = new Set<() => void>()
getState = () => this.state
setState = (newState: Partial<{ theme: Theme }>) => {
this.state = { ...this.state, ...newState }
this.listeners.forEach(listener => listener())
}
subscribe = (listener: () => void) => {
// we add the listener to the set of listeners
// and call it when
this.listeners.add(listener)
// clean up the listener when it is no longer needed
return () => this.listeners.delete(listener)
}
switchToLight = () => this.setState({ theme: 'light' })
switchToDark = () => this.setState({ theme: 'dark' })
toggleTheme = () => this.setState({
theme: this.state.theme === 'light' ? 'dark' : 'light'
})
}
const themeStore = new ThemeStore()
// Custom hook using useSyncExternalStore
function useThemeStore<T>(selector: (state: ThemeState) => T): T {
return useSyncExternalStore(
themeStore.subscribe,
() => selector({
theme: themeStore.getState().theme,
switchToLight: themeStore.switchToLight,
switchToDark: themeStore.switchToDark,
toggleTheme: themeStore.toggleTheme
})
)
}
export default function Parent() {
return (
<RandomBackgroundColorBlock>
<h1>useSyncExternalStore Example</h1>
<Child1 />
<Child2 />
</RandomBackgroundColorBlock>
)
}
export function Child1() {
const switchToLight = useThemeStore((state) => state.switchToLight)
const switchToDark = useThemeStore((state) => state.switchToDark)
const toggleTheme = useThemeStore((state) => state.toggleTheme)
return (
<RandomBackgroundColorBlock>
<h1>Child1</h1>
<div className="flex gap-2 *:p-2">
<button onClick={switchToDark} className="bg-amber-950 rounded">Switch to Dark</button>
<button onClick={switchToLight} className="bg-amber-200 rounded text-cyan-900">Switch to Light</button>
<button onClick={toggleTheme} className="bg-amber-500 rounded text-cyan-900">Toggle Theme</button>
</div>
</RandomBackgroundColorBlock>
)
}
export function Child2() {
const theme = useThemeStore((state) => state.theme)
const toggleTheme = useThemeStore((state) => state.toggleTheme)
const cn = (theme === 'light' ? 'bg-white text-black' : 'bg-gray-800 text-white') + ' p-4'
const randomColorHex = Math.random().toString(16).slice(2, 8)
return (
<div className={cn} style={{ borderColor: `#${randomColorHex}`, borderWidth: "8px", borderStyle: "solid" }}>
<h1>Child2</h1>
<p>Current Theme: {theme}</p>
<button onClick={toggleTheme} className="bg-amber-500 rounded text-cyan-900 w-auto p-2">switch theme</button>
</div>
)
}