都 2022 年了你還是可能不懂 useEffect


Zet Chou@iCHEF

2022.08.04 / ReactJS.tw 社群小聚

  • iCHEF Senior Front-End Engineer

  • 七年 React 開發經驗

  • ALPHA Camp 社群助教

  • 曾任 SITCON / JSDC 主議程講者

  • JSDC 2019 / 2020 議程組工作人員

Zet Chou

周昱安

About me

在進入到 useEffect 之前...

Function component & Class component

你可能不知道的關鍵區別

Function components capture

the rendered values.

Consider this demo...

function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}
class ProfilePage extends React.Component {
  showMessage = () => {
    alert('Followed ' + this.props.user);
  };

  handleClick = () => {
    setTimeout(this.showMessage, 3000);
  };

  render() {
    return <button onClick={this.handleClick}>Follow</button>;
  }
}

Capture the props

class ProfilePage extends React.Component {
  render() {
    // Capture the props!
    const props = this.props;

    // Note: we are *inside render*.
    // These aren't class methods.
    const showMessage = () => {
      alert('Followed ' + props.user);
    };

    const handleClick = () => {
      setTimeout(showMessage, 3000);
    };

    return <button onClick={handleClick}>Follow</button>;
  }
}
function ProfilePage(props) {
  const showMessage = () => {
    alert('Followed ' + props.user);
  };

  const handleClick = () => {
    setTimeout(showMessage, 3000);
  };

  return (
    <button onClick={handleClick}>Follow</button>
  );
}

State

function MessageThread() {
  const [message, setMessage] = useState('');

  const showMessage = () => {
    alert('You said: ' + message);
  };

  const handleSendClick = () => {
    setTimeout(showMessage, 3000);
  };

  const handleMessageChange = (e) => {
    setMessage(e.target.value);
  };

  return (
    <>
      <input value={message} onChange={handleMessageChange} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}
  • Class component 是基於 OOP,以 mutate 來存取 this.props / this.state
  • Function component 則是基於 FP,以每次都重新執行函數並傳入獨立參數的方式來存取 props / state

每一次 render 都有他自己的...

每ㄧ次 render 都有它自己的 props & state

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}
// 在第一次 render 時
function Counter() {
  const count = 0; // 被 useState() 回傳
  // ...
  <p>You clicked {count} times</p>
  // ...
}
// 經過一次點擊,我們的 component function 再次被呼叫
function Counter() {
  const count = 1; // 被 useState() 回傳
  // ...
  <p>You clicked {count} times</p>
  // ...
}
// 經過另一次點擊,我們的 component function 再次被呼叫
function Counter() {
  const count = 2; // 被 useState() 回傳
  // ...
  <p>You clicked {count} times</p>
  // ...
}

每當我們 setState,React 就會重新呼叫 component 函式來執行一次 render。

每次 render 時都會捕捉到屬於它自己的 counter state 的值,

這些值是個只存在於該次 render 中的常數。

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

每ㄧ次 render 都有它自己的 event handlers

function Counter() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}
function sayHi(person) {
  setTimeout(() => {
    alert('Hello, ' + person.name);
  }, 3000);
}

let someone = { name: 'Dan' };
sayHi(someone);

someone = { name: 'Zet' };
sayHi(someone);

someone = { name: 'Foo' };
sayHi(someone);

每ㄧ次 render 都有它自己的 event handlers

