🐻

なぜuseStateを乱用してしまうのか?宣言型UIで考える状態管理

2024/10/16に公開

はじめに

こんにちは、Recustomer株式会社でインターンとしてフロントエンド開発に取り組んでいるRyoheiです。フロントエンド開発を始めて1年が経ち、様々な技術に触れてきましたが、特にReactにおいて、useStateの使い方に疑問を抱くようになりました。
useStateはReactの基本的なフックであり、状態を管理するために非常に便利な機能ですが、手軽に使えるがゆえに、適切な使用方法を考えずに乱用してしまうことがあります。これにより、コードが複雑になりやすく、保守性や可読性が低下するリスクがあることを実感しています。
そこで、useStateの役割や効果的な使い方を調べる中で、「宣言型UI」の理解が重要であることに気付きました。

今回は命令型UIと宣言型UIの違いについて掘り下げながら、useStateを効果的に使うためのヒントを探っていきたいと思います。

命令型UIとは?

命令型UIとは、UIの状態変更を明示的に制御する方法です。
各ステップでどのように画面が変化するかを具体的に指示しなければなりません。

命令型UIの具体例

例1

命令型UIでは、フォームの状態を逐次変更するために、以下のようなコードを書きます。

<body>
  <form id="userForm">
    <input type="text" id="nameInput" placeholder="Enter your name" />
    <button type="submit" id="submitButton" disabled>Submit</button>
  </form>

  <script>
    const nameInput = document.getElementById('nameInput');
    const submitButton = document.getElementById('submitButton');

    // 入力があると、手動でボタンの状態を変更
    nameInput.addEventListener('input', function() {
      if (nameInput.value.trim() !== '') {
        submitButton.disabled = false; // ボタンを有効にする
      } else {
        submitButton.disabled = true; // ボタンを無効にする
      }
    });
    // フォームの送信イベントを手動で管理
    document.getElementById('userForm').addEventListener('submit', function(event) {
      event.preventDefault(); // デフォルトの送信動作を無効化
      alert('Form submitted!'); // 送信完了のアラート
      submitButton.disabled = true; // ボタンを再度無効化
    });
  </script>
</body>

例2

これはボタンを押すごとに1ずつカウントアップしていくコードです。

<body>
  <p id="countDisplay">Current count: 0</p>
  <button id="incrementButton">Increment</button>

  <script>
    let count = 0;
    const countDisplay = document.getElementById('countDisplay');
    const incrementButton = document.getElementById('incrementButton');

    incrementButton.addEventListener('click', function() {
      count += 1;
      countDisplay.textContent = `Current count: ${count}`;
    });
  </script>
</body>

この様に一つずつ処理を明示していく必要があります。

命令型UIの問題点と大規模開発でのリスク

命令型UIでは、UIの状態を個別に更新するため、前の状態を正確に把握していないと誤った状態に遷移するリスクがあります。 複数の要素が異なる状態を参照する場合、それらの同期を適切に管理しなければUIの不整合が生じる可能性があります。
例えば、フォームのエラーメッセージと入力フィールドの有効無効の状態が適切に同期されていない場合、エラーが表示されているにもかかわらず、ユーザーが変更を行えないといった不具合が発生することがあります。

この画像では、運転手が助手席の指示に従って運転をしています。命令型UIも同様に、各ステップでUIの状態を個別に指示しなければならず、命令に誤りがあると結果的に正しいUI状態に到達できないことがあります。

特に、大規模なアプリケーションでは、複数の画面やコンポーネントが複雑に絡み合い、状態の同期や一貫性を確保するためのロジックが膨大になるため、バグが生じやすくなります。

宣言型UIとは?

宣言型UIは、ユーザーインターフェース(UI)の見た目や状態を、状態管理のロジックから切り離して明示的に記述するアプローチです。この手法では、UIの構造を定義する際に、最終的な結果(表示したい内容) を中心に考えることができます。

