🧭

【React】関数型プログラミングを実践する上での、条件分岐の俺的ベストプラクティス

2022/10/09に公開約6,100字9件のコメント

はじめに

React書いてますか?
ReactもFunctional Componentが主流になっていることにより、必然的に関数型で書き進めることになっていると思います。
そんな令和時代、毎度困る場面の一つが条件分岐。
js・tsの条件分岐といえば、if文、switch文、三項演算子など種類は様々。
どういうときにどの形式で書くのか毎度迷うし、その選定を間違えたら汚いコードになりがち。
そこで、今回は条件分岐を書き方の個人的ベストプラクティスを書いていきます!

基本方針

条件分岐の結果を何かの変数に収める

これを意識するだけで割とすぐに関数型っぽくなります。可読性やテスト容易性、変更容易性なども上がります。
これの代表的な例が三項演算子だったりします。

const userName = user ? user.name : 'ユーザーはいません';

ただ、これif文とかでもできたりします。
JavaScript入門段階でif文を習うとき、こんな例をよく見たりします。

const point = 80;
let result;

if (point >= 70) {
  result = '合格';
} else {
  result = '不合格';
}

console.log(result);
// 合格

でも、これをそのまま使おうとすると、この条件分岐の部分だけ手続き型になっていたり、letで定義された部分だけ値の不変性が保証されなかったり、分離しずらかったりします。つまり違和感ありありで扱いづらくなってしまいます。

この問題を解決するには、関数でラップしてあげるのがいいでしょう。

const judge = (point: number) => {
  if (point >= 70) {
    return '合格';
  }

  return '不合格';
};

条件分岐の結果をjudgeの返り値とすることで、条件分岐処理を関数judge()で収めることができます。
また、関数内ではreturnで処理が終わり、その後ろは基本的に無視されます。これを利用すれば、上記の例のようにelseを省くことができます。

tsxの返り値(UI表現部分)で条件分岐を書かない

これは後に例で示しますが、UI部分が長く複雑になる要因となります。また、UIとロジックで責務の分離もしにくくなります。

場面別対処法

1回の条件分岐のみ、条件分岐内で何かしらの処理がいらないとき

シンプルに三項演算子を使いましょう。

例えば、別ファイルでgetUserというユーザー取得関数を定義したとして、そのユーザー名を表示するコンポーネントを作るとしましょう。

Profile.tsx
import { FC } from 'react';
import { useQuery } from '@tanstack/react-query';
import getUser from 'domains/getUser';

type Props = {
  userId: number;
};

const Profile: FC<Props> = (props) => {
  const { userId } = props;
  const getSpecificUser = async () => {
    const specificUser = await getUser(userId);

    return specificUser;
  };
  const { data: user } = useQuery(['user', userId], getSpecificUser);

  const userName = user ? user.name : 'ユーザーはいません';

  return <p>ユーザー名:{userName}</p>;
};

export default Profile;

react-queryを使ってレンダリング時にユーザー取得処理をしています。また、Propsで取得するユーザーIDを受け取っている状況です。
取得した情報をuserで受け取っています。もし対象IDのユーザーが取得されていたらユーザー名を、ユーザーが存在しなかったら「ユーザーはいません」という文字列をuserNameで受け取っています。

const userName = user ? user.name : 'ユーザーはいません';

この条件分岐では、userという値のみ見ればよく、また値を返すまでに別処理を挟むことはないので、これで十分でしょう。

また、この条件分岐をpタグ内で処理することは避けましょう。

return <p>ユーザー名:{user ? user.name : 'ユーザーはいません'}</p>;

こうしてしまうと可読性がグッと下がります。これを許してしまうとtsxファイル内のHTMLを返している部分が複雑になっていきます。
また、ロジック部分とUI部分で分離しにくくなり、保守性も下がります。

条件分岐が階層になっている場合や、3択以上あるとき

この時は三項演算子はふさわしくありません。if文を使いましょう。

上記の例から、性別により異なるクラス名をpタグにつける場合を考えましょう。
ユーザー存在判定→性別判定と2階層の条件分岐となります。

Profile.tsx
/* eslint-disable no-nested-ternary */
import { FC } from 'react';
import { useQuery } from '@tanstack/react-query';
import getUser from 'domains/getUser';

type Props = {
  userId: number;
};