function Counter() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}
// 在第一次 render 時
function Counter() {
  const count = 0; // 被 useState() 回傳
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  // ...
  // 裡面的 count 是 0 的那個版本的 handleAlertClick
  <button onClick={handleAlertClick} />
  // ...
}
// 經過一次點擊,我們的 component function 再次被呼叫
function Counter() {
  const count = 1; // 被 useState() 回傳
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  // ...
  // 裡面的 count 是 1 的那個版本的 handleAlertClick
  <button onClick={handleAlertClick} />
  // ...
}
// 經過另一次點擊,我們的 component function 再次被呼叫
function Counter() {
  const count = 2; // 被 useState() 回傳
  // ...
  function handleAlertClick() {
    setTimeout(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }
  // ...
  // 裡面的 count 是 2 的那個版本的 handleAlertClick
  <button onClick={handleAlertClick} />
  // ...
}
  • 在每一次的 render 之間的 props & state 都是獨立、不互相影響的
  • 在每一次的 render 中的 props & state 永遠都會保持不變,像是該次函式執行的常數
  • event handlers 是以原始資料(props & state)延伸出來的另一種資料結果
    • 因此,每一次 render 都有他自己的 event handlers

每ㄧ次 render 都有它自己的 event handlers

每ㄧ次 render 都有它自己的 effects

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

每ㄧ次 render 都有它自己的 effects

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}
// 在第一次 render 時
function Counter() {
  // ...
  useEffect(
    // 在第一次 render 時的 effect 函式
    () => {
      document.title = `You clicked ${0} times`;
    }
  );
  // ...
}
// 經過一次點擊,我們的 component function 再次被呼叫
function Counter() {
  // ...
  useEffect(
    // 在第二次 render 時的 effect 函式
    () => {
      document.title = `You clicked ${1} times`;
    }
  );
  // ...
}
// 經過另一次點擊,我們的 component function 再次被呼叫
function Counter() {
  // ...
  useEffect(
    // 在第三次 render 時的 effect 函式
    () => {
      document.title = `You clicked ${2} times`;
    }
  );
  // ..
}

概念上來說,

你可以想像 effects 是 render 結果的副產物

每個 effect 都是專屬於特定的一次 render

當依賴的值會從外部發生 mutate 時,

closures 是不直覺、難以預測結果的

 

而當依賴的值永遠不變時,closures 是直覺易懂的,

因為它依賴的都是常數,執行的行為效果永遠固定

Cleanup function

useEffect(() => {
  ChatAPI.subscribe(props.id, handleChange);
  return () => {
    ChatAPI.unsubscribe(props.id, handleChange);
  };
});
// 第一次 render,props 是 { id: 10 }
function Example(props) {
  // ...
  useEffect(
    // 第一次 render 的 Effect
    () => {
      ChatAPI.subscribe(10, handleChange);
      // 清理第一次 render 的 effect
      return () => {
        ChatAPI.unsubscribe(10, handleChange);
      };
    }
  );
  // ...
}
// 第二次 render,props 是 { id: 20 }
function Example(props) {
  // ...
  useEffect(
    // 第二次 render 的 Effect
    () => {
      ChatAPI.subscribe(20, handleChange);
      // 清理第二次 render 的 effect
      return () => {
        ChatAPI.unsubscribe(20, handleChange);
      };
    }
  );
  // ...
}

每個在 component 裡 render 的函式(包含 event handlers、effects、cleanup、timeouts 或裡面呼叫的 API),

都會捕捉到定義它的那次 render 中的 props 和 state。

真正學會 useEffect 的第一步:

忘記 class component 的生命週期

宣告式的同步化,而非生命週期

  • 宣告式

    • 只關心目標與結果,而不關心細節過程與方法

  • 每當我們呼叫 setState 更新資料時,React 就會以最新的資料重新執行 render,並產生對應的 React elements 畫面結果然後自動同步到 DOM。對於 render 本身來說,這個過程在「mount」或是「update」之間並沒有差異。

function Greeting({ name }) {
  return (
    <h1 className="Greeting">
      Hello, {name}
    </h1>
  );
}

宣告式的同步化,而非生命週期

  • useEffect 讓你根據目前的 props 和 state 來同步 React elements 以外的東西,並且避免阻塞 UI 畫面的渲染
  • 理想上這個 effect 無論隨著 render 執行了幾次,你的程式都應該保持同步且正常運作
function Greeting({ name }) {
  useEffect(() => {
    document.title = 'Hello, ' + name;
  });
  return (
    <h1 className="Greeting">
      Hello, {name}
    </h1>
  );
}

為什麼 effect & cleanup 要在每次 render 後都執行

