Open26

(メモ)Reactコンポーネントアンチパターン

kazuma1989kazuma1989

(追加)アンチパターン以前にReactのルール違反

  • プロップ、ステートのミューテーション
  • フックの条件分岐
  • フックの繰り返し呼び出し

プロップのアンチパターン

  • プロップが多すぎるコンポーネント
  • プロップを副作用のトリガーにするコンポーネント
  • オブジェクトをプロップにもつコンポーネント

ステート管理のアンチパターン

  • ステートが多すぎるコンポーネント
  • 算出できる値をステートにもつコンポーネント
  • 頻繁に変化するステートをコンテクストで渡すコンポーネント
  • (追加)子供にステートを直接変更させるコンポーネント
  • (追加)現在の値をもとに次の値を作るとき、関数を使わない

(追加)どこかでフォームバリデーションの具体例を入れたい

  • あらゆる入力を受け付け、バリデーション違反の通知だけする
  • バリデーション違反にならない入力だけ受け付ける
  • バリデーション違反にならない入力だけ受け付け、さらに表示形式を変える(郵便番号や通貨としての表示など)
kazuma1989kazuma1989

頻繁に変化するステートをコンテクストで渡すコンポーネント

頻繁に変化するステートとは

  • テキスト入力
  • 即時関数
  • オブジェクト
kazuma1989kazuma1989

即時関数を渡してしまうパターン

function ParentFoo() {
  return (
    <Provider value={(newFoo) => setFoo(newFoo)}>
      <ChildBar />
    </Provider>
  )
}

毎回新しい関数参照が作られるので、レンダリングのたびに配下のコンポーネントもレンダリングされる。

kazuma1989kazuma1989

でもこの場合、ParentFooが再レンダリングされたらChildBarもどうせレンダリングされるので、たとえばuseCallbackするなどで関数参照の同一性を保ったところで、意味がない。

kazuma1989kazuma1989

意味があるならこういうパターン

function ParentFoo({ children }) {
  return (
    <Provider value={...}>
      {children}
    </Provider>
  )
}

ParentFooのレンダリングとchildrenのレンダリングが切り離されているとき。

<ParentFoo>
  <ChildBar />
</ParentFoo>
kazuma1989kazuma1989

リアルワールドだと具体的には何か?
「子供にステートを直接変更させるコンポーネント」のToastの例。

もう一つシンプルな例はないか?

kazuma1989kazuma1989

オブジェクトを渡してしまうパターン。
オレオレコンテクスト状態ストア。

function StoreProvider() {
  const [state, setState] = useState({})

  return (
    <StoreProviderContext.Provider
      value={{
        state,
        setState,
      }}
    >
      {children}
    </StoreProviderContext.Provider>
  )
}

function App() {
  return (
    <StoreProvider>
      <Header />

      <Body />

      <Footer />
    </StoreProvider>
  )
}

具体例として「ライト/ダークテーマの切り替え」とかにしておくか?
いや、テーマは全部のコンポーネントに影響があるから、影響範囲を限定する意味が薄く感じる。

APIレスポンスの保持もなあ、そうバンバカ起きることじゃないし。

やはりトーストメッセージなどの、「読み手は少ないが書き手は多い」ステートが適しているな。
コンテクストにまつわる問題は全部そうか。

kazuma1989kazuma1989
具体例 読み手 書き手
カラーテーマ 多い 少ない
言語設定 多い 少ない
API レスポンス 多い 少ない
認証情報 多い 少ない
トーストメッセージ 少ない 多い
ダイアログメッセージ 少ない 多い
ローディング 少ない 多い
kazuma1989kazuma1989

子供にステートを直接変更させるコンポーネント

function MyPage() {
  const [messages, setMessages] = useState([])

  return (
    <div>
      <Toast messages={messages} setMessages={setMessages} />

      <Foo setMessages={setMessages} />
    </div>
  )
}

密結合になって、MyPageとFooを分けた意味がない。
できれば、子供にはイベントの通知だけしてほしい。

Toastコンポーネントを使うために、useStateの書き方にもいちいち注意しなくてはならない。
型アノテーションを毎回書くなど。

