JavaScript
reactjs
React

useEffectフックのしくみ

Dave Ceddia氏による全5回におよぶReact Hooks入門記事の第4回を本人の許可を得て意訳しました。

誤りやより良い表現などがあればご指摘頂けると助かります。

原文: https://daveceddia.com/useeffect-hook-examples/


想像してみてください。完璧なファンクションコンポーネントにある日、ライフサイクルメソッドを追加しなければらなくなったと。

ああ。

「何とかして回避できるかもしれない?」それが最終的には「もういいや、クラスに変更しよう!」となります。

class Thing extends React.Component に始まり、関数の本体を render にコピー&ペーストし、最終的にライフサイクルメソッドを追加します

useEffect フックならもっと上手くやれます。

useEffect によって、ファンクションコンポーネント名で直接ライフサイクルイベントを処理できます。具体的には、 componentDidMountcomponentDidUpdatecomponentWillUnmount の3種です。全てを1つの関数で賄えます!驚きですよね。例を見てみましょう。


メモ:Hooksは現在α版であり、プロダクション環境ではまだ使用できません。APIはさらに変更される可能性があるため、現時点ではプロダクションアプリの書き換えはオススメしません。Open RFCにコメントし、公式ドキュメントFAQにも目を通してください。


import React, { useEffect, useState } from 'react';

import ReactDOM from 'react-dom';

function LifecycleDemo() {
// 関数を引数に取る
useEffect(() => {
// 初期状態では、レンダリングごとに呼ばれる
// (初回とその後の毎回)
console.log('render!');

// componentWillUnmountを実装したければ
// ここから関数を返すと
// Reactはアンマウントの直前にそれを呼び出す
return () => console.log('unmounting...');
})

return "I'm a lifecycle demo";
}

function App() {
// stateを作成し、
// 再レンダリングのトリガーとして使う
const [random, setRandom] = useState(Math.random());

// LifecycleDemoが表示/非表示のいずれであるかを
// 記録するためにstateを作成する
const [mounted, setMounted] = useState(true);

// この関数は乱数を変更して
// 再レンダリングを発火させる
// (コンソール上でLifecycleDemoから「render!」が確認できる)
const reRender = () => setRandom(Math.random());

// この関数はLifecycleDemoをアンマウントして、再マウントするため
// クリーンアップ関数が呼ばれていることを確認できる
const toggle = () => setMounted(!mounted);

return (
<>
<button onClick={reRender}>Re-render</button>
<button onClick={toggle}>Show/Hide LifecycleDemo</button>
{mounted && <LifecycleDemo/>}
</>
);
}

ReactDOM.render(<App/>, document.querySelector('#root'));

CodeSandboxで試してみてください。

Show/Hideボタンをクリックして、コンソールを見てください。コンポーネントが消える前に「unmounting」が表示され、再び現れる時に「render!」が表示されます。

useEffect-unmount-remount.gif

続いて、Re-renderボタンを試してください。クリックする度に、「render!」と「unmounting」が表示されます。何かおかしいですね...

useEffect-rerender.gif

何故レンダリングごとに「unmounting」が表示されるのでしょうか?

(任意で) useEffect から返されるクリーンアップ関数は、コンポーネントがアンマウントされる時だけ呼ばれるわけではありません。副作用が実行されるたびに呼ばれ、前の実行からクリーンアップを行います。実際には componentWillUnmount ライフサイクル よりもパワフルなのですが、必要であればレンダリングの前後に副作用を実行できるというのがその理由です。


完全にライフサイクルと同一ではない

useEffect はレンダリングごとに実行され(初期状態では)ますが、再度実行される前に任意で自身をクリーンアップすることもできます。

useEffect は、3つのライフサイクルを処理する関数というよりも、単純にレンダリング後に副作用を実行する手段と捉えた方がわかりみがあるかもしれません。これには、必要に応じてレンダリングの前やアンマウントの前にクリーンアップを行うことも含まれます。


レンダリングごとにuseEffectが実行させるのを防ぐ

副作用の実行頻度を減らしたければ、第2引数に値の配列を渡すことができます。これは副作用のための依存関係と考えてください。前回から依存関係の1つが更新されると、副作用は再度実行されます。(初期レンダリングの後でも同様です)

const [value, setValue] = useState('initial');

useEffect(() => {
// この副作用は変数valueを使う
// そのためvalueに依存している
console.log(value);
}, [value]) // 依存関係としてvalueを渡す

この配列は、副作用が使用する周辺スコープの全ての変数を含むべきと考えても良いでしょう。propsを使う場合はそれが配列に含まれます。また、stateの一部を使う場合はそれが配列に含まれます。


マウント&アンマウント時のみ実行する

特殊な値として空配列 [] を渡すことで、「マウント時とアンマウント時のみ実行する」方法として機能します。上のコンポーネントを更新して useEffect を呼ぶのであればこんな具合です。