const Profile: FC<Props> = (props) => {
  const { userId } = props;
  const getSpecificUser = async () => {
    const specificUser = await getUser(userId);

    return specificUser;
  };
  const { data: user } = useQuery(['user', userId], getSpecificUser);

  const userName = user ? user.name : 'ユーザーはいません';
  const genderColor = user ? (user.gender === '男性' ? 'blue' : 'pink') : '';

  return <p className={genderColor}>ユーザー名:{userName}</p>;
};

export default Profile;

これを三項演算子で書いてしまうと、可読性が下がります。
基本的に、三項演算子は純粋な2択の条件分岐しか向いていません。
ESLintのエラーでもno-nested-ternaryというのが存在するくらいです。

こういうときは、2階層の条件分岐を関数でラップして処理させましょう。

Profile.tsx
import { FC } from 'react';
import { useQuery } from '@tanstack/react-query';
import getUser from 'domains/getUser';

type Props = {
  userId: number;
};

const Profile: FC<Props> = (props) => {
  const { userId } = props;
  const getSpecificUser = async () => {
    const specificUser = await getUser(userId);

    return specificUser;
  };
  const { data: user } = useQuery(['user', userId], getSpecificUser);

  const userName = user ? user.name : 'ユーザーはいません';
  const genderColor = () => {
    if (!user) {
      return '';
    }

    if (user.gender === '男性') {
      return 'blue';
    }

    return 'pink';
  };

  return <p className={genderColor()}>ユーザー名:{userName}</p>;
};

export default Profile;

genderColor内で条件分岐処理を行なっています。

const genderColor = () => {
  if (!user) {
    return '';
  }

  if (user.gender === '男性') {
    return 'blue';
  }

  return 'pink';
};

実は2階層の条件分岐処理なのですが、returnと組み合わせることでif文を入れ子にする必要がなく、シンプルに記述ができます。

関数の記述の部分で行数が増えて読みにくくなっているように思えますが、そういう場合は別ファイルで定義してimportしてしまえばよりシンプルになります。
これが関数でラップすることのもう一つのメリットで、責務を分離して1ファイルを小さく保つことも容易になります。

条件分岐により別のUIを出力したいとき

if文で出力するUIを切り替えましょう。

上記の例から、ユーザーが存在するときは詳細なプロフィールを出力し、存在しないときはメッセージのみ出力する場合を考えましょう。

Profile.tsx
import { FC } from 'react';
import { useQuery } from '@tanstack/react-query';
import getUser from 'domains/getUser';

type Props = {
  userId: number;
};

const Profile: FC<Props> = (props) => {
  const { userId } = props;
  const getSpecificUser = async () => {
    const specificUser = await getUser(userId);

    return specificUser;
  };
  const { data: user } = useQuery(['user', userId], getSpecificUser);

  if (!user) {
    return <p>ユーザーはいません</p>;
  }

  const { name, gender, age } = user;
  const genderColor = gender === '男性' ? 'blue' : 'pink';

  return (
    <div className={genderColor}>
      <p>ユーザー名:{name}</p>
      <p>性別:{gender}</p>
      <p>年齢:{age}</p>
    </div>
  );
};

export default Profile;

これまでの例と大きく変わっています。

if (!user) {
  return <p>ユーザーはいません</p>;
}

const { name, gender, age } = user;
const genderColor = gender === '男性' ? 'blue' : 'pink';

この条件分岐でuserundefinedの場合の出力するUIを定義しています。
実はこの条件分岐により、上記のif文以降でuserundefinedである可能性を消してくれています。
よって型の安全性が担保されたため、前の例で個別で処理していたユーザーが存在するかどうかのチェックをする必要がなくなります。
なのでuserのプロパティも分割代入でシンプルに記述でき、付与するクラス名の判定も三項演算子で1行で書けるようになっております。

これは、コンポーネント自体が関数(=functional component)であることを利用していて、コンポーネント内全体で大きく型ガードができます。

おわりに

いかがでしたか?
こちらに関しては好みがあると思いますので、上記を参考に各々で調整していただければと思います。
また、もっとこうした方がいい!というのがありましたら意見いただけると助かります!コメントお待ちしております。
twitterのフォローもお願いいたします!
それでは!

参考

りあクト! TypeScriptで始めるつらくないReact開発 第4版【① 言語・環境編】
りあクト! TypeScriptで始めるつらくないReact開発 第4版【② React基礎編】