Toastがmessages配列の管理をするのはまだいいが、Fooも配列の管理をやっていかないといけない。
間違って配列を空にしてしまうことが起きないわけではない。

kazuma1989kazuma1989

作戦1。
潔く手続型で書く。

function MyPage() {
  const toast$ = useRef()

  return (
    <div>
      <Toast ref={toast$} />

      <Foo
        onMessage={(message) => {
          toast$.current.notify(message)
        }}
      />
    </div>
  )
}

愚直でわかりやすさがある。
ToastをレンダリングしてもFooのレンダリングは不要。

kazuma1989kazuma1989

作戦2。
コンテクストを使って・・・を書こうとしたら、コンテクスト利用のプラクティスを結構知らないといけない構成になった。

これ自体がアンチパターンを生み出す具体例になる。

kazuma1989kazuma1989

作戦2具体例。
いろいろ知識が必要で面倒かもしれない。

ただ、ToastとFoo(もしくはほかにnotifyを呼びたいコンポーネント)の距離がいくら離れていても書き味が変わらないので、汎用性がもっとも高いかもしれない。

function MyPage() {
  return (
    <ToastMessageProvider>
      <Toast />

      <Foo />
    </ToastMessageProvider>
  )
}

export function ToastMessageProvider({ children }) {
  const [messages, setMessages] = useState([])

  // const notify = (message) => {
  //   setMessages((messages) => [...messages, message])
  // }
  // こういう関数を作ってToastMessageSetterContext.Providerに渡すのもいいが、
  // そうするとSetter利用するだけのコンポーネントも毎回レンダリングされてしまう。
  // 関数の参照が毎回変わるため。
  // const notify = useCallback((message) => {
  //   setMessages((messages) => [...messages, message])
  // }, [])
  // こういうふうにuseCallbackしないといけない。

  return (
    <ToastMessageValueContext.Provider value={messages}>
      <ToastMessageSetterContext.Provider value={setMessages}>{children}</ToastMessageSetterContext.Provider>
    </ToastMessageValueContext.Provider>
  )
}

export function useNotifyToast() {
  const setMessages = useContext(ToastMessageSetterContext)

  return (message) => {
    setMessages((messages) => [
      ...messages,
      {
        id: Math.random().toString(),
        message,
      },
    ])
  }
}

function Toast() {
  const messages = useContext(ToastMessageValueContext)
  const setMessages = useContext(ToastMessageSetterContext)

  return (
    <div>
      {messages.map(({ id, message }) => (
        <p
          key={id}
          onClick={() => {
            setMessages((messages) => messages.filter((message) => message.id !== id))
          }}
        >
          {message}
        </p>
      ))}
    </div>
  )
}

function Foo() {
  const notify = useNotifyToast()

  notify('hello!')
  // ...
}
kazuma1989kazuma1989

作戦3。
Toastコンポーネントと対になるフックを作る。

function MyPage() {
  const [notify, toastProps] = useToast()

  return (
    <div>
      <Toast {...toastProps} />

      <Foo
        onMessage={(message) => {
          notify(message)
        }}
      />
    </div>
  )
}

function useToast() {
  const [messages, setMessages] = useState([])

  const notify = (message) => {
    setMessages((messages) => [
      ...messages,
      {
        id: Math.random().toString(),
        message,
      },
    ])
  }

  const props = {
    messages,
    setMessages,
  }

  return [notify, props]
}

わかりやすさはあるが、messagesをMyPageのステートとして持つので、Toastをレンダリングすると(正確にはnotifyを呼ぶと)Fooまでレンダリングされてしまう。

kazuma1989kazuma1989

プロップが多すぎるコンポーネント

  • 単純に、抱えるUIが大きすぎる。
  • 種類の異なるものを無理やり共通化しようとして(見た目が似ているという理由だけで)同じコンポーネントに押し込め、複雑化している。
  • 算出できる値をわざわざもらっている。
  • コンポーネントたちの親玉で、バケツリレーの上流にいる。
kazuma1989kazuma1989

単純に、抱えるUIが大きすぎるパターン。

  • 検索条件のコンポーネントと、検索結果のコンポーネントが合体したコンポーネント。
    • これは分割しても、親がバケツリレー担当になってプロップ数が変わらない結果もありうる。