componentDidMount() {
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

componentWillUnmount() {
  ChatAPI.unsubscribeFromFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}
componentDidMount() {
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

componentDidUpdate(prevProps) {
  // 從先前的 friend.id 取消訂閱
  ChatAPI.unsubscribeFromFriendStatus(
    prevProps.friend.id,
    this.handleStatusChange
  );
  // 訂閱下一個 friend.id
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

componentWillUnmount() {
  ChatAPI.unsubscribeFromFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

為什麼 effect & cleanup 要在每次 render 後都執行

componentDidMount() {
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

componentDidUpdate(prevProps) {
  // 從先前的 friend.id 取消訂閱
  ChatAPI.unsubscribeFromFriendStatus(
    prevProps.friend.id,
    this.handleStatusChange
  );
  // 訂閱下一個 friend.id
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

componentWillUnmount() {
  ChatAPI.unsubscribeFromFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}
componentDidMount() {
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

componentWillUnmount() {
  ChatAPI.unsubscribeFromFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}
function FriendStatus(props) {
  // ...
  useEffect(() => {
    // ...
    ChatAPI.subscribeToFriendStatus(
      props.friend.id,
      handleStatusChange
    );
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(
        props.friend.id,
        handleStatusChange
      );
    };
  });

為什麼 effect & cleanup 要在每次 render 後都執行

componentDidMount() {
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

componentDidUpdate(prevProps) {
  // 從先前的 friend.id 取消訂閱
  ChatAPI.unsubscribeFromFriendStatus(
    prevProps.friend.id,
    this.handleStatusChange
  );
  // 訂閱下一個 friend.id
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}

componentWillUnmount() {
  ChatAPI.unsubscribeFromFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}
function FriendStatus(props) {
  // ...
  useEffect(() => {
    // ...
    ChatAPI.subscribeToFriendStatus(
      props.friend.id,
      handleStatusChange
    );
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(
        props.friend.id,
        handleStatusChange
      );
    };
  });
// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange);     // 執行第一個 effect

// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // 清除前一個 effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange);     // 執行下一個 effect

// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // 清除前一個 effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange);     // 執行下一個 effect

// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // 清除最後一個 effect

dependencies 是一種效能最佳化

componentDidUpdate(prevProps) {
  if (prevProps.friend.id === this.props.friend.id) {
    return;
  }

  // 從先前的 friend.id 取消訂閱
  ChatAPI.unsubscribeFromFriendStatus(
    prevProps.friend.id,
    this.handleStatusChange
  );
  // 訂閱下一個 friend.id
  ChatAPI.subscribeToFriendStatus(
    this.props.friend.id,
    this.handleStatusChange
  );
}
useEffect(
  () => {
    const handleStatusChange = (status) => {
      // ...
    };

    ChatAPI.subscribeToFriendStatus(
      props.friend.id,
      handleStatusChange
    );

    return () => {
      ChatAPI.unsubscribeFromFriendStatus(
        props.friend.id,
        handleStatusChange
      );
    };
  },
  [props.friend.id]
);

dependencies 是一種效能最佳化

function Greeting({ name }) {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    document.title = 'Hello, ' + name;
  });

  return (
    <h1 className="Greeting">
      Hello, {name}
      <button onClick={() => setCounter(count + 1)}>
        Increment
      </button>
    </h1>
  );
}

dependencies 指的是「同步動作的資料依賴清單」,如果這個清單中記載的所有依賴資料都與上一次 render 時沒有差異,就代表沒有再次進行同步的需要,

因此就可以略過本次 effect 來節省效能。

function Greeting({ name }) {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    document.title = 'Hello, ' + name;
  }, [name]);

  return (
    <h1 className="Greeting">
      Hello, {name}
      <button onClick={() => setCounter(count + 1)}>
        Increment
      </button>
    </h1>
  );
}
  • Function component 沒有生命週期的 API,只有 useEffect 用於「同步資料到 effect 行為」
  • useEffect 讓你根據目前的資料來同步 React elements(畫面)以外的任何事物
  • 在一般情況下,useEffect 會在每次 component render 然後瀏覽器完成 DOM 的更新 & 繪製畫面後才執行,以避免阻塞 component render 的過程 & 瀏覽器繪製畫面的過程
  • useEffect 在概念上並不區分 mount 與 update 的情況,它們被視為是同一種情境
  • 預設情況下,每一次 render 後都應該執行屬於該 render 的 useEffect,來確保同步的正確性與完整性
  • 理想上這個 useEffect 無論隨著 render 重新執行了幾次,你的程式都應該保持同步且正常運作
  • useEffect 的 dependencies 是一種「忽略某些不必要的執行」的效能最佳化,而不是用來控制 effect 發生在特定的 component 生命週期,或特定的商業邏輯時機

useEffect 的核心思考模型整理

不要欺騙 hooks 的 dependencies chain

function SearchResults() {
  async function fetchData() {
    // ...
  }

  useEffect(
    () => {
      fetchData();
    },
    [] // 這樣寫是 ok 的嗎? 並非總是沒問題,而且有更好的寫法
  ); 

  // ...
}

Consider this demo...

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount(count + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );

  return <h1>{count}</h1>;
}
// 第一次 render,count state 是 0
function Counter() {
  // ...
  useEffect(
    // 第一次 render 的 effect
    () => {
      const id = setInterval(() => {
        setCount(0 + 1); // 永遠 setCount(1)
      }, 1000);
      return () => clearInterval(id);
    },
    [] // 永遠不會重新執行
  );
  // ...
}

// 接下來的每次 re-render,count state 都是 1
function Counter() {
  // ...
  useEffect(
    // 這個 effect 會永遠被忽略,
    // 因為我們欺騙 React 說 effect dependencies 是空的
    () => {
      const id = setInterval(() => {
        setCount(1 + 1);
      }, 1000);
      return () => clearInterval(id);
    },
    []
  );
  // ...
}
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount(count + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );

  return <h1>{count}</h1>;
}
// 第一次 render,count state 是 0
function Counter() {
  // ...
  useEffect(
    // 第一次 render 的 effect
    () => {
      const id = setInterval(() => {
        setCount(0 + 1); // setCount(count + 1)
      }, 1000);
      return () => clearInterval(id);
    },
    [0] // [count]
  );
  // ...
}

// 第二次 render,count state 是 1
function Counter() {
  // ...
  useEffect(
    // 第二次 render 的 effect
    () => {
      const id = setInterval(() => {
        setCount(1 + 1);  // setCount(count + 1)
      }, 1000);
      return () => clearInterval(id);
    },
    [1] // [count]
  );
  // ...
}
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount(count + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    [count]
  );

  return <h1>{count}</h1>;
}
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount(count + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    [count]
  );

  return <h1>{count}</h1>;
}
// 第一次 render,count state 是 0
function Counter() {
  // ...
  useEffect(
    // 第一次 render 的 effect
    () => {
      const id = setInterval(() => {
        setCount(0 + 1); // setCount(count + 1)
      }, 1000);
      return () => clearInterval(id);
    },
    [0] // [count]
  );
  // ...
}

