Jotai を使ったリアーキテクチャで React Hook Form のつらみから抜け出す

こんにちは、Webアプリケーションエンジニアの鈴木です。

私が開発を担当しているtebiki現場分析では、今年の2月から6月にかけて大きな品質改善(リアーキテクチャ)を行いました。

リアーキテクチャの内容は、フォームの状態管理ライブラリを React Hook Form から Jotai に移行するというものです。

当記事では

  • React Hook Form に対してどのような課題を抱えていたのか
  • Jotai をどのように使い、どんなアプローチでそれを解決したのか

などを紹介させていただきます。

React Hook Form で苦しんだ経験を持つ方にとって何か参考になれば幸いです!

はじめに

その昔、tebiki現場分析の記録作成画面は "ユーザーが入力した値を保存するだけ" のシンプルなものでした。

入力できる形式には「自由入力」「数値」「画像」「日時/日付」などバリエーションはありますが、各入力項目がそれぞれ独立しており、依存関係もありません。

そこで、シンプルな画面の実装に対してパフォーマンスが高く、スキーマバリデーションライブラリとの親和性も高い React Hook Form を採用しました。

シンプルなフォームの例

しかし、サービスが成長し多様なユーザーニーズが明らかになると、React Hook Form では実装が複雑になるユースケースが増加してきます。

昨年の12月にリリースした自動計算機能は、まさにそのようなユースケースの代表でした。

自動計算とは

tebiki現場分析における自動計算は、"ユーザーが入力した値" と "事前に定義した計算式" をもとに、別の記録の値を算出する機能です。

例えば、記録者が入力した「生産数」と「不良数」の値をもとに、事前に定義された「(不良数/生産数) * 100」という計算式に沿って結果を算出し、これを「不良率」として自動で記録することができます。

自動計算を含むフォームの例

現場の記録業務では、こうした "記録した内容をもとに別の値を算出して記録する作業" が頻繁に発生します。

先の「不良率」の例は単純ですが、実際の現場ではより複雑な計算が求められます。

例えば、冷凍唐揚げをつくる工場では、仕入れる食肉の重量が日によって変わります。同じ味を安定して出すには、肉の重量に応じて他の原材料の量を調整する必要があります。

原材料の配合比率はレシピで決まっており、担当者はそのレシピに沿って毎日電卓で必要量を計算し、機械に投入しています。

計算を誤れば不良につながりますし、そもそも毎回の計算自体が大きな負担です。

このように "入力値から別の値を導く作業" は日常的に起きており、私たちのプロダクトでも実現できなければならない機能でした。

React Hook Form で自動計算を実装することの難しさ

そんな自動計算機能ですが、React Hook Form を使って実現しようとすると、いくつかの壁にぶつかることになりました...。

ここからは、自動計算機能の実装を振り返りながら、当時のtebiki現場分析が抱えていた課題、そしてなぜ Jotai を使うこと選んだのかを深掘りしていきます。

① 自動計算のコンポーネント側が "計算処理" と "form への値の設定" を担う形

React Hook Form で自動計算を実現するため、最初はuseWatchで参照先のフィールドを購読し、useEffect内でsetValueを実行するアプローチを取りました。

const CalculatorComponent = () => {
  // ...

  // 変数として使用している他の Input を購読し、入力内容が変わるたびに再計算する
  const subscribeValues = useWatch({ control, name: variableNames });
  const result = calculate(subscribeValues)

  // 計算結果が変わるたびに setValue でフォームに反映する
  useEffect(() => {
    setValue(name, result)
  }, [name, result])

  // ...
}

この実装には以下の利点があります。

  • 責務の局所化
    • 計算処理の責務が自動計算コンポーネント内に完全に閉じているため、数値入力コンポーネントなどの購読される側は自動計算の存在を意識する必要がない
  • 宣言的な処理
    • 購読している入力値が変更されるたびに自動で計算処理が実行され、結果が保存される仕組みを宣言的に記述できている

しかし同時に、重大な制約も抱えています。

useEffect を使用しているため、コンポーネントがアンマウント状態では動作しないという問題です。

tebiki現場分析の記録画面では、パフォーマンス向上のために仮想スクロールを採用しています。仮想スクロールで非表示になったコンポーネントはアンマウント状態となるため、値の再計算・更新が行われなくなってしまいます。

このマウント状態への依存は、実装当初は問題になりませんでした。

しかし、気付きにくい特性であるがゆえにバグを生みやすく、今後の機能拡張においても大きな制約となることが予想されました。

