React 是單向資料流,利用 React.createElement 建構出整個 element tree 結構,使用者利用 state 及 props 處理元件資料,並搭配觸發 react 更新元件。因為 props 是需要傳遞的,所以時常會遇到 props 需要傳很多層。React 為了解決這問題,建立了 context API 的功能,要功能就是跨元件傳遞資料,像是知名的 state 管理工具 react-redux 就是依賴 context 實現的。

最近就遇到所有 API Error handle 都需要用到新欄位的值,這個值就幾乎傳到到所有元件,中間還不小心遺漏傳遞一個元件,發生些問題…。現在回過頭想想用 context 處理問題會少很多。

  • Pass props Hell
<Header islogin={islogin}/>
// inside Header Element
  <Navbar islogin={islogin} />
  // inside Navbar Element
    <Account islogin={islogin} />
    // inside Account Element
      <User islogin={islogin} />
        {islogin ?
          <Button onClick={Logout}>Logout</Button>
        :

// Pass all props
// it will make child Components rerender by all props update
<Header {...props}/>

createContext 建立資料

首先必須先在需要使用的元件內,先執行React.createContext建立一個 context,其中參數 defaultValue 只會在沒有 Provider 傳遞 value 才會使用到。

  • React.createContext return object
// React.createContext(defaultValue);
const MyContext = React.createContext({isLogin: false});

$$typeof: Symbol(react.context)
Consumer: {$$typeof: Symbol(react.context), _context: {}, _calculateChangedBits: null,}
Provider: {$$typeof: Symbol(react.provider), _context: {}}
_calculateChangedBits: null
_currentRenderer: {}
_currentRenderer2: null
_currentValue: {isLogin: false}
_currentValue2: {isLogin: false}
_threadCount: 0
__proto__: Object

Provider 提供 value

調用 createContext 後,回傳的物件會帶有 Provider、Consumer 元件,Provider 可以提供 value,給相對應最接近的 Consumer 使用 value,最特別的是 Provider 更新 value 後,會觸發相對應的 Consumer 更新元件,並且無視 shouldComponentUpdate 限制 (這在舊版 Context 無法達到)。

記得要 export React.createContext 回傳值,讓其他元件可以直接 import 使用 Consumer。還有提醒要注意 Provider 的 update 狀態,如果 Provider 的元件會頻繁更新,但 Provider 的 value 會每次都是新物件,會促使有 Cosumer 的元件每次都 update。

  • 當元件 rerender 會同時更新 Consumer 調用的元件
// isLogin will forever new one
  render() {
    return (
      <MyContext.Provider value={{ isLogin: isLogin }}>
  • 傳遞的值保持同一參考 MyContext Provider
export const MyContext = React.createContext({ isLogin: false });

export default class Container extends Component {
  state = {
    isLoginStatus: { isLogin: true }
  };

  render() {
    const { isLoginStatus } = this.state;
    return (
      <MyContext.Provider value={isLoginStatus}>
...

Consumer 提取 value

Consumer 元件可以獲取 context 資料,假設沒有最接近的 Provider 提供 value,Cosumer 會取到 createContext 的 defaultvalue。若有 Provider 提供值,則是會保持訂閱更新,也就是達到跨元件同步資料,並 update component。

import React from 'react';
import { MyContext } from '../Container';

export default function Account() {
    return (
        <div className="account">
            <MyContext.Consumer>
                {({ isLogin }) => {
                    return isLogin ? 'Logout' : 'Login';
                }}
            </MyContext.Consumer>
        </div>
    );
}

Consumer codesandbox

context 更新 rerender 取用元件

context 的 Provider 更新 value 時,會一起更新 context Consumer 的取用元件,並且無視於 shouldComponentUpdate。

Consumer shouldComponentUpdate codesandbox

contextType 取值

contextType 是直接在 react 的 component 的 instance 再加上 context,所以只能用在 class Component,一個元件只能使用一個 context。

import React, { Component } from 'react';
import MyContext from '../context/MyContext';

export default class Account extends Component {
    static contextType = MyContext;
    render() {
        const { isLogin, setLogin } = this.context;
        return (
            <div className="account">
                <div>
                    {isLogin ? 'Logout' : 'Login'}
                    <button onClick={setLogin}>toggleLogin</button>
                </div>
            </div>
        );
    }
}

我在這邊有遇到一個問題,在 Container component export context,並在 Account 引用 Container export 的 context 時,會發生我取不到值得問題,這是因為循環依賴的關係,在我們 Account 引用 Container 內的 MyContext 時,ES6 只會是 referrence MyContext undefined 狀態,實際在 Container 還尚未建立 createContext,這個 Account 又會再初始化階段就執行 MyContext,導致拿到 empty object。

解法就是獨立建 MyContext ,解除與 Container 關係,就可以避免掉循環依賴的問題。至於 Consumer 會沒有問題,因為 Consumer 是在 render 時才會調用參考,所以會拿到正確的值。

// Container File
import Account from "./components/Account";
export const MyContext = React.createContext({
  isLogin: false
});
export default class Container extends Component {
  state = {
    isLogin: true
  };
...
      <MyContext.Provider value={{ isLogin: isLogin, setLogin: this.setLogin }}>
        <Account />
      </MyContextProvider>
}

// Account File

import MyContext from "../Container";

export default class Account extends Component {
  // MyContext undefined
  static contextType = MyContext;
  render() {
    // empty object
    console.log(this.context);
...

Dan 神表示: React contextType undefined GitHub issue

how-to-analyze es6 circular-dependencies

Hooks useContext

React Hooks 有可以直接調用 Context 的方法,useContext 與 Consumer 特性相似,當沒有 Provider 提供 value,就會以 defaultValue 為值,提醒有用到 useContext 的元件當 value 更新時皆會 rerender,rerender 效能不好的話,建議搭配 Memo 來做 memorize。

const value = useContext(MyContext);

Preventing rerenders with React.memo and useContext hook.
Preventing rerenders with React.memo and useContext hook.

import React, { useContext, useMemo } from 'react';
import MyContext from '../context/MyContext';

export default function Account() {
    const { isLogin, setLogin } = useContext(MyContext);
    return useMemo(() => {
        return (
            <div className="account">
                <div>
                    {isLogin ? 'Logout' : 'Login'}
                    <button onClick={setLogin}>toggleLogin</button>
                </div>
            </div>
        );
    }, [isLogin, setLogin]);
}

useContext with useMemo codesandbox

心得

會特別研究寫關於 context API 內容,是因為目前專案幾乎都沒用到,多半還是以 redux 居多,redux 更新版 hooks 也有 useSelector,也是非常好用,雖然常聽到 useReucer、useContext 幾乎可以取代 redux。

但 redux 有極好用的 debug 工具,devtool 觀看變化、history、dispatch,這些都是無法取代的功能。與夥伴討論過後,認為某些無狀態不需要更新值,我們才會考慮用 context API,因為不需要 update,也沒有隨之的監控更新需求。