🧹

【React】Reactのみでシンプルなアーキテクチャを考える

2025/01/13に公開

React 内での状態管理についてフックのみで制御できるようなアーキテクチャを考えたのでこちらを記事にします。React での状態管理についてはReduxが有名ですが作成した WEB アプリがそれほど複雑ではなかったのとイマイチ仕組みを理解できてなかったので React のみで完結するアーキテクチャを考えました。

あとから Redux の内部実装見てみましたが、Redux を利用した際、リソースのスコープが広がるためページを跨いで参照したいデータなどが多ければ選択肢になりそうな印象です。今回はほとんどのデータが各ページで独立したデータを利用していたので Redux 使っても旨味が薄かったかなとも思いました。

アーキテクチャ

考えたアーキテクチャが下記のイメージです。基本的には MVC と同様な単方向フローのシンプルな構成です。
アーキテクチャ図

Container, Componentが見た目を司る層になります。React のコンポーネントはこちらに相当します。プロジェクトで利用するデータ(ユーザー情報など)はHookにて制御します。それらを UI に伝えるためにViewModelを用います。
Serviceはビジネスロジックが含まれる層です。ただ WEB アプリによってはビジネスロジックはバックエンド側にあることも多いので不要な場合もあるかと思います。

View

View にあたるコンポーネントはContainerComponentで区別しています。こちらは 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は下記のようになります。

loginViewModel.ts
/**
 * ログイン画面の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 を考えると次のようになります。

useLogin.ts
/**
 * ログイン画面の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にビジネスロジック以外の関数も定義しましたが必要に応じでProviderRepository層に切り出せばよいと思います。

ここで定義する関数はできる限り純粋関数の方が良いです。ビジネスロジックをテストするときにテストコードが書ける(書きやすい)という点は継続的に開発する際に重要です。

依存性の注入

ここでは主にContainerへの依存性の注入について示します。

  • Containerでカスタムフックを呼び出す
  • useContextで Context から依存性を取得する

Containerでカスタムフックを呼び出す

ContainerHookのカスタムフックを呼び出すことで依存性を注入します。例えばログインページを示すLoginPageではLoginViewModelを利用するため
useLoginを用いて viewmodel を取得します。

LoginPage.tsx
// ログインページ
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 として提供することで、どのページからもただ一つの情報源のデータを参照することになります。

App.tsx
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を定義します。

LoadState.tsx
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は読み込み中の場合に参照しようとするとエラーになるように設計しました。今回は_loadingfalse_errorundefinedではないときにデータを保持していることにしました。LoadStateのコンストラクタを公開していないため想定していない状態が作られないこともないのでメンテナンスしやすいです。
ちなみにこれは flutter の状態管理ライブラリRiverpodAsyncValueを参考にしました。

このLoadStateuseStateで使えるようにしたカスタムフックuseLoadStateも定義します。

useLoadState.ts
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と更新関数群setLoadStateuseStateに似た使い方ができるようにしました。

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を導入します。

Loading.tsx
/**
 * ロード中を表すコンポーネント
 * @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 の最新情報全然追えていないので車輪の再開発にならないようにキャッチアップしないといけないですが、今ある知識で実装するとに新機能に気付けない。キャッチアップする癖をつけないとなあ。

GitHubで編集を提案

Discussion