// 第二次 render,count state 是 1
function Counter() {
  // ...
  useEffect(
    // 第二次 render 的 effect
    () => {
      const id = setInterval(() => {
        setCount(1 + 1);  // setCount(count + 1)
      }, 1000);
      return () => clearInterval(id);
    },
    [1] // [count]
  );
  // ...
}
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount(prevCount => prevCount + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );

  return <h1>{count}</h1>;
}

函式與 dependencies

把函式定義移到 useEffect 中

function SearchResults() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('react');

  useEffect(
    () => {
      // 把函式定義移到 useEffect 中
      // 這個函式只有在 effect 執行時才會重新產生
      async function fetchData() {
        const result = await axios(
          `https://hn.algolia.com/api/v1/search?query=${query}`,
        );
        setData(result.data);
      }

      fetchData();
    },
    [query] // 這裡的 dependencies 是誠實的
  ); 
  // ...
function SearchResults() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('react');

  async function fetchData() {
    const result = await axios(
      `https://hn.algolia.com/api/v1/search?query=${query}`,
    );
    setData(result.data);
  }

  useEffect(
    () => {
      fetchData();
    },
    [] // 這樣是 ok 的嗎?
  ); 
  // ...
function SearchResults() {
  const [data, setData] = useState({ hits: [] });
  const [query, setQuery] = useState('react');

  async function fetchData() {
    const result = await axios(
      `https://hn.algolia.com/api/v1/search?query=${query}`,
    );
    setData(result.data);
  }

  useEffect(
    () => {
      fetchData();
    },
    [] // deps 不誠實,effect 使用了內部沒有的變數 "fetchData"
  ); 
  // ...

Hooks exhaustive-deps linter rule

function SearchResults() {
  async function fetchData(query) {
    const result = await axios(
      `https://hn.algolia.com/api/v1/search?query=${query}`,
    );
  }

  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    []
  );

  useEffect(
    () => {
      fetchData('vue').then(result => { /* 用資料進行某些操作 */ });
    },
    []
  );
function SearchResults() {
  async function fetchData(query) {
    const result = await axios(
      `https://hn.algolia.com/api/v1/search?query=${query}`,
    );
  }

  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 遺漏:fetchData
    []
  );

  useEffect(
    () => {
      fetchData('vue').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 遺漏:fetchData
    []
  );
function SearchResults() {
  async function fetchData(query) {
    const result = await axios(
      `https://hn.algolia.com/api/v1/search?query=${query}`,
    );
  }

  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 誠實
    [fetchData]
  );

  useEffect(
    () => {
      fetchData('vue').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 誠實
    [fetchData]
  );

無效的 dependencies 效能最佳化

function SearchResults() {
  async function fetchData(query) {
    const result = await axios(
      `https://hn.algolia.com/api/v1/search?query=${query}`,
    );
  }

  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 誠實,但是 fetchData 函式每次 render 時都會重新產生,
    // 因此這個 useEffect deps 的效能最佳化完全無效
    [fetchData]
  );

  useEffect(
    () => {
      fetchData('vue').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 誠實,但是 fetchData 函式每次 render 時都會重新產生,
    // 因此這個 useEffect deps 的效能最佳化完全無效
    [fetchData]
  );

解決方案一:

把與 component 資料流無關的流程抽到 component 外部

async function fetchData(query) {
  const result = await axios(
    `https://hn.algolia.com/api/v1/search?query=${query}`,
  );
  return result;
}

function SearchResults() {
  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    // deps 誠實,因為 fetchData 是一個在 component 外部永遠不會改變的函式
    []
  );

  useEffect(
    () => {
      fetchData('vue').then(result => { /* 用資料進行某些操作 */ });
    },
    /// deps 誠實,因為 fetchData 是一個在 component 外部永遠不會改變的函式
    []
  );

解決方案二:

把 useEffect 依賴的函式以 useCallback 包起來

function SearchResults(props) {
  const fetchData = useCallback(
    async (query) => {
      const result = await axios(
        `https://hn.algolia.com/api/v1/search?query=${query}&hitsPerPage=${props.rows}`,
      );
      return result;
    },
    [props.rows] // callback deps 誠實
  );

  useEffect(
    () => {
      fetchData('react').then(result => { /* 用資料進行某些操作 */ });
    },
    // effect deps 誠實,
    // 且只有當 props.rows 不同時,fetchData 才會被重新產生,連帶的此時 effect 才會再次被執行
    // 而如果 props.rows 沒有改變時,則連帶的這個 effect 就會被忽略
    // 因此這裡的 effect deps 效能最佳化可以正常發揮效果
    [fetchData]
  );

  // ...
function Parent() {
  const [query, setQuery] = useState('react');

  // fetchData 會保持不變,直到 query 的值與前一次 render 時不同
  const fetchData = useCallback(
    () => {
      const result = await axios(
        `https://hn.algolia.com/api/v1/search?query=${query}`,
      );
      return result;
    },
    [query] // callback deps 誠實
  );  

  return <Child fetchData={fetchData} />
}

function Child({ fetchData }) {
  let [data, setData] = useState(null);

  useEffect(
    () => {
      fetchData().then(setData);
    },
    [fetchData] // effect deps 誠實
  );

  // ...
}

函式在 function component 與 hooks 中

是屬於資料流的一部份

class Parent extends Component {
  state = {
    query: 'react'
  };

  async fetchData = () => {
    const result = await axios(
      `https://hn.algolia.com/api/v1/search?query=${this.state.query}`,
    );
    return result;
  };

  render() {
    return <Child fetchData={this.fetchData} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };

  componentDidMount() {
    this.props.fetchData();
  }

  render() {
    // ...
  }
}
class Parent extends Component {
  state = {
    query: 'react'
  };

  async fetchData = () => {
    const result = await axios(
      `https://hn.algolia.com/api/v1/search?query=${this.state.query}`,
    );
    return result;
  };

  render() {
    return <Child fetchData={this.fetchData} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };

  componentDidMount() {
    this.props.fetchData();
  }

  componentDidUpdate(prevProps) {
    // 這個條件式永遠都不會是 true
    if (this.props.fetchData !== prevProps.fetchData) {
      this.props.fetchData();
    }
  }

  render() {
    // ...
  }
}
class Parent extends Component {
  state = {
    query: 'react'
  };

  async fetchData = () => {
    const result = await axios(
      `https://hn.algolia.com/api/v1/search?query=${this.state.query}`,
    );
    return result;
  };

  render() {
    return <Child fetchData={this.fetchData} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };

  componentDidMount() {
    this.props.fetchData();
  }

  componentDidUpdate(prevProps) {
    // 這個呼叫會在每一次的 render 時都再次觸發
    this.props.fetchData();
  }

  render() {
    // ...
  }
}
class Parent extends Component {
  state = {
    query: 'react'
  };

  async fetchData = () => {
    const result = await axios(
      `https://hn.algolia.com/api/v1/search?query=${this.state.query}`,
    );
    return result;
  };

  render() {
    return <Child fetchData={this.fetchData} query={this.state.query} />;
  }
}

class Child extends Component {
  state = {
    data: null
  };

  componentDidMount() {
    this.props.fetchData();
  }

  componentDidUpdate(prevProps) {
    if (this.props.query !== prevProps.query) {
      this.props.fetchData();
    }
  }

  render() {
    // ...
  }
}

useCallback & useMemo 讓由原始資料產生出來的延伸資料能夠完全的參與資料流,

並以 dependencies chain 維持 useEffect 的同步可靠性

Race conditions

class Article extends Component {
  state = {
    article: null
  };

  componentDidMount() {
    this.fetchData(this.props.id);
  }

  async fetchData(id) {
    const article = await API.fetchArticle(id);
    this.setState({ article });
  }
  // ...
}
class Article extends Component {
  state = {
    article: null
  };

  componentDidMount() {
    this.fetchData(this.props.id);
  }

  componentDidUpdate(prevProps) {
    if (prevProps.id !== this.props.id) {
      this.fetchData(this.props.id);
    }
  }

  async fetchData(id) {
    const article = await API.fetchArticle(id);
    this.setState({ article });
  }
  // ...
}
function Article({ id }) {
  const [article, setArticle] = useState(null);

  useEffect(() => {
    let didCancel = false;

    async function fetchData() {
      const article = await API.fetchArticle(id);
      if (!didCancel) {
        setArticle(article);
      }
    }

    fetchData();

    return () => {
      didCancel = true;
    };
  }, [id]);

  // ...
}

useReducer 是 dependencies chain 的合法作弊手段

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount(prevCount => prevCount + step);
      }, 1000);

      return () => clearInterval(id);
    },
    [step]
  );

  return (
    <>
      <h1>{count}</h1>
      <input
        value={step}
        onChange={e => setStep(Number(e.target.value))}
      />
    </>
  );
}

