研修で作ったアプリケーションのリファクタリングをした話

社内の研修でNext.jsを使ったWebアプリケーション開発を行なった。研修当時は短期間で完成させる必要もあり、かなりコードがおざなりになってしまっていた。また、新しくなったReactの公式ドキュメントに一通り目を通し始めたこともあり、タイミングを見て簡単なリファクタリングを試みることにした。

react.dev

ここでは、リファクタリングの中で得た知見について書き留めていくことにする。とはいっても、巨大なコンポーネントの分割だとかについて述べてもしょうがないので、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にしようと思ったら、そこでは同時にisEditingReplyisDeleteも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を一度呼び出すだけで済む。

参考: Choosing the State Structure – React

キーの順序に依存する処理を書きたいときは、オブジェクトではなく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);
});

参考: Map - JavaScript | MDN

その他の落とし穴

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;
  };
}, []);

このテクニックは、もちろん依存配列が空でない場合にも有効である。

参考: Synchronizing with Effects – React

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()を使うことが有効である。

例えば上記の例では、親コンポーネントから渡されるthemepropsの値が変わっても、それを参照しないhandleSubmitには何も影響がないはずなので、useCallback()でメモ化することでthemepropsの値が変わった後も同一のものとして扱うことができる。これはつまり、ShippingFormコンポーネントに渡されているpropsがProductPageコンポーネントレンダリング前後で同一と言うことなので、一見するとShippingFormコンポーネントの再レンダリングを防げるかのように思われる。

しかしながら、デフォルトではReactはコンポーネントが再レンダリングされた際には、その子コンポーネントも全て再帰的に再レンダリングしようとしてしまう。よって、このままではShippingFormコンポーネントの再レンダリングを防げてはいないのだ。

themepropsの変更によるShippingFormコンポーネントの再レンダリングを妨げるには、ShippingFormコンポーネントmemoでラップする必要がある。

const ShippingForm = memo(function ShippingForm({ onSubmit }) {
  // ...
});

なぜかこの仕様を忘れてしまうので気をつけたい......。

参考: useCallback – React