社内の研修でNext.jsを使ったWebアプリケーション開発を行なった。研修当時は短期間で完成させる必要もあり、かなりコードがおざなりになってしまっていた。また、新しくなったReactの公式ドキュメントに一通り目を通し始めたこともあり、タイミングを見て簡単なリファクタリングを試みることにした。
ここでは、リファクタリングの中で得た知見について書き留めていくことにする。とはいっても、巨大なコンポーネントの分割だとかについて述べてもしょうがないので、ReactだったりJavaScriptだったりの言語的な使用に関連したトピックについて触れていきたい。
アプリケーションの概要
本題の前にアプリケーションの概要を述べておくと、簡潔にいえばテキスト投稿サービスである。投稿には返信をすることもできる。また、画像や文字修飾によって投稿をデコレーションすることもできるが、今回の話ではあまり関係してこない。
矛盾するようなstateを管理しない
開発したアプリケーションでは投稿を表示するためのPost
コンポーネントがあるのだが、これはユーザーの操作によって見た目が変わる。具体的には投稿の編集ボタンを押した時は編集用のフォームが出現するし、投稿に返信するボタンを押した時は返信用のフォームが出現する。また、投稿自体が削除されている場合は特別なコンポーネントを表示するようにしている。
これらを実現するために、Post
コンポーネントでは次のような複数のstateを管理していた。
const [isEditing, setIsEditing] = useState(false); const [isEditingReply, setIsEditingReply] = useState(false); const [isDelete, setIsDelete] = useState(post.isDeleted);
しかし、このような実装はstate管理やロジックを複雑にする原因になる。
今回のケースでは、これらのstateが複数同時にtrueになることはないはずである。投稿の編集と投稿への返信は同時には実行されないし、投稿が削除された状態で投稿の編集や投稿への返信が行えるのは明らかにおかしい。これはつまり、例えば編集ボタンが押された時にisEditing
をtrueにしようと思ったら、そこでは同時にisEditingReply
とisDelete
もfalseにして、コンポーネントの状態が衝突しないことを保証しなくてはいけないということである。
このように同時に存在することがない複数の状態を矛盾なく管理したいような場合は、次のように単一のstateでコンポーネントの状態を管理する方が良い。
type PostStatus = "show" | "edit" | "reply" | "delete"; const [status, setStatus] = useState<PostStatus>(post.isDeleted ? "delete" : "show"); const isEditing = status === "edit"; const isEditingReply = status === "reply"; const isDelete = status === "delete";
status
はこのコンポーネントの現在の状態を表す文字列を保持するstateである。コンポーネントの初期状態はshow
で表すことにする。status
が任意の文字列を受け取れると困るので、ユニオン型を利用することで受け取れる文字列を制限する。
このようにすればコンポーネントの状態が矛盾することはなく、各状態への更新もsetStatus
を一度呼び出すだけで済む。
キーの順序に依存する処理を書きたいときは、オブジェクトではなくMapを使う
投稿の一覧を表示する場面では、日付毎に投稿を表示したい。これを実現するために、次のような処理を行っていた。
const postsOrderByDate: Record<string, PostWithChildPosts[]> = {}; posts.forEach((post) => { const date = new Date(post.createdAt); const dateStr = date.toLocaleDateString(); if (!postsOrderByDate[dateStr]) { postsOrderByDate[dateStr] = []; } postsOrderByDate[dateStr].push(post); });
このコードでは投稿の配列posts
を元に、日付を表す文字列をキーとして、その日付に作成された投稿の配列を得ることができるようなオブジェクトを作成している。ここで作成されたオブジェクトは、次のようにObject.keys()
メソッドを用いてJSX.Element
の配列を作成するのに使用されている。
const postsWithDate = Object.keys(postsOrderByDate).map((key, _) => { return ( <div key={`date:${key}`}> <DateHeader date={key} /> <PostList posts={postsOrderByDate[key]} updatePostList={updatePostList} /> </div> ); });
しかし、この処理ではObject.keys()
が返すキーの順序に無頓着であるにも関わらず、開発者としては先のpostsOrderByDate
オブジェクトに挿入した順序でキーの順序が保たれることを期待している。より厳密には、元となる投稿の配列posts
は既に作成日時で降順ソートされているという前提で、postsOrderByDate
およびpostsWithDate
では日時の降順にデータが保たれていることを期待している。
しかしながら、JavaScriptのオブジェクトは公式ドキュメントでも記載されている通り、一般的にはプロパティの順序が保証されないものとして扱うべきである。ECMAScript 2015以降ではプロパティの列挙順をある程度で保証するような仕様変更がなされたとはいえ、様々な落とし穴もあるため好ましくはない。
このような場合は、Mapオブジェクトを使用することが推奨されている。Mapオブジェクトでは挿入順でキーを返すことが保証されているため、キーの順序に依存する処理を安全に書くことができる。
const dateToPosts = new Map<string, PostWithChildPosts[]>(); posts.forEach((post) => { const date = new Date(post.createdAt); const dateStr = date.toLocaleDateString(); const now = dateToPosts.get(dateStr) ?? []; now.push(post); dateToPosts.set(dateStr, now); });
その他の落とし穴
useEffect が2回実行される
Reactではコンポーネントが最初にマウントされたタイミングで一度だけ実行したいような処理を書くとき、依存配列が空なuseEffect()
を使用することがある。例えばトップページにアクセスした時に、最新の投稿一覧を表示したい場合などがそうである。
useEffect(() => { async function startFetching() { const json = await fetchCurrentPosts(); setPosts(json); } startFetching(); }, []);
しかし、Reactは開発環境においてStrictModeが有効な場合に、すべてのコンポーネントを最初にマウントした直後に一度だけ再マウントする、という挙動をとる。そのため、開発環境では上記のようなuseEffect()
が2回続けて実行されることがある。
これによる不都合を回避する方法は、クリーンアップ関数を実装してあげることである。addEventListener()
に対するremoveEventListener()
のようにわかりやすい場合はいいのだが、明確なクリーンアップ関数が存在しない場合は工夫が必要だ。例えば上記のデータフェッチのような場合は、次のようなignore
変数を用いることで、以前のマウント時に発行されたリクエストが後のマウント時のstateに影響を与えないようにすることが可能になる。
useEffect(() => { let ignore = false; async function startFetching() { const json = await fetchCurrentPosts(); if (!ignore) { setPosts(json); } } startFetching(); return () => { ignore = true; }; }, []);
このテクニックは、もちろん依存配列が空でない場合にも有効である。
useCallback は memo と一緒に使う
コンポーネントの不必要な再レンダリングを避けたくて、次のようなコードを書くことがある。
function ProductPage({ productId, referrer, theme }) { const handleSubmit = useCallback((orderDetails) => { post('/product/' + productId + '/buy', { referrer, orderDetails, }); }, [productId, referrer]); return ( <div className={theme}> <ShippingForm onSubmit={handleSubmit} /> </div> );
JavaScriptでは関数をはじめとするオブジェクトの等価性は参照によって比較されるため、単にhandleSubmit
をコンポーネント内で定義するだけでは、再レンダリング前後では別物として扱われてしまう。これを避けるためには、レンダリング前後でメモ化された関数を返すことが可能なuseCallback()
を使うことが有効である。
例えば上記の例では、親コンポーネントから渡されるtheme
propsの値が変わっても、それを参照しないhandleSubmit
には何も影響がないはずなので、useCallback()
でメモ化することでtheme
propsの値が変わった後も同一のものとして扱うことができる。これはつまり、ShippingForm
コンポーネントに渡されているpropsがProductPage
コンポーネントのレンダリング前後で同一と言うことなので、一見するとShippingForm
コンポーネントの再レンダリングを防げるかのように思われる。
しかしながら、デフォルトではReactはコンポーネントが再レンダリングされた際には、その子コンポーネントも全て再帰的に再レンダリングしようとしてしまう。よって、このままではShippingForm
コンポーネントの再レンダリングを防げてはいないのだ。
theme
propsの変更によるShippingForm
コンポーネントの再レンダリングを妨げるには、ShippingForm
コンポーネントをmemo
でラップする必要がある。
const ShippingForm = memo(function ShippingForm({ onSubmit }) { // ... });
なぜかこの仕様を忘れてしまうので気をつけたい......。