preview image

如何避免prop drilling

在我們開發應用程式,常常會需要把父組件的狀態傳遞給子元件,然而,有時候需要傳遞的層級很深,這時候就會出現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,這樣就可以讓 ChildGrandChild 直接使用這個 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>
    )
}