在掌握了單向資料流的基礎後,我們開始面對另一個棘手的挑戰:狀態應該存放在哪裡?剛開始,我們傾向於將所有狀態放在組件內部(Local State),但隨著代購 App 的功能日益複雜,從個人化的瀏覽紀錄到跨頁面的購物車狀態,這類資訊若僅止於局部組件,將引發難以維護的「屬性鑽取(Prop Drilling)」問題。這篇要談的是從局部狀態跨越至全域狀態(Global Store)的演進邏輯,以及如何決定資料的「居住地」。
定義:僅限於特定組件或其子組件內部使用的資料。
特點:
當多個距離遙遠的組件需要共用同一份狀態時,我們被迫將狀態層層向下傳遞。這會導致中間不相關的組件必須處理這些資料,導致程式碼冗餘且極難維護。
定義:將核心資料抽離出組件樹,存放在一個外部的儲存空間(Store)。
機制:任何組件皆可透過特定的 API 訂閱或讀取該狀態,實現狀態的「隨需取用」。
昨天講的規則——狀態只能透過 reducer、action 這條路徑被修改——並不會因為狀態搬到全域就改變,只是原本「住在一個元件裡」的狀態,現在「住在一個大家都能訂閱的地方」,管理它的規則還是同一套單向資料流。
Local State 應用:商品詳情頁中的「文字輸入框」或「展開折疊規格說明」。這些資訊僅對該商品頁有意義,不需要同步給其他頁面,存放在組件內部即可。
Global Store 應用:購物車(Cart)。購物車內容需要出現在商品瀏覽頁(顯示購物車圖示數字)、結帳頁(計算總價)、訂單確認頁。若把購物車狀態寫在局部組件,就得靠一層層傳遞把資料送達——實際會長這樣:
// 屬性鑽取:購物車數字要一路往下傳,中間元件其實用不到
function App() {
const [cartCount, setCartCount] = useState(0);
return <Layout cartCount={cartCount} />;
}
function Layout({ cartCount }) {
// Layout 自己根本用不到 cartCount,只是幫忙轉手
return <Header cartCount={cartCount} />;
}
function Header({ cartCount }) {
// 傳到這裡才終於用得到
return <CartIcon count={cartCount} />;
}
改用全域狀態之後:
// 全域狀態:誰需要誰自己拿,不用一層層傳
const CartContext = createContext();
function App() {
const [cartCount, setCartCount] = useState(0);
return (
<CartContext.Provider value={{ cartCount, setCartCount }}>
<Layout />
</CartContext.Provider>
);
}
function Layout() {
return <Header />; // 不用再接 cartCount 這個路人參數
}
function Header() {
return <CartIcon />;
}
function CartIcon() {
const { cartCount } = useContext(CartContext); // 直接拿
return <span>{cartCount}</span>;
}
Layout 跟 Header 兩層完全不用再知道 cartCount 這個參數的存在,只有真正需要它的 CartIcon 會去訂閱。這就是為什麼購物車這種跨頁面共用的狀態,適合放進 Global Store,而不是一路用 props 傳下去。
狀態管理的演進,本質上是在「隔離性」與「共享性」之間尋找平衡。局部狀態保證了組件的獨立與乾淨;全域狀態則解決了大規模應用的資料同步問題。理解這兩者的界線,是開發健壯系統的關鍵。剛開始搞不懂局部狀態和全域狀態差在哪,後來把它想成兩種不同的視角就懂了——局部狀態是用微觀去看,只在乎眼前這個元件自己的事;全域狀態則是用宏觀視角去理解整個資料架構,看的是這份資料在整個系統裡怎麼被多個地方共用。同一份購物車資料,如果只用微觀視角想「這個元件需要它」,就會很自然地想用 props 一路傳下去;只有切換到宏觀視角,才會意識到這份資料的歸屬,根本不該綁在任何一個元件身上。