kazuma1989kazuma1989

種類の異なるものを無理やり共通化しようとして(見た目が似ているという理由だけで)同じコンポーネントに押し込め、複雑化しているパターン。

  • アカウント一覧のコンポーネントと、検索してヒットしたアカウントだけを表示するコンポーネント。
    • でもこれは結構特殊だな。普通は検索してヒットする結果は複数あるので、一覧と同じものを使うのは筋がとおっている。
    • 検索結果が1か0か、という特殊ケースだった。

ほかにいい例あるだろうか。

kazuma1989kazuma1989

オブジェクトをプロップにもつコンポーネント

いざというときにmemoでレンダリングの最適化ができない。

・・・うーん、まあそこまでアンチでもないな。
memoできないが必ずしも悪いわけじゃないから。
memoしなくても速く動くものは作れる、というかmemoしても速くなるとは限らない。

具体例。

  • APIレスポンスをそのまま受け取るコンポーネント。
kazuma1989kazuma1989

プロップを副作用のトリガーにするコンポーネント

単純に副作用と宣言的UIの相性が悪い、同期が難しい。
副作用には、たとえばフォーカス、動画プレイヤーの再生・停止などがある。

function Player({ play }: { play: boolean }) {
  const video$ = useRef()

  useEffect(() => {
    if (play) {
      video$.current.play()
    } else {
      video$.current.pause()
    }
  }, [play])

  return <video ref={video$} />
}

video要素側のUIでplayかそうじゃないかが変わったときにプロップを変化させる手段がない。
親コンポーネントに通知はできるが、絶対にplay=falseにしてくれるとも限らない。
(親コンポーネントも自分で作っているならそういう約束をとりつけるのも可能だろうが、本質的には不要な制約を生んでいるだけ)

そういうことするなら、Playerコンポーネントのrefを使ってref.current.play()を呼ぶのが健全。

kazuma1989kazuma1989

というかそもそも、これはプロップかステートかの問題ではなく、Reactが主かDOMが主かという話。
video要素の再生状態はDOMが主で、それを逆転させようと思ったら作り込みが必要。

「再生状態に応じて変化するボタン」を作りたいなら、DOMのイベントをリッスンして、それに従うコンポーネントを作らないといけない。

kazuma1989kazuma1989

どこかでフォームバリデーションの具体例を入れたい

第一に考えるべきは「あらゆる入力を受け付け、バリデーション違反の通知だけする」こと。

「バリデーション違反にならない入力だけ受け付ける」のは、ユーザーの意図と結果が乖離するので、戸惑いや不快感を生む。とくにパスワード入力欄では絶対にやってはいけない。パスワードがわからなくなる(実体験)。

ベストなのは、修正してもいいバリデーション違反は勝手に修正してあげること。
とくに、全角・半角の矯正、ハイフン・分かち書きの有無は勝手に修正すべき。

フィールドの意味によっては、勝手な修正はNGとなる。
たとえばパスワードは、同値性にしか意味がないので、全角・半角の矯正に限らずその他あらゆる変更は絶対だめ。

kazuma1989kazuma1989

フォームバリデーションだけじゃなく、フォームに起因するステート管理は全般的にいい具体例になるな。

楽観的UI更新の話もある。

APIから受け取った値、もしくはプロップで受け取った値をステート初期値にして、画面で編集した部分とマージして保持したいという場合。
プロップを初期値に使ったあとプロップが変わったらどうするか?が面倒。

useEffectのdepsで値の変更を監視するというのは、本来的な使い方ではないのでアンチパターン。
プロップとは別に空のステートを持って、ステートに値が入っているならそちらの値を表示、そうでないならプロップの値を表示、とすればいい。

props = {
  name: "John Doe",
  page: 32,
}

state = {
  page: 33
}

<input value={state.name ?? props.name} />
<input value={state.age ?? props.age} />
kazuma1989kazuma1989

視聴履歴の一覧がAPIから返ってきていて、それの一部をUI操作で削除した。

削除APIのレスポンスがリスト全量を返すわけではないとか、削除処理後にGET APIを再度呼ぶわけじゃないとかの場合、リストからの当該アイテムの削除はクライアント側のロジックで実現する必要がある。
楽観的UIを実現するときも同様。