從動作分離更新

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;

  useEffect(
    () => {
      const id = setInterval(() => {
        dispatch({ type: 'tick' }); // 而不是 setCount(c => c + step);
      }, 1000);

      return () => clearInterval(id);
    },
    [] // deps 誠實
  );
const initialState = {
  count: 0,
  step: 1,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}
function Counter({ step }) {
  const [count, dispatch] = useReducer(reducer, 0);

  function reducer(state, action) {
    if (action.type === 'tick') {
      return state + step;
    } else {
      throw new Error();
    }
  }

  useEffect(
    () => {
      const id = setInterval(() => {
        dispatch({ type: 'tick' });
      }, 1000);

      return () => clearInterval(id);
    },
    [] // deps 誠實
  );

  return <h1>{count}</h1>;
}

以 dependencies 來控制 useEffect

執行邏輯的誤區

componentDidMount?

  • useEffect 的用途是同步資料到 effect 效果,不是生命週期
  • Function component & hooks 本身也沒有提供任何生命週期的 API
  • useEffect 的 dependencies 是一種「忽略某些不必要的執行」的效能最佳化,而不是用來控制 effect 發生在特定的 component 生命週期,或特定的商業邏輯時機

componentDidMount in function component?

componentDidMount in function component?

import { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount((prevCount) => prevCount + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );

  useEffect(
    () => {
      // do something ...
      console.log('do something effect...');
    },
  );

  return <div>{count}</div>;
}