(実際、検討していた自動計算の拡張案の中には、この状態を解消しないと実現できないものもありました)

利点 欠点
・単一責任である
・宣言的に書ける
・マウント状態でないと動作しない(仮想スクロールで動かない)
② 計算処理をイベントハンドラの中で呼び出す形

マウント依存の問題を解決するため、私たちは useEffect を廃止し、イベントハンドラ内で関連する計算行いを手動で setValue する形にリファクタリングしました。

const CalculatorComponent = () => {
  // ...

  // CalculatorComponent は計算結果を表示するだけの役割に変更
  const result = useWatch({ control, name: name });

  // ...
}

const NumberInputComponent = () => {
  // ...

  const handleChange = () => {
    // ...

    // 自身を変数として使用している calculator を全て取得し、計算後に setValue で更新
    const calculators = retrieveCalculators()
    calculators.forEach((calculator) => {
      const variables = calculator.variables.map((v) => getValues(v.name))
      const result = calculate(calculator.formula, variables)

      setValue(calculator.name, result)
    })
  }

  // ...
}

これによりマウント状態への依存は解決できましたが、この実装方法にも新たな課題が生じました。

  • 過剰な責務
    • 入力コンポーネント(ex. 数値入力)が、「自分がどの計算に使われるのか」という本来知る必要のない情報にアクセスしている
  • 処理が手続的である
    • 手続的な処理を適切なタイミングで呼び出す必要がある
    • => これは、計算のトリガーが増えるたびに呼び出し元も増加することを意味し、人的ミス(呼び出し漏れ)や全体像の把握困難につながる

依存関係がシンプルであれば、この実装で十分だったかもしれません。しかし私たちの場合、この課題も許容できませんでした。

というのも、リファクタリング後に「自動計算の機能拡張」を進めていたところ、呼び出し漏れによるバグが実際に発生してしまったからです。

幸いにも顧客への影響はありませんでしたが、この実装を続ける限り、バグが生まれるリスクと常に隣り合わせの状態が続くことになります...。

利点 欠点
・マウント状態に依存しない ・責務が過剰である
・処理が手続的である(人的ミスが生まれやすい)

tebiki現場分析で解決したい、本質的な課題とは?

私たちが抱えている本質的な課題を理解するために、これまでの情報をまとめてみます。

まず、自動計算機能は、入力値同士から算出される派生値(Derived value)として捉えることができます。

React Hook Form で派生値の Read/Write を実現するには、前述の例のとおり「コード上のどこかで setValue を呼び出し、それを watch する」必要があります。

マウント状態に依存させたくない場合は useEffect が使えないため手続的な手段を取らざるを得ませんが、そうすると多くの箇所で setValue を呼び出すことになり、保守のしづらいものになってしまいます。

要するに、私たちが解決すべき課題は以下の2点に集約されると言えます。

  • 課題①:仮想スクロールとの相性上、UI(特に、マウント状態)に依存させたくない
  • 課題②:派生値を宣言的に記述したい。これによって、保全性の向上状態の整合性の担保がしたい

これら2つの要求を自然な形で満たすには、React Hook Form は相応しくないのではないか?

この結論により、私たちは React Hook Form からの移行を検討し始めました。

リアーキテクチャのコンセプト

これらの課題感を受けて、私たちが設定したリアーキテクチャのコンセプトは以下の通りです。

1. 記録に関する状態管理のロジックは中央集権化し、UIと疎結合にする

記録に関するロジックは、自動計算だけでなくマスタデータの取得/自動入力処理など、さまざま存在します。これらのロジックは各コンポーネントに分散しており、UI と強く結びついていました。

そのため、記録全体のデータの整合性を保つには複数コンポーネント間での状態の同期や調整が必要になります。

結果として、全体としての整合性について責任を持つ構成要素が存在しない状況でした。

こうした問題により、コード全体の見通しが悪くなり、不整合な状態を生むリスクが高まっていました。

そこで、各コンポーネントに分散したロジックを UI から分離して一箇所に集約し、記録内容全体の整合性を中央で管理する設計方針にしました。

2. パフォーマンスとのトレードオフで、状態の整合性と保全性を重視する

次に、派生値の計算方法として、ユーザーの入力をベースに記録内容全体を再計算する方式を採用しました。

なぜ一部分だけでなく全体を再計算するのか?それは、自動計算における複雑な依存関係が理由です。