useEffect(() => {

console.log('mounted');
return () => console.log('unmounting...');
}, []) // <-- ここに空配列を追加する

すると、初期レンダリングで「mounted」が表示された後は、コンポーネントが生存する限り沈黙しますが、消える時には「unmounting」が表示されます。

1つ重要な注意点があります。空配列を渡すとバグを起こしやすいということです。依存関係を追加した場合、項目の追加を忘れがちですが、依存関係が不足していると、その値は次回 useEffect が実行された時に更新されずにおかしな挙動の原因となるでしょう。


マウントに集中する

マウント時に1つの小さな仕事だけをさせたいような場合、その小さな仕事は関数をクラスに書き換えることを必要とします。

この例では、初回レンダリングの入力制御に集中するために useEffectuseRef フックを結合して活用しています。

import React, { useEffect, useState, useRef } from "react";

import ReactDOM from "react-dom";

function App() {
// 入力用DOMノードへの参照を保持する
const inputRef = useRef();

// 入力値をstateに保持する
const [value, setValue] = useState("");

useEffect(
() => {
// 初回レンダリング後に実行されるため、
// refは今このタイミングで設定される
console.log("render");
// inputRef.current.focus();
},
// 副作用はinputRefに依存する
[inputRef]
);

return (
<input
ref={inputRef}
value={value}
onChange={e => setValue(e.target.value)}
/>
);
}

ReactDOM.render(<App />, document.querySelector("#root"));

コード上部で、 useRef を使って空の参照を作っています。それを入力要素の ref propに渡すことで、DOMがレンダリングされたタイミングで一度だけ参照され、その useRef で返される値はレンダリング中は一切変化しません。

つまり、 [inputRef]useEffect の第2引数として渡しても、それは初期マウント時の1回だけ実行されます。基本的には、「componentDidMount」(後ほど言及する実行タイミングを除き)と同じと考えて良いでしょう。

証明のため、例を試してみてください。どのようにマウント時に集中しているのか(CodeSandboxエディタは少々バギーですが、その場で「ブラウザ」の更新ボタンを押してみてください)確認してみてください。続いてボックス内でタイピングすると、各文字が再レンダリングのトリガーになりますが、コンソールを見ると「render」は1度だけ表示されています。


useEffectでデータを取得する

よくある使用例をもう1つ見てみましょう。データを取得して表示します。クラスコンポーネントでは、このコードを componentDidMount メソッドに記述します。Hooksで実装するには、 useEffect を活用し、 useState も動員してデータを保持します。

Reactのデータ取得に関する新たな機能であるSuspenseが用意されたら、それに言及しないわけには行かないでしょう。これがデータ取得の推奨された方法になるのは間違いありません。 useEffect からデータを取得するには1つの大きな落とし穴(すぐに克服されるでしょうが)がありますが、Suspense APIはずっと使いやすくなるでしょう。

Redditから記事一覧を取得して表示するコンポーネントの例です。

import React, { useEffect, useState } from "react";

import ReactDOM from "react-dom";

function Reddit() {
// 記事一覧を保持するためにstateを初期化する
const [posts, setPosts] = useState([]);

// async関数を使ってfetchをawaitする
useEffect(async () => {
// いつも通りfetchを呼ぶ
const res = await fetch(
"https://www.reddit.com/r/reactjs.json"
);

// いつも通りデータを取得する
const json = await res.json();

// 記事一覧をstateに保存する
// (ネットワークタブを見てなぜパスがこうなっているのか確認する)
setPosts(json.data.children.map(c => c.data));
}); // <-- 値を渡さなかったことで何が起こる?

// いつも通りレンダリングする
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}

ReactDOM.render(
<Reddit />,
document.querySelector("#root")
);

useEffect の第2引数を渡さなかったことに気づきましたか?これは悪い例なので真似しないでください。

第2引数がないと、useEffect はレンダリングごとに呼ばれてしまいます。さらに、実行時はデータを取得してstateを更新し、いったんstateが更新されると、コンポーネントは再レンダリングされ、 useEffectが再度発火します。これは問題ですね。

修正のため、第2引数を渡す必要があります。どんな値が適切でしょうか?

ちょっと考えてみてください。

...

...

useEffect が依存する唯一の変数は setPosts です。なので、ここでは配列 [setPosts] を渡しましょう。 [setPosts]useState によって返されるセッター関数なので、レンダリングごとに再生成されることはなく、副作用は1度だけ実行されます。

興味深い事実として、 useState を呼び出すと、セッター関数は1度だけ返されます。コンポーネントがレンダリングされるたびに全く同じ関数のインスタンスになります。そのため、副作用が依存するのに安全だと言えます。この事実は、 useReducer 関数によって返される dispatch 関数でも同様です。


データが更新されたら再取得する

よくあるトラブルをカバーするために例を拡張してみましょう。ユーザーIDもしくは、今回の例ではsubredditの名前が更新された時にデータを再取得します。

