🫤

[Flutter]各画面の状態管理をスマートに実装したい

に公開

はじめに

皆さんは、Flutterアプリで各画面のローディング状態やエラー状態をどのように管理していますか?

「データ取得中はローディング表示」「エラー時はエラー画面」「成功時はコンテンツ表示」といった、よくあるUIパターンを実装する際に、毎回似たようなコードを書いていませんか?

今回は、そんな状態管理のボイラープレートを大幅に削減できる実装方法をご紹介します。

前提:LoadingStateの必要性

まず前提として、各画面には以下のようなLoadingStateを用意する必要があります:


class LoadingState<T> with _$LoadingState<T> {
  const factory LoadingState.initial() = _Initial;
  const factory LoadingState.loading() = _Loading;
  const factory LoadingState.loaded(T data) = _Loaded;
  const factory LoadingState.error(String message) = _Error;
}

これにより、画面の状態を明確に管理できます。

従来の実装方法

多くの場合、以下のようなif文を使った実装になりがちです:

Widget build(BuildContext context) {
  final state = ref.watch(favoritePageProvider);
  
  if (state.loadingState is LoadingState<Loading>) {
    return const Center(child: CircularProgressIndicator());
  } else if (state.loadingState is LoadingState<Error>) {
    return Center(
      child: Column(
        children: [
          Text('エラーが発生しました'),
          ElevatedButton(
            onPressed: () => ref.read(favoritePageProvider.notifier).retry(),
            child: Text('再試行'),
          ),
        ],
      ),
    );
  } else {
    return ListView.builder(
      itemCount: state.favorites.length,
      itemBuilder: (context, index) {
        return ListTile(title: Text(state.favorites[index].name));
      },
    );
  }
}

問題点

  • 冗長なif文: 状態ごとにif-else文が必要
  • 重複コード: ローディングやエラー表示が画面ごとに似たような実装
  • 可読性: ネストが深くなりがち
  • 保守性: UI変更時に全画面を修正する必要

今回の提案:when関数の活用

今更ながら、when関数の存在に気づきました!😅

FreezedのUnion型にはwhenメソッドが自動生成されており、これを使うことで状態分岐を非常にスマートに書けます:

return state.favoritePageLoadingState.when(
  initial: () => const Center(child: CircularProgressIndicator()),
  loading: () => const Center(child: CircularProgressIndicator()),
  loaded: () => _buildFavoriteList(context, state, notifier),
  error: (message) => _buildErrorView(context, message, notifier),
);

しかし、これでもまだ各画面で似たようなコードを書く必要があります。

さらなる改善:StateBuilderの導入

そこで、StateBuilderというウィジェットを作成しました:

class StateBuilder<T> extends StatelessWidget {
  final LoadingState<T> state;
  final Widget Function() onLoaded;
  final Widget Function(String message)? onError;
  final Widget Function()? onLoading;
  final Widget Function()? onInitial;
  final VoidCallback? onRetry;

  const StateBuilder({
    super.key,
    required this.state,
    required this.onLoaded,
    this.onError,
    this.onLoading,
    this.onInitial,
    this.onRetry,
  });

  
  Widget build(BuildContext context) {
    return state.when(
      initial: onInitial ?? _defaultLoading,
      loading: onLoading ?? _defaultLoading,
      loaded: (_) => onLoaded(),
      error: onError ?? _defaultError,
    );
  }

  Widget _defaultLoading() {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }

  Widget _defaultError(String message) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Icon(Icons.error_outline, size: 64),
          const SizedBox(height: 16),
          const Text('エラーが発生しました'),
          const SizedBox(height: 8),
          Text(message),
          if (onRetry != null) ...[
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: onRetry,
              child: const Text('再試行'),
            ),
          ],
        ],
      ),
    );
  }
}

実際の使用例

Before(従来の実装)

Widget build(BuildContext context) {
  final state = ref.watch(favoritePageProvider);
  final notifier = ref.read(favoritePageProvider.notifier);
  
  return Scaffold(
    appBar: AppBar(title: Text('お気に入り')),
    body: state.favoritePageLoadingState.when(
      initial: () => const Center(child: CircularProgressIndicator()),
      loading: () => const Center(child: CircularProgressIndicator()),
      loaded: () => ListView.builder(
        itemCount: state.favorites.length,
        itemBuilder: (context, index) {
          return ListTile(title: Text(state.favorites[index].name));
        },
      ),
      error: (message) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.error_outline, size: 64),
            Text('エラーが発生しました'),
            Text(message),
            ElevatedButton(
              onPressed: () => notifier.init(),
              child: Text('再試行'),
            ),
          ],
        ),
      ),
    ),
  );
}

After(StateBuilder使用)

Widget build(BuildContext context) {
  final state = ref.watch(favoritePageProvider);
  final notifier = ref.read(favoritePageProvider.notifier);
  
  return Scaffold(
    appBar: AppBar(title: Text('お気に入り')),
    body: StateBuilder(
      state: state.favoritePageLoadingState,
      onLoaded: () => ListView.builder(
        itemCount: state.favorites.length,
        itemBuilder: (context, index) {
          return ListTile(title: Text(state.favorites[index].name));
        },
      ),
      onRetry: () => notifier.init(),
    ),
  );
}

StateBuilderの利点

1. コードの簡潔性

  • when文や条件分岐が不要
  • 成功時のUIのみに集中できる

2. 一貫したUX

  • ローディングやエラー表示が統一される
  • アプリ全体で一貫したユーザー体験を提供

3. 保守性の向上

  • ローディングやエラーUIの変更が一箇所で済む
  • カスタマイズも可能

4. 可読性の向上

  • 状態管理のボイラープレートが隠蔽される
  • ビジネスロジックに集中できる

カスタマイズ例

特定の画面で独自のローディングやエラー表示が必要な場合:

StateBuilder(
  state: state.loadingState,
  onLoaded: () => _buildContent(),
  onLoading: () => CustomLoadingWidget(), // カスタムローディング
  onError: (message) => CustomErrorWidget(message: message), // カスタムエラー
  onRetry: () => notifier.retry(),
)

まとめ

StateBuilderを利用することで、各画面にLoadingStateを構築するという手間はありますが、それをStateBuilderに受け渡すことで画面全体の表示ハンドリングがかなりスマートになります。

特に以下のような効果が期待できます:

  • 開発効率の向上: ボイラープレートコードの大幅削減
  • コード品質: 状態管理ロジックの統一
  • ユーザー体験: 一貫したローディング・エラー表示
  • 保守性: 共通UIの一元管理

まさに「いかにも公式が出していそう」なStateBuilderですが、実際にプロダクションで使ってみると、その効果は絶大です。ぜひ皆さんのプロジェクトでも試してみてください!


この記事が参考になった方は、ぜひいいねやシェアをお願いします!✨

Discussion