記録内の各項目は相互に依存しており、一つの値が変わると複数の項目に連鎖的に影響を与えます。

一部分だけを再計算した場合、この依存関係の連鎖を正確に把握し切れず、計算漏れが発生するリスクがあります。

全体を再計算することで、こうした依存関係の見落としを防ぎ、常に正しい状態を維持できます。また、局所的な更新処理を避けることで実装の複雑さを軽減し、バグの混入リスクも最小化できます。

この方針により、ある程度のパフォーマンス低下は避けられません。

しかし実際のユースケースを考慮すると、この程度のパフォーマンス低下はユーザー体験に大きな影響を与えません。一方で、計算ミスによるデータ不整合が発生した場合の影響は極めて深刻です。

そのため、多少の処理時間の増加は許容範囲として、データの確実性とその管理のしやすさを最優先とする設計判断を行いました。

なぜ Jotai を選んだのか

これらのコンセプトを実現するための手段として、私たちは Jotai を選択しました。その理由は以下の通りです。

1. UI と分離した状態の一元管理

Jotai では atom という単位でグローバルな状態管理を行います。atom は UI コンポーネントとは独立して定義されるため、状態管理のロジックを完全に分離できます。

従来は各コンポーネントに分散していた記録データの管理を atom として一箇所にまとめて定義でき、記録に関するロジックの全体像を把握しやすくなります。

また、UI に依存しないため、仮想スクロールなどの UI 側の制約に左右されることなく、堅牢で一貫性のある状態管理を実現できると考えました。

2. Derived Atom による派生値の自然な表現

Jotai では Derived atom を定義することで、データの依存関係を宣言的に定義できます。

詳しくは後述しますが、私たちが目指す「全体更新」のアプローチも Derived atom を使用することで自然に表現することが可能でした。

これにより、データが変更されると関連するすべてのデータが自動的に更新され、状態の整合性が自然と保たれる仕組みを実現できます。

3. レンダリングのパフォーマンス最適化

Derived atom を定義することで、UI コンポーネント側では useAtom を使って必要な atom の値のみを購読できます。データが変更された際も、実際に変更が発生した部分のみが再レンダリングされます。

この仕組みにより、「全体更新」という方式を採用しても、UI の再レンダリング範囲は最小限に抑えられます。

4. 軽量で扱いやすい

Zustand や Redux といった他の状態管理ライブラリと比較して軽量・単純で、学習コストを低く抑えられます。また、Suspense に対応しており、非同期の状態管理とも自然に統合できるため、私たちのユースケースに適していました。

これらの特徴により、Jotai は私たちが抱えていた課題を解決する最適な選択肢だと判断しました。

Jotai で解決する自動計算の課題

リアーキテクチャのコンセプトを実装に反映し、これまでに挙げた React Hook Form での課題を Jotai でどのように解決したかを詳しく解説します。

※ 複雑な説明を避けるため、簡略化して記載します。

Form の型設計

まず、Form を表す型は UI を構成するための pages とユーザーの入力を保持する ItemById の2つの要素に分けて設計しました。

// 記録画面の入力フォームを表す型定義
type Form = {
  pages: Page[]; // UI 構成情報
  ItemById: Record<Id, Item>; // ユーザー入力値
};

この設計により、UI の構造とデータの管理を明確に分離し、それぞれに対して適切なアクセスパターンを提供できるようになります。

状態更新のアーキテクチャ

状態更新のロジックは以下のような構成で実装しました。

// フォームの基本状態を保持する Primitive atom
export const formBaseAtom = atom<Form>();

// フォーム全体を更新する関数を返す atom
const updateFormAtom = atom<(form: Form) => Form>((get) => {
  return (form) => {
    // 現在のフォームに自動計算を適用し、
    // 計算結果が反映された Form を返す
    const computedFields = updateComputedFields(form);

    return computedFields;
  };
});

// 自動計算が適用されたフォームを返す Derived atom
export const formAtom = atom(
  (get) => {
    const base = get(formBaseAtom);
    const update = get(updateFormAtom);

    return update(base);
  },
  (_get, set, action: SetStateAction<Form>) => {
    set(formBaseAtom, action);
  },
);

この実装の核心は、コンセプトで説明した全体再計算のアプローチにあります。

自動計算が適用された状態のフォーム全体を派生値として捉えることで、入力値の変更時に影響を受ける可能性のあるすべての項目を確実に再計算できるようになります。