componentDidMount in function component?

import { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount((prevCount) => prevCount + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );

  useEffect(
    () => {
      // do something ...
      console.log('do something effect...'); // 在 React 18 中會執行兩次
    },
    []
  );

  return <div>{count}</div>;
}

componentDidMount in function component?

import { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount((prevCount) => prevCount + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );

  useEffect(
    () => {
      // do something ...
      console.log('do something effect...'); // 在 React 18 中會執行兩次
    },
    []
  );

  return <div>{count}</div>;
}

componentDidMount in function component?

function App() {
  const [count, setCount] = useState(0);
  const isEffectCalledRef = useRef(false);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount((prevCount) => prevCount + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );

  useEffect(
    () => {
      if (!isEffectCalledRef.current) {
        isEffectCalledRef.current = true;
        // do something effect...
        console.log('do something effect...');
      }
    },
    []
  );

  return <div>{count}</div>;
}

componentDidMount in function component?

function App() {
  const [count, setCount] = useState(0);
  const isEffectCalledRef = useRef(false);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount((prevCount) => prevCount + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );

  useEffect(
    () => {
      if (!isEffectCalledRef.current) {
        isEffectCalledRef.current = true;
        // do something effect...
        console.log('do something effect...');
      }
    }
  );

  return <div>{count}</div>;
}