例えば、Reactのコンポーネントでは、アプリケーションの状態に基づいて自動的にUIが更新されます。これにより、開発者は状態の変化を手動で追跡する必要がなくなり、より高い抽象度でアプリケーションのロジックを構築できます。このアプローチにより、コードは直感的で読みやすく、保守性が向上します。

宣言型UIのもう一つの利点は、UIの状態を一元管理できる点です。たとえば、複数のコンポーネントが同じ状態を参照する場合、状態管理ライブラリ(例:ReduxやZustand)を使うことで、各コンポーネントが一貫したデータに基づいて動作することができます。これにより、アプリケーションの予測可能性が高まり、バグの発生を抑えることができます。

このように、宣言型UIは開発者にとって効率的で、読みやすいコードを書くための強力な手法となっています。

宣言型UIの具体例

import React, { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p>Current count: {count}</p>
      <button onClick={increment}>Increment</button>
    </div>
  );
};
export default Counter;

このコードは、従来の命令型アプローチから宣言型アプローチに移行した例です。このようにUIを細かく管理する(命令型)のではなく、視覚状態ごとにUIを記述することで、前の状態を正確に把握する必要がなくなるため、誤った状態に遷移するリスクが低減します。

例としてタクシーを考えてみましょう。目的地を運転手に伝えるだけで、どの道を通るかを考える必要がありません。このように、詳細な指示を与えずに目的を伝えることで、スムーズに移動できます。(タクシーで口頭で道を教えた時にグダグタした実体験ある方も多いのでは?)

宣言型UIの考え方と実践

宣言型UIを実践する上で重要なことは、名詞起点の設計を取り入れ、正しい工程を踏むことだと考えます。 そうすることで宣言型UIをより効果的に活用できるようになります。
ここでは名詞的な設計について具体的に説明し、宣言型UIを実現するためのプロセスを紹介します。

名詞起点の設計の概念とは?

名詞起点の設計は、UIや状態が 「どう動作するか」(動詞) を詳細に記述するのではなく、「何を表示するか」(名詞) に焦点を当てるアプローチです。例えば、お問い合わせ画面を作成する際に、「ボタンが押された時にバリデーションチェックをする」などの動作中心の思考を避け、名詞中心で考えます。

宣言型UIを実現するための5つの工程

宣言型UIを実現するためには、次の5つの工程を踏む必要があります。

  • 視覚状態の特定: コンポーネントが持つ様々な視覚状態を特定します。
  • トリガの決定: 状態変更を引き起こすトリガを明確にします。
  • stateの表現: useStateを用いて、メモリ上に状態を表現します。
  • 不要なstateの削除: 必要不可欠でないstate変数をすべて削除します。
  • イベントハンドラの接続: 状態を設定するためのイベントハンドラを接続します。

では、これから宣言型UIを実現するためのプロセスを具体的に説明します。大きく5つの工程に分かれています。

コンポーネントの様々な視覚状態を特定する

ユーザーが目にする可能性のあるUIの「状態」を定義し、可視化します。この工程は、名詞起点の考え方に基づいており、コンポーネントの設計をより直感的で明確なものにします。
名詞起点の設計を適用することで、状態がどのように表示されるかを重視し、ユーザーの体験を向上させます

  • Empty状態: フォームが空の状態。
  • Typing状態: ユーザーが入力中の状態。
  • Submitting状態: 送信中の状態。
  • Success状態: 送信成功の状態。
  • Error状態: エラーが発生した状態。

それらの状態変更を引き起こすトリガを決定する


今回のトリガの主語にあたる部分には二つの種類があると思います。それは人間コンピューターです。
そしてそれぞれのトリガを細かく分けると下記の様になります。

  • テキスト入力フィールドの編集(人間)により、テキストボックスが空かどうかによって、Empty 状態と Typing 状態を切り替える。
  • 送信ボタンのクリック(人間)により、Submitting 状態に切り替える。
  • ネットワーク応答の成功(コンピュータ)により、Success 状態に切り替える。
  • ネットワーク応答の失敗(コンピュータ)により、対応するエラーメッセージと共に Error 状態に切り替える。