具体的な実装では、「Primitive な Form の atom(formBaseAtom)」と「自動計算が適用された後の Form を表す Derived atom(formAtom)」という二層構造を採用しています。

こうすることで、ユーザーの入力に反応したフォーム全体の再計算を実現することができました。

個別フィールドの最適化

個々の入力フォームでは、atomFamily を使って formAtom から派生した Derived atom を作成し、useAtom で読み込むことで再レンダリングの範囲を最小限に抑えています。

// 個別フィールド用 atom 定義
export const ItemFamily = atomFamily((id: ItemId) =>
  focusAtom(formAtom, (o) => o.prop('ItemById').prop(id)),
);

// コンポーネントでの使用例
const [item, setItem] = useAtom(ItemFamily(id));

この atomFamilyfocusAtom の組み合わせにより、特定のフィールドが変更された際に、そのフィールドを使用しているコンポーネントのみが再レンダリングされるようになります。

非同期処理への対応

基本的な実装は上記の通りですが、非同期処理が絡む場合など、局所的な更新が必要となるケースも存在します。例えば、非同期処理を挟んで外部から入力値を取得して設定するケースなどがあります。

このような箇所では、専用の setter を別途定義し、局所的な更新を行っています。

// 非同期データ適用用 atom 定義
export const applyAsyncDataAtom = atom(
  null,
  async (get, set, action) => {
    const asyncData = await fetchAsyncData()

    set(formBaseAtom, (form) => {
      return applyAsyncData(form, asyncData)
    })
  }
)

// コンポーネントでの使用例
const applyAsyncData = useSetAtom(applyAsyncDataAtom)

const handleChange = async () => {
  await applyAsyncData()
}

この部分については、整合性をどこまで妥協できるかや計算量の多さ、コストの高さなどを総合的に加味して判断しています。

このような実装により、全体更新を基本としつつも、必要に応じて局所的な更新も可能な柔軟なアーキテクチャを実現できました。

Jotai の活用方法についてはまだ改善の余地があると考えていますが、現時点でも十分な効果を得ることができています。

得られた効果

今回のリアーキテクチャで設定した目的を振り返ると、

  • UI に依存しない状態管理の実現
  • 保全性の向上と状態の整合性の担保

のように整理できます。これに対して、実際に得られた効果をまとめてみます。

1. 全体像の把握しやすさと予測可能性の向上

記録全般のロジックを中央集権化したことで、form 全体の複雑な動きの見通しが良くなり、予測しやすいコードとなりました。

以前は各コンポーネントに分散していた責務が一箇所に集約されたことで、「何がどこで起こっているのか」を把握しやすくなったのが大きな収穫です!

2. テスタビリティと拡張性の改善

atom ごとに独立したテストが書けるようになり、atom 内で使用する処理も関数として切り出しているため、ロジック単体でのテストが容易になりました。これにより品質担保もしやすくなっています。

以前は React Hook Form を使用していた関係で、React Testing Library を使って UI を通したテストが中心でしたが、現在はより細かい粒度でのテストが可能となり、バグの早期発見につながっています。

3. 強固な整合性の担保とミスの削減

当初の目的であった「ミスの余地を減らし、強い整合性を担保する」ことが実現できました。

実際に、記録画面のその後の開発として「条件分岐機能」の追加や「自動計算の変数に自動計算を使う」といった複雑な機能の開発を進めていますが、これらの実装もスムーズに進められています。

現時点でミスによるバグも発生しておらず、アーキテクチャの効果を実感しています!

まとめ

今回の経験を通じて、適切な技術選択と設計により、複雑なドメインロジックも見通し良く実装できることを改めて学びました。

今後もプロダクト品質の課題に向き合いながら、より良いプロダクトを目指していきたいと思います!

なお、今回のリアーキテクチャについては、リリース方法や段階的な移行の進め方など、まだまだお話ししたいことがあります。しかし今回は「なぜ React Hook Form から Jotai に移行したのか」「どのような実装になったのか」という部分に焦点を絞らせていただきました。

機会があれば、移行プロセスの詳細についても記事にしたいと考えています!


そして、私たちは一緒に働く仲間を募集しています。

まだまだ成長途中の会社ということもあり、新規開発でもプロジェクトの品質改善でも、技術的にチャレンジングなことがまだまだあります!

この記事でご興味を持っていただけた方、まずはカジュアルにお話しましょう!

▼採用HP tebiki.co.jp

▼募集中のエンジニア求人一覧 herp.careers