🫤
[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