useStateを使用してメモリ上にstateを表現する

可能な限りuseStateは少ない方が好ましいです。それは不要なレンダリングを防ぐためです。
しかしこのタイミングでは一度考えられるすべての視覚状態を確実にカバーできる十分な数の state を追加します。(次の工程で不要な物を削除します。)

const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

必要不可欠でない state 変数をすべて削除する

この工程がReactで宣言的UIを設計する上で最重要な工程です。
大規模開発になるとuseStateが乱用されることが多いです。useStateを使って状態を管理できているからいいと言う訳ではなく、useStateの乱用な不要なレンダリングを増やしパフォーマンスを下げてしまいます。

では先ほどのusestateの中で不要な物はどれでしょうか?

const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
  • 矛盾はないか
    • 例えばisTyping と isSubmitting の両方が true となることはありえません。
  • 別のstate変数の逆を取って同じ情報を得られないか?
    • 成功の反対は失敗です。つまり成功と失敗の両方のuseState用意せず、成功をfalseにすればいいのです。
  • 重複はないか
    • isEmpty と isTyping が同時に trueになることはありません。
    • これらを別々のstate変数にすることで、同期がとれなくなり、バグが発生する危険性があります。
    • コンピューターの状態をわざわざ分けずに、statusとして、まとめることができると思います。

まとめると次の様になります。

const [answer, setAnswer] = useState('');
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success' or 'error'

イベントハンドラを接続して state を設定する

最後に、state を更新するイベントハンドラを作成します。以下に、すべてのイベントハンドラが接続された最終的なフォームを示します。
このコードは、元の命令型の例よりも長くなっていますが、はるかに壊れにくくなっています。すべてのインタラクションを state 変化として表現することで、既存の state を壊すことなく、後から新しい視覚状態を導入することができます。また、インタラクション自体のロジックを変更することなく、各 state で表示されるべきものを変更することができます。

export default function Form(): JSX.Element {
  const [answer, setAnswer] = useState('')
  const [status, setStatus] = useState('typing')

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
    e.preventDefault()
    setStatus('submitting')
    try {
      setStatus('success')
    } catch (err) {
      setStatus('error')
    }
  }

  const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>): void => {
    setAnswer(e.target.value)
    setStatus('typing')
  }

  if (status === 'success') {
    return <h1>正解です!!</h1>
  }

  return (
    <>
      <h2>クイズ</h2>
      <form onSubmit={handleSubmit}>
        <textarea value={answer} onChange={handleTextareaChange} disabled={status === 'submitting'} />
        <br />
        <button disabled={answer.length === 0 || status === 'submitting'}>Submit</button>
      </form>
      {status === 'error' && <p>不正解です!</p>}
    </>
  )
}

まとめ

宣言型UIの「名詞」起点の考え方は、ユーザーインターフェースを直感的かつ効率的に設計するための強力なアプローチです。
状態を細かく管理するのではなく、UIが 「何を表示するか」 に焦点を当てることで、開発者は状態の変化を容易に扱うことができます。この手法により、コードはよりシンプルになり、バグの発生を抑えることができます。

本記事では、宣言型UIを実現するためのプロセスを具体的に示しました。効果的な宣言型UIを構築する方法を解説しました。

  • 視覚状態の特定
  • 状態変更を引き起こすトリガの決定
  • 適切なuseStateの使用
  • 不要なstateの削除
  • そしてイベントハンドラの設定

これにより、開発者はUIの変更に柔軟に対応しつつ、高い可読性と保守性を実現できます!
今後のプロジェクトにぜひこのアプローチを取り入れてみてください。


https://speakerdeck.com/uenitty/why-declarative-ui-is-less-fragile
https://ja.react.dev/learn/reacting-to-input-with-state

Discussion