その場合は、削除したアイテムのID一覧をステートとして保持し、表示のときにフィルターするのが賢い。

deletedItems = ["foo"]

<div>
  {items.filter((item) => !deletedItems.includes(item.id)).map(...)}
</div>
kazuma1989kazuma1989

アンチパターン具体例。
郵便番号の ___-____ のフォーマット。

Angularだとfilterという機能がある。
Reactは自前で作る。

Bad. いろいろと許せん実装をしてみた。
const App = () => {
  const [input, setInput] = useState('');
  const [formattedInput, setFormattedInput] = useState('___-____');

  return (
    <div>
      <input
        value={formattedInput}
        onChange={e => {
          const text = e.currentTarget.value;

          setFormattedInput(format(text));

          setInput(getOnlyNumber(text));
        }}
        onKeyDown={e => {
          if (e.key === 'Backspace') {
            const newInput = getOnlyNumber(formattedInput).slice(0, -1);
            setFormattedInput(format(newInput));
          }
        }}
      />

      <button
        type="button"
        onClick={() => {
          console.log({ input });
        }}
      >
        送信
      </button>
    </div>
  );
};

function getOnlyNumber(raw: string): string {
  return raw.replace(/\D/g, '');
}

function format(raw: string): string {
  const onlyNumber = getOnlyNumber(raw);
  const frontPart = onlyNumber.slice(0, 3).padEnd(3, '_');
  const endPart = onlyNumber.slice(3, 7).padEnd(4, '_');

  return `${frontPart}-${endPart}`;
}
onKeyDownも消したけど動きがキモイ
const App = () => {
  const [input, setInput] = useState('');

  return (
    <div>
      <input
        value={format(input)}
        onChange={e => {
          const text = e.currentTarget.value;

          setInput(getOnlyNumber(text));
        }}
      />

      <button
        type="button"
        onClick={() => {
          console.log({ input });
        }}
      >
        送信
      </button>
    </div>
  );
};

function getOnlyNumber(raw: string): string {
  return raw.replace(/\D/g, '');
}

function format(raw: string): string {
  const onlyNumber = getOnlyNumber(raw);
  const frontPart = onlyNumber.slice(-7, -4).padStart(3, '_');
  const endPart = onlyNumber.slice(-4).padStart(4, '_');

  return `${frontPart}-${endPart}`;
}
多少マシ
const App = () => {
  const [input, setInput] = useState('');

  const input$ = useRef<HTMLInputElement>(null);
  useEffect(() => {
    const inputLength = input.length;
    const position = inputLength <= 3 ? inputLength : inputLength + 1;

    input$.current?.setSelectionRange(position, position);
  });

  return (
    <div>
      <input
        autoFocus
        ref={input$}
        value={format(input)}
        onChange={e => {
          const text = e.currentTarget.value;

          const newInput = getOnlyNumber(text);
          setInput(newInput);
        }}
      />

      <button
        type="button"
        onClick={() => {
          console.log({ input });
        }}
      >
        送信
      </button>
    </div>
  );
};

function getOnlyNumber(raw: string): string {
  return raw.replace(/\D/g, '');
}

function format(raw: string): string {
  const onlyNumber = getOnlyNumber(raw);
  const frontPart = onlyNumber.slice(0, 3).padEnd(3, '_');
  const endPart = onlyNumber.slice(3, 7).padEnd(4, '_');

  return `${frontPart}-${endPart}`;
}

「多少マシ」をこうすると、動きとしては完璧になった。

 const App = () => {
-  const [input, setInput] = useState('');
+  const [{ input }, setInput] = useState({ input: '' });
 
   const input$ = useRef<HTMLInputElement>(null);
   useEffect(() => {
@@ -22,7 +22,7 @@ const App = () => {
           const text = e.currentTarget.value;
 
           const newInput = getOnlyNumber(text);
-          setInput(newInput);
+          setInput({ input: newInput });
         }}
       />
kazuma1989kazuma1989

Paginationコンポーネントもステート管理のおもしろい題材になりそう。
ページ式APIと絡むといろいろ考慮点がある。