用 dependencies 來判斷執行邏輯?

function App() {
  const [count, setCount] = useState(0);
  const [todos, setTodos] = useState([]);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount((prevCount) => prevCount + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );
 
  useEffect(
    () => {
      setCount(prevCount => prevCount * 2);
    },
    [todos] // deps 不誠實,填寫了未使用的依賴
  );


  // ...
}

用 dependencies 來判斷執行邏輯?

function App() {
  const [count, setCount] = useState(0);
  const [todos, setTodos] = useState([]);
  const prevTodos = usePreviousValue(todos);

  useEffect(
    () => {
      const id = setInterval(() => {
        setCount((prevCount) => prevCount + 1);
      }, 1000);

      return () => clearInterval(id);
    },
    []
  );
 
  useEffect(
    () => {
      if (prevTodos !== todos) {
        setCount(prevCount => prevCount * 2);
      }
    },
    [todos] // deps 誠實
  );


  // ...
}

Reusable state — React 18 的 useEffect 在 mount 時為何會執行兩次?

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";

import App from "./App";

const rootElement = document.getElementById("root");
const root = createRoot(rootElement);

root.render(
  <StrictMode>
    <App />
  </StrictMode>
);
function App() {
  useEffect(
    // 在 React 18 的 development env + strict mode 時,
    // 這個 effect 在 mount 時會執行兩次
    () => {
      // do something ...
      console.log('do something effect...'); 
    },
    []
  );

  // ...
}
  • React 預計在未來添加的許多新功能們有一個共同前提約束
    • Component 必須要設計得有足夠的彈性來多次 mount & unmount 也不會壞掉
  • Offscreen API
    • 讓 React 可以在 UI 切換時保留 component 的 local state 以及對應的真實 DOM elements,像是把他們暫時隱藏起來而不是真的移除它們
    • 當這個 component 有再次顯示的需要時,就能以之前留下來的 state 狀態再次 mount
  • 為了確保你的 component 能支援上述的特性,effect 必須要是可重複執行也不會壞掉的

