【React】Reactのみでシンプルなアーキテクチャを考える
React 内での状態管理についてフックのみで制御できるようなアーキテクチャを考えたのでこちらを記事にします。React での状態管理についてはReduxが有名ですが作成した WEB アプリがそれほど複雑ではなかったのとイマイチ仕組みを理解できてなかったので React のみで完結するアーキテクチャを考えました。
あとから Redux の内部実装見てみましたが、Redux を利用した際、リソースのスコープが広がるためページを跨いで参照したいデータなどが多ければ選択肢になりそうな印象です。今回はほとんどのデータが各ページで独立したデータを利用していたので Redux 使っても旨味が薄かったかなとも思いました。
アーキテクチャ
考えたアーキテクチャが下記のイメージです。基本的には MVC と同様な単方向フローのシンプルな構成です。
Container
, Component
が見た目を司る層になります。React のコンポーネントはこちらに相当します。プロジェクトで利用するデータ(ユーザー情報など)はHook
にて制御します。それらを UI に伝えるためにViewModel
を用います。
Service
はビジネスロジックが含まれる層です。ただ WEB アプリによってはビジネスロジックはバックエンド側にあることも多いので不要な場合もあるかと思います。
View
View にあたるコンポーネントはContainer
とComponent
で区別しています。こちらは Container/Presentational パターンで UI とロジックを分離するためのアーキテクチャです。Component(Presentational Component)
には状態を持たず、Props
で渡されたデータを用いて UI を設計する部分を責務とします。
Container(Container Component)
はロジック(状態)をもちComponent
にデータを渡します。このアーキテクチャではContainer
で ReactHook を呼び出し、状態を保持するようにしています。
このパターンのメリットは UI とロジックが分離されるため、テストがしやすいということと再利用性が高くなることです。Component
は純粋関数(入力と出力が明確な関数)で定義できるためテストがしやすくなります。またComponent
には状態がないためビジネスロジックが介入しません。そのため様々なユースケースで用いることができるコンポーネントを定義することができます。
- Component
- 再利用性の高い UI を Component として定義する
- 例) トグルスイッチ、テキストフォーム
- Container
- ReactHooks で状態を保持し Component にデータを渡す
- 例) ログインページなどページそのものを示すコンポーネント
ViewModel
ViewModel
は Hook で実装され、Container に状態や振る舞いを渡すために利用します。今回はこちらを typescript のtype
として表現しています。例えばログイン画面を描画する Container で利用するLoginViewModel
は下記のようになります。
/**
* ログイン画面のViewModel
*/
export type LoginViewModel = {
/** メールアドレス */
email: string;
setEmail: (state: string) => void;
/** パスワード */
password: string;
setPassword: (state: string) => void;
/**
* ログインする
*/
login: () => Promise<void>;
/** メッセージ */
loginResult: LoadState<string | null>;
};
ログインページでメールアドレスやパスワードを入力するため、入力されたメールアドレス、パスワードを状態として保持します。またログインするという振る舞いについても定義します。
これらのプロパティについては実際には Hook で実装し Container から呼び出すことになります。
ViewModel を定義するメリットとしては Hook から内側のレイヤー(よりビジネスロジック近い層)を React から分離することができます。今回は Hook を ReactHooks で実装していますが、Redux や他の状態管理ライブラリで実装することが容易になります。また当然 ViewModel で定義した type であれば View(Container)のコードを変えることなく動作するためテストのための Stub に置き換えたりすることもできます。
Hook
Hook
は ReactHooks で表現し、ViewModel で定義した状態や振る舞いを実装します。LoginViewModel
を実装した Hook を考えると次のようになります。
/**
* ログイン画面のHook
*/
export const useLogin = (): LoginViewModel => {
// メールアドレス
const [email, setEmail] = useState("");
// パスワード
const [password, setPassword] = useState("");
// メッセージ
const [loginResult, setLoginResult] = useLoadState<string | null>(null);
const login = useCallback(async () => {
const params: AccountService.LoginParam = {
email: email,
password: password,
};
try {
await AccountService.login(params);
setLoginResult.data("ログインしました。");
} catch (exception: unknown) {
if (exception instanceof ApiResponseBaseError) {
setLoginResult.error(exception);
}
}
}, [email, navigate, password, setLoginResult]);
return {
email,
setEmail,
password,
setPassword,
login,
loginResult,
};
};
Service
Service
では主にビジネスロジックや API でデータをやり取りするための関数を記述します。 今回はそこまで規模の大きいプロジェクトではなかったのでService
にビジネスロジック以外の関数も定義しましたが必要に応じでProvider
やRepository
層に切り出せばよいと思います。
ここで定義する関数はできる限り純粋関数の方が良いです。ビジネスロジックをテストするときにテストコードが書ける(書きやすい)という点は継続的に開発する際に重要です。
依存性の注入
ここでは主にContainer
への依存性の注入について示します。
-
Container
でカスタムフックを呼び出す -
useContext
で Context から依存性を取得する
Container
でカスタムフックを呼び出す
Container
でHook
のカスタムフックを呼び出すことで依存性を注入します。例えばログインページを示すLoginPage
ではLoginViewModel
を利用するため
useLogin
を用いて viewmodel を取得します。
// ログインページ
export function LoginPage(): React.ReactNode {
const viewModel = useLogin();
return (
<div>
<div className="w-1/5 mx-auto">
<TextInput inputKey="メールアドレス" placeholder="" value={viewModel.email} setValue={viewModel.setEmail} />
<PasswordInput
inputKey="パスワード"
placeholder=""
password={viewModel.password}
setPassword={viewModel.setPassword}
/>
</div>
</div>
);
}
useContext
で Context から依存性を取得する
useContext
で Context から ViewModel を注入することができます。 Context.Provider
については React のエントリーポイントとなるコンポーネントで利用することを推奨します。 Context.Provider で囲われたコンポーネント以降でデータを参照できます。また Context が変更された場合、 Context.Provider
にリビルドがかかるため、適切に使用しないと無駄なリレンダリングが発生しバグの温床になりがちです。 そのためContext.Provider
を利用する箇所を限定し、適切なリレンダリングが発生するように心がけましょう。
使い分けとしてはページを跨いでも利用する情報は Context に保持したほうが情報の一元化ができます。例えば web ページにログインしたユーザーの情報を示すloggedInUserViewModel
を Context として提供することで、どのページからもただ一つの情報源のデータを参照することになります。
export function App(): React.ReactNode {
const loggedInUserViewModel = useLoggedInUser();
const router = createBrowserRouter([
{
/// react-router-domでのルーティングを定義
},
]);
return (
<LoggedInUserContext.Provider value={loggedInUserViewModel}>
<RouterProvider router={router} />
</LoggedInUserContext.Provider>
);
}
非同期データの状態管理
バニラの Raect 内では ReactHooks を用いた状態管理があります。 ただ ReactHooks だけだと、Web アプリを作るときに必須となる非同期でデータを取得した際の状態管理について、都度実装していると複雑になりがちです。
- 読み込み状態を示す
State(loading)
と読み込むデータを示すState(data)
を用意する - API 叩く際に
loading
を読み込み状態にする - 読み込み完了したら
loading
を読み込み完了状態にし、data
に読込結果を格納 - 読み込みエラーであれば
loading
をエラー状態にする
ここにリトライする処理なども考慮すると毎回これを実装するのは億劫です。そこで統括的に制御できるLoadState
を考えました。非同期処理における読み込み状態(_loading
)、読み込むデータ(_data
)、エラー(_error
)について一緒にまとめたLoadState
を定義します。
export type State<T> = T | null;
/**
* 非同期状態を扱う。
* _data 非同期データ nullであればデータなし
* _error エラー データの読み込みに失敗
* _loading ロード中かどうか
* ロードが終わってエラーが無ければデータあり。
*/
export class LoadState<T> {
private _data: State<T>;
private _error: Error | undefined;
private _loading: boolean;
private constructor(data: State<T>, loading: boolean, error?: Error) {
this._data = data;
this._loading = loading;
this._error = error;
}
static newLoad<T>(): LoadState<T> {
return new LoadState<T>(null, true);
}
static newData<T>(data: T): LoadState<T> {
return new LoadState<T>(data, false);
}
static newError<T>(error: Error) {
return new LoadState<T>(null, false, error);
}
get data(): T {
if (!this.hasData()) {
throw new Error("No data in LoadState");
} else {
return this._data as T;
}
}
hasData(): boolean {
return !this.isLoading() && !this.isError();
}
get error(): Error {
if (this.isError()) {
return this._error as Error;
} else {
return new Error("No Error in LoadState");
}
}
isError(): boolean {
return this._error != undefined;
}
isLoading(): boolean {
return this._loading;
}
}
data
は読み込み中の場合に参照しようとするとエラーになるように設計しました。今回は_loading
がfalse
で_error
がundefined
ではないときにデータを保持していることにしました。LoadState
のコンストラクタを公開していないため想定していない状態が作られないこともないのでメンテナンスしやすいです。
ちなみにこれは flutter の状態管理ライブラリRiverpod
のAsyncValue
を参考にしました。
このLoadState
をuseState
で使えるようにしたカスタムフックuseLoadState
も定義します。
type useLoadStateUpdateFunctions<T> = {
load: () => void;
data: (data: T) => void;
error: (error: Error) => void;
};
// LoadStateをuseStateで使うためのカスタムフック
export function useLoadState<T>(initialState?: T): [LoadState<T>, useLoadStateUpdateFunctions<T>] {
const [loadState, setLoadState] = useState<LoadState<T>>(
initialState === undefined ? LoadState.newLoad() : LoadState.newData(initialState),
);
const updateLoad = useCallback(() => {
setLoadState(LoadState.newLoad());
}, []);
const updateData = useCallback((data: T) => {
setLoadState(LoadState.newData(data));
}, []);
const updateError = useCallback((error: Error) => {
setLoadState(LoadState.newError(error));
}, []);
const updateFunctions: useLoadStateUpdateFunctions<T> = useMemo(() => {
return {
load: updateLoad,
data: updateData,
error: updateError,
};
}, [updateData, updateError, updateLoad]);
return [loadState, updateFunctions];
}
返す配列は状態を示すloadState
と更新関数群setLoadState
でuseState
に似た使い方ができるようにしました。
useLogin.ts
でも使っていますがログイン結果を示すloginResult
については下記のように読み込み状態やエラーの制御を機械的に記述することができます。
// メッセージ
const [loginResult, setLoginResult] = useLoadState<string | null>(null);
const login = useCallback(async () => {
const params: AccountService.LoginParam = {
email: email,
password: password,
};
try {
await AccountService.login(params);
setLoginResult.data("ログインしました。");
} catch (exception: unknown) {
if (exception instanceof ApiResponseBaseError) {
setLoginResult.error(exception);
}
}
}, [email, navigate, password, setLoginResult]);
こうなると View についても機械的に非同期処理を書きたいですよね。そこでLoading
を導入します。
/**
* ロード中を表すコンポーネント
* @param state ロード状態
* @param builder ロード完了時に表示するコンポーネント
*/
export function Loading<T>({
state,
builder,
loadingBuilder,
errorBuilder,
}: {
state: LoadState<T>;
builder: (state: T) => React.ReactNode;
loadingBuilder?: () => React.ReactNode;
errorBuilder?: (message: string) => React.ReactNode;
}): React.ReactNode {
if (state.isLoading()) {
if (loadingBuilder) {
return loadingBuilder();
} else {
return <LoadingIndicator />;
}
} else if (state.isError()) {
// APIレスポンスエラーではない場合内部エラーが画面に出てしまうと良くないので隠蔽
const errorMessage = state.error instanceof ApiResponseBaseError ? state.error.message : "エラーが発生しました";
if (errorBuilder) {
return errorBuilder(errorMessage);
} else {
return <ErrorMessage message={errorMessage} />;
}
} else {
return builder(state.data);
}
}
Loading
は渡されたLoadState
の状態に応じてレンダリングする UI を変更します。builder
にはLoadState
で取得したデータで UI を構築する関数を指定することができます。エラー時とローディング時の UI を定義しておくと読み込み時の UI を簡潔に記述できると思います。
<Loading
state={viewModel.loginResult}
builder={(message) =>
message === null ? null : <div className="mt-10 text-center">{message}</div>
}
/>
まとめ
React のみで完結するアーキテクチャを考えました。React 使う際に状態管理は Redux !と脳死で考えていましたが、React だけでもシンプルな Web アプリであれば問題ないことがわかりました。
あるあるだと思いますが実装進めているうちにフレームワークのアップデートで作った機能が公式でサポートされることありますよね。
React の最新情報全然追えていないので車輪の再開発にならないようにキャッチアップしないといけないですが、今ある知識で実装するとに新機能に気付けない。キャッチアップする癖をつけないとなあ。
Discussion