この記事はクラウドワークス グループ Advent Calendar 2024 シリーズ2の21日目の記事です。
はじめに
こんにちは。工数管理SaaS クラウドログ でエンジニアをしている @eiji03aero です。
突然ですが、みなさんはReactのステート管理とそれを扱うロジックを、どのような構成で実装されていますでしょうか?
zustandやReduxのようなライブラリを入れていたり、愚直なuseState
x Context
でシンプルに保ったり、様々な方法があるかと思います。
どういう構成で実装するかは個人的にいつも悩むトピックです。
そこでこの記事では、ちょっと変わり種な構成のReact実装についてご紹介します。
やりたいこと
ステート管理ライブラリValtioを使って、Reactのステート管理とロジックの実装をただのクラスに持っていくという実装を試してみます。
何の話かと言うと、イメージとしてはこんな感じです:
通常のReactでは、ステートの作成や更新の方法はReactの世界の中で提供されるため、基本的にそれらはコンポーネントやカスタムフックに実装される必要があります。これは具体的にはuseState
フックはコンポーネントやカスタムフックの中でしか使用することができない制約があるためです。
しかし、Valtioを導入することによって、イメージの右側のように、Reactのような特定のフレームワークに依存しない世界にステートそのものやそれを読み書きするロジックを持ち出し、アプリのコアな部分の実装を切り出すことができます。
なぜそんなことをするのかというモチベーションは後述します。
そもそもValtioって?
ValtioはReactとVanilla JS向けに作られたステート管理ライブラリです。
使い方は簡単で、proxy
という関数にオブジェクトを渡すことでステートオブジェクトを生成するだけです。
このステートオブジェクトは直接値を変更することが可能で、その変更は各種方法によって購読することができます。
簡単にコードサンプルを出してみます。
下記サンプルではReact向けのuseSnapshot
を使ってReactのコンポーネントにステートの変更を購読させています。
import { proxy, useSnapshot } from 'valtio';
// ステートオブジェクトの生成
const state = proxy({ text: 'hello' });
// ステートを変更する関数
function changeText(text: string) {
state.text = text; // ステートの変更処理が、Reactに依存しない関数に実装されている!
}
// コンポーネント
function TextBox() {
const snap = useSnapshot(state);
return (
<input
value={snap.text} // <= useSnapshot経由でステートを購読してUIに反映する
onChange={(e) => changeText(e.target.value)}
/>
)
}
Valtioは、特筆すべき点として「ステートの読み書きを、Reactの領域の外側 (コンポーネントやフックではない箇所) で行うことができる」という特徴をもっています。
今回試す実装はこの特徴にそのまま乗っかったものとなります。
またボーナスとして、useSnapshot
を通してステートを読んでいる場合は、そのステートが使用している値に変更があった場合のみレンダリングが走るよう自動で最適化をしてくれています。やったね。
なぜこれをやるのか
このValtioを引っ張り出してまでなぜこんなことをするのかというと、以下のモチベーションが考えられます。
1. 取りうるアーキテクチャの選択肢を広げる
スピードを落とさずに拡張していくコードベースを構築したい時、どういったアーキテクチャで実装を構築するかはじっくり考えたいものです。
Reactの世界でもステートに関わる実装のアーキテクチャとしては、Reduxや、xstateのようなステートマシン、またはReactのuseContext
で愚直に実装するなどさまざまなものが活用されています。
しかしこういったすでに世の中にあるアーキテクチャが現実の要件をうまく倒してくれるとは限りません。
そんな時に、ロジックの実装をフレームワークに依存しないプレーンなコードで書けると、より自由な構成でコードを組むことができ、アーキテクチャの選択肢を広げることができるのではないかと思います。
2. フレームワークを跨いでのロジックの再利用性の向上
ステートやロジックの実装を、フレームワークに縛られない形とすることによって、再利用性を押し上げることができます。
サービスによっては、同じプロダクト群の中でもReactとVueを併用するなど、複数種類のフレームワークを併用するケースはあるかと思います。その時に、例えばステートとそれに紐づくロジックの実装をReactのカスタムフックに収めると、その実装はVueで再利用することができなくなってしまいます。
そこで、プレーンなクラスでロジックを実装して、フレームワークに依らない実装にしていれば、フレームワークを跨いだロジックの再利用は比較的簡単になるかと思います。
結構狙い撃ちしたユースケースになりますが、実装を厳密に一元管理してスケールさせたい時にハマるかもしれません。
3. バックエンドエンジニアの人が親しみやすいように
Reactでの実装は、バックエンド中心に開発するエンジニアとしては、なかなかすんなり入っていきづらいところがあるのではないかと思います。
例えば、これはJavaScriptの特性になりますが「関数やクラスまでもを含む色んな値を、関数の引数や返り値で引き回す」という点は普段から扱っていないと戸惑うかもしれません。
またReactの話で言うと、「カスタムフックとはなんぞや」というところもなかなかとっつきにくいかもしれません。Reactのライフサイクルやプリミティブなフックを理解していないと、実装したくてもすぐにイメージがすぐに浮かばないのではと思います。
というところで、実装の中心となるステートの管理とそれを扱うロジックだけでもクラスに外出しにできていれば、一定程度Reactの世界から解放されて実装にとっつきやすくなるのではないでしょうか。
クラスのインスタンスの内部状態にステートを持ち、メソッドでステートの操作を行うと言った実装であれば、Reactにあまり触れていないエンジニアでも実装のイメージが湧きやすいのではと思います。
実装の紹介
前置きが長くなりましたが、ここからは実装を交えて、この実装方針が具体的にどういうコードになるのか見ていきたいと思います。
サンプルの実装を用いて簡単に紹介してみます。
サンプルの題材はTodoアプリで、TodoやTagの「作成」や「状態変更」を行うことができます。
この中でTodoを作成する部分の処理の流れを追ってみたいと思います。
コードはこちら:
アーキテクチャの全体感
まず最初にアーキテクチャについて説明しておきます。
ざっくりと以下のような構成となります。
実装の方針としては以下となります:
- ページに対応する形で、
ViewService
クラスを定義する- このクラスがそのページに固有なステートとそれを操作するロジックを持つ
- そのロジックをpublicなメソッドとして公開することで、Reactの世界からそれらの機能を呼び出せるようにする
- ページに特有でない共通実装は、
Service
、Repository
、Model
などで切り出しておく - Reactの世界は、
useSnapshot
を用いてステートの変更を購読する
基本的な処理の流れとしては以下となります:
- 1: UI上でユーザーが操作を行うことでイベントが発行され、コンポーネントはそれを検知します
- 2: コンポーネントは
ViewService
クラスのメソッドを発火します - 3: 発火されたメソッドがステートを変更します
- 4: Valtioは
useSnapshot
にステートの変更を通知します - 5:
useSnapshot
は通知された変更を元に、レンダリングをトリガーします - 6: コンポーネントは変更をUIに反映します
Todo作成の処理の流れ
それではコードを見ていきます。
1. TodosViewService
の初期化
最初にTodosViewService
の実装について確認しておきます。
import { proxy } from "valtio";
// ...
type TodosViewState = {
createForm: {
input: string;
due: Date | null;
};
// ...
};
export class TodosViewService {
// ...
private state: TodosViewState;
constructor(params: {/* */}) {
// ...
this.state = proxy<TodosViewState>({
createForm: {
input: "",
due: new Date(),
},
// ...
});
}
// ...
}
今回の実装の中心となるクラスです。
先述した通り、このクラスはValtioが生成するステートオブジェクトを保持します。
ステートはこのクラスのインスタンス化の際に初期化されます。
今回の紹介に関連のある部分だけを上記サンプルコードに残しており、Todo作成フォーム用のinput (名前)
とdue (締切日)
をここでは初期化しています。
今回のサンプル実装ではこのクラスのインスタンスをuseRegistry
というuseContext
をラップしたカスタムフック保持させて取り回す形にしています。
2. Todoの値を入力する
次にTodo作成フォームへの変更をステートに書き込んでいくところをみてみます。
// ...
import { useSnapshot } from "valtio";
import { useRegistry } from "../../../contexts/RegistryContext";
import { Todo } from "../../../domain/models/Todo";
// ...
export const TodosView = () => {
const registry = useRegistry();
const viewSvc = registry.get("todosViewSvc"); // <= ①
const state = useSnapshot(viewSvc.getState()); // <= ②
// ...
return (
<>
<h1 className="p-component text-2xl">Todos</h1>
<div className="flex mb-3 gap-3">
<div className="p-inputgroup flex-auto">
<InputText
placeholder="Todo name"
value={state.createForm.input} // <= ③
onChange={(e) => viewSvc.changeInput(e.target.value)} // <= ④
/>
</div>
<div>
<Calendar
value={state.createForm.due} // <= ③
onChange={(e) => viewSvc.setDue(e.value)} // <= ④
showIcon
minDate={new Date()}
/>
</div>
<div>
<Button label="Create" onClick={() => viewSvc.createTodo()} />
</div>
</div>
{/* ... */}
</>
);
};
TodosView
コンポーネントは、Todoを管理するページに対応するコンポーネントです。
今回の作成フォームに関わる内容としては:
- ①:
useRegistry
からTodosViewService
クラスのインスタンスを取り出します - ②:
useSnapshot
を使ってこのインスタンスが保持するステートの変更への購読を開始しています - ③: stateの値を各フォーム要素に渡します
- ④:
TodosViewService
クラスのメソッドを使ってステートの更新が行えるようにイベントハンドラのpropsとつなぎ込みをします
// ...
export class TodosViewService {
// ...
changeInput(input: string) {
this.state.createForm.input = input;
}
setDue(due: Date | null | undefined) {
this.state.createForm.due = due ?? null;
}
// ...
}
つなぎこまれたメソッドはこのような実装となります。
ステートオブジェクトのプロパティに値を代入をして更新をするだけとなります。
3. Todoを作成する
次にTodoを作成していく部分です。
// ...
export const TodosView = () => {
// ...
return (
<>
<h1 className="p-component text-2xl">Todos</h1>
<div className="flex mb-3 gap-3">
{/* ... */}
<div>
<Button label="Create" onClick={() => viewSvc.createTodo()} />
</div>
</div>
</>
);
};
保存ボタンがクリックされたところで、Todoの作成を受け持つcreateTodo
メソッドが呼び出されます。
// ...
export class TodosViewService {
// ...
async createTodo() {
if (!this.state.createForm.due) { // <= ①
return;
}
await this.todoSvc.create({ // <= ②
name: this.state.createForm.input,
due: this.state.createForm.due,
status: "todo",
tagIds: [],
});
this.state.createForm.input = ""; // <= ③
this.toastSvc.show({ severity: "success", summary: "Created a todo" }); // <= ③
}
// ...
}
こちらがcreateTodo
メソッドです。
バリデーション -> 作成 -> 後片付けの順に処理が実装されています。
- ①: 簡易的なバリデーションです
- ②: Todoに関するロジックを受け持つ
TodoService
のcreate
メソッドを呼び出しています - ③: 各種後片付け処理です。ここでは名前の初期化と、Toastによるフィードバックを実行しています
簡単にはなりますが、Todo作成の処理の流れは以上となります。
補足
- サーバーステートの扱い
- ややこしくなるため深く扱いませんが、サーバーステートに当たる部分はTanStack Queryで管理しており、Reactの世界での
useQuery
の再読み込みをService
やViewService
が主導できる形にしています
- ややこしくなるため深く扱いませんが、サーバーステートに当たる部分はTanStack Queryで管理しており、Reactの世界での
まとめ
Valtioを使ったReactの実装について紹介をしてみました。
Githubのスター数は他の選択肢に比べると控えめなライブラリですが、自由度が高く、いろいろなことを試せそうと感じています。(すばらしいものをありがとうございます!)
こういった実験をしている時が一番楽しいのですが、試して終わりではなく、実務にも還元できるようアンテナを張っていきたいと思います。