Hooks が正式にリリースされたばかりの頃、私は Hooks に関する雑談 を書きました。これは概念の紹介からの簡単な分析理解でした。実習中に開発した新しいコンソールはこの機能を完全に使用していたため、現在の実践経験をもとに Hooks のベストプラクティスと比較分析を行いたいと思います。
値のキャプチャ#
Hooks が登場して以来、関数コンポーネントも状態の特性を持つようになりました。したがって、Stateless Component を避けて Function Component と統一して呼ぶ方が適切です。ここではまず、props の不変性に基づいて Function Component と Class Component を比較します:
Function Component:
function ProfilePage(props) {
setTimeout(() => {
// 親コンポーネントが再レンダリングされ、props も初期のまま
console.log(props)
}, 3000)
}
Class Component:
class ProfilePage extends React.Component {
render() {
setTimeout(() => {
// 親コンポーネントが再レンダリングされ、this.props も変わる、なぜなら this が変わったから
console.log(this.props)
}, 3000)
}
}
Class Component で初期の props をキャプチャしたい場合は const props = this.props
とできますが、これは少しハック的なので、安定した props
を取得するには Function Component の使用をお勧めします。
Hooks:
function MessageThread() {
const [message, setMessage] = useState("")
const showMessage = () => {
alert("あなたが言った: " + message)
}
const handleSendClick = () => {
setTimeout(showMessage, 3000)
}
const handleMessageChange = e => {
setMessage(e.target.value)
}
return (
<>
<input value={message} onChange={handleMessageChange} />
<button onClick={handleSendClick}>送信</button>
</>
)
}
send
をクリックした後に入力ボックスの値を変更しても、3 秒後に出力されるのはクリック前の入力ボックスの値であり、Hooks も値をキャプチャする特性を持っていることを示しています。
各レンダリングの内容はスナップショットを形成すると考えることができ、各レンダリング状態は固定された Props と State を持っています。
これはオブジェクトだけでなく、関数も毎回のレンダリング時に独立しているため、これが値のキャプチャ特性です。
実際の開発ではこの特性に悩まされたことがあり、値のキャプチャを避けるために useRef
を利用できます:
function MessageThread() {
const latestMessage = useRef("")
const showMessage = () => {
alert("あなたが言った: " + latestMessage.current)
}
const handleSendClick = () => {
setTimeout(showMessage, 3000)
}
const handleMessageChange = e => {
latestMessage.current = e.target.value
}
}
ref
はすべてのレンダリングプロセスで唯一の参照を保持していると考えることができるため、ref
の取得や設定は常に最終状態を取得します。また、ref
は可変であり、state
は不変であると簡潔に考えることができます。
ライフサイクルメソッドの代替#
- constructor:Function Component では初期コンストラクタが不要で、
useState
を呼び出すことで状態を初期化できます。初期値の計算が非常に時間を消費する場合は、関数を渡すことができ、これにより一度だけ実行されます。 - getDerivedStateFromProps:レンダリング時に合理的に更新をスケジュールします。
- shouldComponentUpdate:
[React.memo](https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-shouldcomponentupdate)
を参照してください。 - render:それ自体が Function Component です。
- componentDidMount、componentDidUpdate、componentWillUnmount:これらの集合は
useEffect
に対応します。 - componentDidCatch、getDerivedStateFromError:最近、対応する Hook メソッドが追加される予定です。
ベストプラクティス#
コンポーネント定義#
const App: React.FC<{ title: string }> = ({ title }) => {
return React.useMemo(() => <div>{title}</div>, [title])
}
App.defaultProps = {
title: 'Function Component'
}
React.FC
を使用して Function Component のコンポーネントタイプと Props パラメータタイプを宣言します。React.useMemo
を使用してレンダリング性能を最適化します。App.defaultProps
を使用して Props のデフォルト値を定義します。
なぜ React.memo を使わないのか?
コンポーネント間の通信に React.useContext
の使用があるため、使用されるすべてのコンポーネントが再レンダリングされます。React.useMemo
のみが必要に応じてレンダリングでき、将来のメンテナンスを考慮すると、useContext
などでデータが注入される可能性があるため、パフォーマンスに問題がないコンポーネントでも useMemo
の使用をお勧めします。
なぜデフォルト Props の代わりに分割代入を使わないのか?
書き方としては分割代入の方が優雅ですが、パフォーマンスの問題があります。オブジェクトタイプの場合、毎回の再レンダリング時に参照が変わります。
ローカル状態#
一般的な使用頻度の順に:useState
、useRef
、useReducer
。
useState
const [hide, setHide] = React.useState(false)
const [name, setName] = React.useState("jeremy")
状態と関数名は意味がわかるようにし、推奨されるのはすべてをトップで宣言して、参照しやすくすることです。
useRef
const dom = React.useRef(null)
useRef
はできるだけ少なく使用するべきで、大量の可変データはコードの保守性に影響を与えます。再初期化が不要なオブジェクトには使用をお勧めします。
useReducer
ローカル状態には useReducer
の使用は推奨されず、内部状態が複雑になりやすいため、複数のコンポーネント間で通信する場合は useContext
と組み合わせて使用することをお勧めします。
関数内で普通の定数や普通の関数を直接宣言するのは適切ですか?
Function Component は毎回のレンダリングで再実行されるため、定数は関数の外側に置くことを推奨し、パフォーマンスの問題を避けるべきです。関数は useCallback
を使用して宣言し、正確性とパフォーマンスを保証することを推奨します。useCallback
の第二引数は必ず記入し、eslint-plugin-react-hooks が自動的に依存関係を記入します。
コンポーネント通信#
シンプルなコンポーネント通信は Props を透過的に使用し、頻繁なコンポーネント間通信には useContext
を使用します。
useEffect ガイド#
Function Component にはライフサイクルがなく、UI 状態を記述するだけで、React がそれを DOM に同期します。毎回のレンダリングの状態は固定され、state
、props
、useEffect
、および内部のすべての関数が含まれます。
ライフサイクルの同期を放棄することは、いくつかのパフォーマンス問題を引き起こすため、React に対して Effects を比較する方法を教える必要があります。
useEffect の依存関係#
React は DOM のレンダリング時に内容を diff し、変更された部分のみを修正しますが、Effect の増分変更の識別はできません。開発者は useEffect
の第二引数を通じて React にどの外部変数が使用されているかを教える必要があります:
useEffect(() => {
document.title = 'こんにちは、' + name
}, [name])
name
が変更された再レンダリングが行われるまで、useEffect
は再度実行されません。手動でのメンテナンスは面倒ですが、eslint を利用して自動的に修正を提案できます。
ここで注目すべきは、依存関係の設定が非常に重要であることです:
useEffect
は値のキャプチャ特性に従うため、依存関係を適切に処理しないと値の正確性が保証されません:
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(id)
}, [count])
ここで count
を依存関係として渡さないと、取得される count
の値は常に初期の 0
になり、その後の setCount
は効果がなくなります。
count
を渡すことで最新の count
を取得できますが、以下の 2 つの問題が発生します:
- タイマーが正確でなくなります。なぜなら、
count
が変わるたびに再生成されるからです。 - 頻繁にタイマーを生成および破棄することが一定のパフォーマンス負担をもたらします。
依存関係を設定するかどうかに関わらず問題があります。本質的には、私たちが一度だけ実行したい Effect の中で外部変数に依存しているからです。
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])
setCount
には関数コールバックモードがあり、現在の値が何であるかを気にする必要はなく、古い値を変更するだけで済みます。これにより、コードは常に最初のレンダリングで実行されますが、常に最新の state
にアクセスできます。
上記の解決策は、2 つの state
に同時に依存する場合など、すべてのシナリオの問題を完全に解決するものではありません:
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + step)
}, 1000)
return () => clearInterval(id)
}, [step])
ここでは step
という変数に依存せざるを得ませんが、今どう処理すればよいのでしょうか?
useReducer
関数を利用して更新とアクションを分離します:
const [state, dispatch] = useReducer(reducer, initialState)
const { count, step } = state
useEffect(() => {
const id = setInterval(() => {
dispatch({ type: "tick" })
}, 1000)
return () => clearInterval(id)
}, [dispatch])
これにより、ローカル Redux が形成され、更新時に依存する変数がいくつあっても、実際の更新時にはどの変数にも依存する必要がなく、具体的な更新操作は reducer
関数内に記述できます。
Function と Effect#
関数定義が useEffect
内にない場合、依存関係を見逃す可能性があり、eslint プラグインも自動的に依存関係を収集することができません。
Function Component 内の変数に依存しない関数は直接抽出できますが、変数に依存する関数はどうすればよいでしょうか?
useCallback
:
function Parent() {
const [query, setQuery] = useState("react");
// ✅ query が変わるまでアイデンティティを保持
const fetchData = useCallback(() => {
const url = "https://hn.algolia.com/api/v1/search?query=" + query;
// ... データを取得して返す ...
}, [query]); // ✅ コールバックの依存関係は OK
return <Child fetchData={fetchData} />;
}
function Child({ fetchData }) {
let [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, [fetchData]); // ✅ Effect の依存関係は OK
// ...
}
関数も値のキャプチャ特性を持つため、useCallback
でラップされた関数は普通の変数として useEffect
の依存関係として扱うことができます。
useCallback
はその依存関係が変化したときに新しい関数参照を返し、useEffect
の依存関係の変化を引き起こして再実行を活性化します。
Class Component では、パラメータが変わると再度データを取得したい場合、データ取得関数の diff を直接比較するのではなく、データ取得パラメータが変わったかどうかを比較する必要があります。このようなコードは内聚性がなく、メンテナンスが難しくなります。Function Component で useCallback
でラップされたデータ取得関数を利用すれば、useEffect
はこの依存関係が変化したかどうかだけを気にすればよく、パラメータの変化は useCallback
内で関係し、eslint プラグインと組み合わせることで依存関係を失わず、ロジックの内聚性を保ちやすくなります。
クリーンアップメカニズム#
コンポーネントが破棄されるとき、useEffect
で登録されたリスナーは破棄される必要があります。これはその戻り値を通じて処理できます:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange);
};
});
コンポーネントが破棄されると、戻り値の関数内のコールバック関数が実行されます。値のキャプチャ特性により、毎回の登録とクリーンアップで取得されるのはペアの固定値です。
適切なクリーンアップを行わないと、メモリリークを引き起こす可能性があります。async
のような非同期関数を直接渡すと、useEffect も警告を出しますが、非同期関数を破棄時にキャンセルするにはどうすればよいでしょうか?
使用している非同期方式がキャンセルをサポートしている場合は、クリーンアップ関数内で非同期リクエストをキャンセルできます。より簡単な方法は、ブール値を利用することです:
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]);
// ...
}
この記事 では、エラーやローディング状態の処理に関するさらなるシナリオが議論されています。
その他の利点#
useEffect
はレンダリング終了時に実行されるため、ブラウザのレンダリングプロセスをブロックしません。これは React Fiber の理念に合致しています。Fiber は状況に応じて異なるコンポーネントのレンダリングを一時停止または中断して実行します。値のキャプチャ特性に従ったコードは値の安全なアクセスを保証し、ライフサイクルを弱めることで中断実行による問題を解決できます。
React Hooks は現在も改善と発展の途中にあり、公式およびコミュニティの実践的なソリューションやツールには学ぶべき点が多くあります。また、Vue 3.0 も React Hooks の思想を参考にして Vue Hooks を生み出しました。両者にはそれぞれの利点と欠点があり、この部分を深く理解した後に再度ここに追加する予定です。
参考: