在我們開發應用程式,常常會需要把父組件的狀態傳遞給子元件,然而,有時候需要傳遞的層級很深,這時候就會出現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在這裡可以看到。
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 的值經常改變時。
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。
在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>
);
}
如果你什麼第三方狀態管理庫都不想用,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>
);
}