Reusable State

Strict mode + dev env 時會自動 mount 兩次,

是在模擬「mount => unmount => mount 」的過程,

幫助開發者檢查 effect 的設計是否滿足這個彈性的要求。

Effect 調教建議:fetch API

useEffect(
  () => {
    async function startFetching() {
      const json = await fetchTodos(userId);
      setTodos(json);
    }

    startFetching();
  },
  [userId]
);

Effect 調教建議:fetch API

useEffect(
  () => {
    let ignore = false;

    async function startFetching() {
      const json = await fetchTodos(userId);
      if (!ignore) {
        setTodos(json);
      }
    }

    startFetching();

    return () => {
      ignore = true;
    };
  },
  [userId]
);

Effect 調教建議:控制 React 外部的插件

useEffect(
  () => {
    const map = mapRef.current;
    map.setZoomLevel(zoomLevel);
  },
  [zoomLevel]
);
useEffect(
  () => {
    if (!mapRef.current) {
      mapRef.current = new FooMap();
    }
  },
  // 這裡是因為沒有任何依賴才填空陣列,
  // 而不是為了控制 effect 只執行一次
  [] 
);

Effect 調教建議:控制 React 外部的插件

useEffect(
  () => {
    const dialog = dialogRef.current;
    dialog.showModal();
    return () => dialog.close();
  },
  []
);

Effect 調教建議:訂閱事件

useEffect(
  () => {
    function handleScroll(e) {
      console.log(e.clientX, e.clientY);
    }

    window.addEventListener('scroll', handleScroll);

    return () => {
      window.removeEventListener('scroll', handleScroll)
    };
  },
  []
);

Effect 調教建議:手動操作 DOM

useEffect(
  () => {
    const node = ref.current;
    node.style.opacity = 1; // Trigger the animation

    return () => {
      node.style.opacity = 0; // Reset to the initial value
    };
  },
  []
);

Effect 調教建議:傳送 log 紀錄

useEffect(
  () => {
    logVisit(url); // 傳送一個 API request 去 log 經過這裡
  },
  [url]
);

不該是 effect:使用者的操作所觸發的事情

useEffect(
  () => {
    // 錯誤:這個 request 會在 dev env 自動被送出兩次
    fetch('/api/buy', { method: 'POST' });
  },
  []
);

不該是 effect:使用者的操作所觸發的事情

  function handleClick() {
    // 結帳購買的動作將會由使用者進行操作後才對應觸發一次
    fetch('/api/buy', { method: 'POST' });
  }

總結

  • useEffect 的正確用途
    • Function component 沒有生命週期的 API,只有 useEffect 用於「從資料同步到 effect 的行為與影響」
    • useEffect 讓你根據目前的資料來同步 React elements(畫面)以外的任何事物
  • useEffect 是隨著每次 render 後而自動觸發的
    • 預設情況下,每一次 render 後都應該執行屬於該 render 的 useEffect,來確保同步的正確性與完整性
    • useEffect 的 dependencies 是一種「忽略某些不必要的同步」的效能最佳化,而不是用來控制 effect 發生在特定的 component 生命週期,或特定的商業邏輯時機
    • 確保你有自己寫邏輯來讓 effect 在你預期的條件情境時才執行,而不該依靠 dependencies 來控制這件事情
  • useEffect 應設計成即使多次重複執行也有保持邏輯行為正確的彈性
    • 確保你的 useEffect 無論隨著 render 重新執行了幾次,你的程式結果都應該保持同步且正常運作
    • Strict mode + dev 環境時, component 會自動被 mount 兩次。這是在模擬「mount => unmount => mount 」的過程, 幫助開發者檢查 effect 的設計是否滿足這個彈性的要求
    • 如果 effect 多次執行會導致問題,可以優先考慮實作 effect 的 cleanup function 來停止或逆轉 effect 中造成的影響

參考資料來源

iCHEF 產品團隊誠徵前端工程師中!

Q & A 時間

都 2022 年了你還是可能不懂 useEffect

By tz5514

都 2022 年了你還是可能不懂 useEffect

  • 926