最初に Reddit コンポーネントを更新して、subredditをpropとして受け取ります。subredditに基づいてデータを取得し、propが更新された時だけ副作用が実行されるようにします。

// 1. propsからsubredditを分割代入する

function Reddit({ subreddit }) {
const [posts, setPosts] = useState([]);

useEffect(async () => {
// 2. URLを設定するためにテンプレートリテラルを使う
const res = await fetch(
`https://www.reddit.com/r/${subreddit}.json`
);

const json = await res.json();
setPosts(json.data.children.map(c => c.data));

// 3. subredditが更新された時にこの副作用を再実行する
}, [subreddit, setPosts]);

return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}

// 4. reactjsをpropとして渡す
ReactDOM.render(
<Reddit subreddit='reactjs' />,
document.querySelector("#root")
);

まだハードコーディングした部分が残っていますが、 Reddit コンポーネントをsubredditを更新するコンポーネントでラップすることでカスタマイズ可能です。この新しいAppコンポーネントを追加して、コード下部でレンダリングしてみましょう。

function App() {

// 2つのstate:1つは入力値を保持する
// もう1つは現在のsubredditを保持する
const [inputValue, setValue] = useState("reactjs");
const [subreddit, setSubreddit] = useState(inputValue);

// ユーザーがエンターキーを押すとsubredditが更新される
const handleSubmit = e => {
e.preventDefault();
setSubreddit(inputValue);
};

return (
<>
<form onSubmit={handleSubmit}>
<input
value={inputValue}
onChange={e => setValue(e.target.value)}
/>
</form>
<Reddit subreddit={subreddit} />
</>
);
}

ReactDOM.render(<App />, document.querySelector("#root"));

CodeSandboxの動作例を試してみてください

このアプリケーションは2つのstateを保持します。現在の入力値と現在のsubredditです。「commits」という入力値を送信すると、subredditは Reddit に新たな選択からデータを再取得させます。入力項目をフォームでラップすることで、ユーザーがエンターキーを押すと送信されます。

ところで、注意深くタイピングしてくださいね。エラーハンドリングがないので、実在しないsubredditを入力するとアプリケーションが爆発します。とはいえ、エラーハンドリングの実装は素晴らしい練習になると思います!

ここでは1つのstateを使いました。入力値を保持し、同じ値を Reddit に送信しますが、 Reddit コンポーネントはキー入力のたびにデータを取得するように見えます。

コード上部の useState は少し奇妙な感じがします。特に2行目に注目してください。

const [inputValue, setValue] = useState("reactjs");

const [subreddit, setSubreddit] = useState(inputValue);

最初のstateの初期値として「reactjs」を渡すのは理にかなっており、その値は決して変更されません。

しかし、2行目はどうでしょうか?初期値が更新されるとしたら?(実装にboxで入力するとそうなります)

useState がステートフルであることを忘れないでください(useStateについての詳細)。stateの初期値は初回レンダリング時に1度だけ使われ、その後は無視されます。そのため、更新される可能性のあるpropsや他の変数のような一時的な値を渡すことは安全です。


多様な用途

useEffect 関数はHooksのスイスアーミーナイフのようなものです。サブスクリプションの設定からタイマーの後処理、refの値を更新するなど、多くの仕事をすることができます。

苦手なことの1つは、ユーザーに見えるDOMの更新です。動作タイミングとして、副作用関数はブラウザがレイアウトとペイントを完了した後にだけ発火しますが、これは見た目を更新するには遅すぎます。

このような場合、Reactは useMutationEffectuseLayoutEffect フックを提供しますが、これは発火タイミング以外は useEffect と同じように使えます。useEffectの説明書に目を通してみてください。視覚的なDOMの更新をしたいのであれば、特に副作用のタイミングセクションがおすすめです。

これは過剰な複雑さのように思えるかもしれませんが、それほど心配することではありません。この利点は、 useEffect はレイアウトとペイントの後に実行されるため、遅い副作用がUIを台無しにすることがないということです。欠点としては、ライフサイクルの古いコードをHooksに移行する際に注意する必要があるということです。 useEffectcomponentDidMount とほぼ同等ですが、タイミングに関しては完全に同じというわけではないということです。


useEffectを試してみる

Hooksが有効化されたCodeSandboxuseEffect を試すことができます。いくつかのアイデアとして...


  • 入力ボックスをレンダリングしてその値を useState で保持して、 document.title を副作用として設定する(React ConfでのDan先生のデモのように)

  • URLからデータを取得するカスタムフックを作成する

  • クリックハンドラをdocumentに追加して、ユーザーがクリックするたびにメッセージを表示する(ハンドラをクリーンアップすることを忘れないで!)

インスピレーションを得たいのであれば、ここにNik Graf氏のReact Hooksコレクションがあります。その数は現在88個に達しています。これらの多くは、自分で実装するのも簡単です。( useOnMount であれば、この記事で学んだことを生かして実装できるでしょう!)

Hooksウィークも残すところあと1日です。見逃しがないようにサインアップしてください。