Discussion

オプショナルチェーン(?.)と同じES2020で追加されたnull合体演算子を使うともう少しコンパクトに書けます.

const userName = user?.name ?? 'ユーザーはいません';
// user.name が null または undefined のときのみ代入される

const genderColor = (user?.gender ?? '') && (user.gender === '男性' ? 'blue' : 'pink')
// 複雑なので記事のようにuser.genderの存在判定を先にした方がよさそうです.

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing_operator

基本この辺の演算子は可読性とのトレードオフですが,Reactを書いていると手続き型や行数が増えることを避けがちですね.

コメントありがとうございます!

そうですね。null合体演算子とか、場合によっては||等のショートサーキット評価もありなのかなと思います。
基本的に、可読性を損なわずに1-2行程度で書けるのであればこういった演算子を用いる方向で考えています。

説明変数を足すこと自体は賛成ですが、userNameの導入には反対です。userNameに"ユーザーはいません"というフォールバックが入ることを知らずに <p>ユーザー名: {userName}</p> だけ見たときに挙動を勘違いしうるからです。userNameという変数名をやめるか、単に user?.name ?? 'ユーザーはいません' と書き下した方が誤解が生じづらいです。

他の例も賛同しかねます。例えば「条件分岐が階層になっている場合や、3択以上あるとき」の例ですが、if(!user) return <p>ユーザー名: ユーザーはいません</p> という early return があれば narrowing も効いてかなり読みやすくなるはずです。<p className={genderColor()}>ユーザー名:{userName}</p> と書かれるといちいち genderColor の実装と userName の中身を調べないと動作を読み取れないので、あまり上手くない抽象化だと思います(僕ならレビューで弾きます)

ご指摘ありがとうございます!

そうですね、例として挙げたものが悪かった気がします。
説明変数を定義してあげて可読性を高める、という趣旨で書いていました。
userNameという変数名はご指摘の通りふさわしくないと思うので修正いたします。
(上記の例、「ユーザーがいないときは『ユーザーはいません』という文字列を出力するという例を変更するかもしれません)

2つ目の以下の指摘は僕も書いている途中に気づきました。

if(!user) return <p>ユーザー名: ユーザーはいません</p> という early return があれば narrowing も効いてかなり読みやすくなるはずです。

しかし、「UIの変更はしない」「関数内で条件分岐させてあげた方がいい」例として見せたかったので無理矢理このようにした次第です。(FC内での早期リターンは3つ目の例として出したかったというエゴです。)
多分これも例が悪く、一つ前の例から引き継ぎの形で書いたことが原因のように思います。
2つ目に関してはもっといい例を示したく思います。(2つ目の例が極端に少ないorないようなら内容自体見つめ直そうと思います。)

良い記事ですね!
React で条件分岐は私もよく考えされられます!
本筋とちょっと外れますが、コード例にある getSpecificUser は単純に Promise を返すだけなので asyncawait はいらないと思いました!

  // 例にある関数
  const getSpecificUser = async () => {
    const specificUser = await getUser(userId);

    return specificUser;
  };

  // こっちの方が簡潔
  const getSpecificUser = () => getUser(userId);

ご指摘ありがとうございます!
確かにそちらの方がベターですね!

三項演算子って、入れ子にせずに同じ方向に延ばしていく分には、3択以上になっても実は可読性が下がらないと思いますね。

const userName = user == null ? ''
                : user.gender === '男性' ? 'blue'
                : user.gender === '女性' ? 'pink'
                : 'white';

ts-pattern を採用すると、かなりスッキリ分岐を書けます。
なんなら、条件分岐ではなくパターンマッチングにできます。

こっちの方が良いというより、説得難しいっていう感想です。

三項演算子が許可されてるなら、単純な比較と値を返すだけならif ~ returnを1行で書くことも許されるかと。。。

例示されたコードはきれいだけど、行数の差が大きすぎて、PRなどで「ネストした三項演算子は読みづらい」って指摘したときに「なれれば1行の方が読みやすい」って言われそうだなぁと思いました。

const genderColor = () => {
  if (!user) return '';
  if (user.gender === '男性') return 'blue';

  return 'pink';
};

コーディングに対して合意を得るのって難しいですよねぇ。

ログインするとコメントできます