Flutterをある程度書けるようになると、アプリは作れるのに、なぜかコードがどんどんしんどくなってくる時期があります。
- 機能追加のたびに修正範囲が広がる
- 状態管理を入れたのに見通しが良くならない
- 同じような処理があちこちに増える
- 画面ごとの実装差分が大きすぎる
- バグ修正をしたら別の画面が壊れる
この段階で起きている問題は、Flutterが難しいというより、動くコードを優先した結果として、徐々にアンチパターンが積み上がっていることが多いです。
この記事では、Flutter中級者がやりがちなアンチパターンを、ありがちな実装例と改善例つきで整理します。
全部を一気に直す必要はありません。
ただ、1つずつ意識するだけでも、かなりコードのしんどさは変わってきます。
1. 画面が何でも屋になっている
まず一番多いのがこれです。
Widgetの中に、表示・API通信・エラーハンドリング・ナビゲーション・Snackbar表示まで全部書いてしまうパターンです。
よくあるコード
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
bool isLoading = false;
final emailController = TextEditingController();
final passwordController = TextEditingController();
Future<void> login() async {
setState(() {
isLoading = true;
});
try {
final result = await AuthApi().login(
emailController.text,
passwordController.text,
);
if (result.success) {
if (!mounted) return;
Navigator.pushReplacementNamed(context, '/home');
} else {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.message)),
);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('ログインに失敗しました')),
);
} finally {
setState(() {
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
TextField(controller: emailController),
TextField(controller: passwordController),
ElevatedButton(
onPressed: login,
child: isLoading
? const CircularProgressIndicator()
: const Text('ログイン'),
),
],
),
);
}
}
一見普通に見えますが、責務がかなり詰まっています。
- 画面表示
- 入力値管理
- 通信処理
- 成功失敗判定
- エラー表示
- 画面遷移
- ローディング制御
この形のまま機能が増えると、画面がどんどん太ります。
改善の方向
少なくとも次の3つは分けたほうが楽です。
- Widgetは表示とイベント受付
- ログイン処理はControllerやNotifier
- データ取得はRepositoryやAPI層
改善例
class LoginController extends ChangeNotifier {
LoginController(this._repository);
final AuthRepository _repository;
bool isLoading = false;
String? errorMessage;
bool loginSucceeded = false;
Future<void> login(String email, String password) async {
isLoading = true;
errorMessage = null;
loginSucceeded = false;
notifyListeners();
try {
await _repository.login(email, password);
loginSucceeded = true;
} catch (e) {
errorMessage = 'ログインに失敗しました';
} finally {
isLoading = false;
notifyListeners();
}
}
}
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
final controller = context.watch<LoginController>();
return Scaffold(
body: Column(
children: [
ElevatedButton(
onPressed: controller.isLoading
? null
: () async {
await controller.login('test@example.com', 'password');
if (!context.mounted) return;
if (controller.loginSucceeded) {
Navigator.pushReplacementNamed(context, '/home');
} else if (controller.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(controller.errorMessage!)),
);
}
},
child: controller.isLoading
? const CircularProgressIndicator()
: const Text('ログイン'),
),
],
),
);
}
}
完璧な分離でなくても、何でも画面に置かないだけでかなり改善します。
2. boolだらけで状態を表現している
中級者がかなりやりがちなのが、画面状態を bool の組み合わせで持つことです。
よくあるコード
bool isLoading = false;
bool hasError = false;
bool isEmpty = false;
List<Todo> todos = [];
一見シンプルですが、状態の矛盾が起きやすいです。
- isLoading が true なのに todos にデータが入っている
- hasError と isEmpty が両方 true
- 初期状態と空状態の区別が曖昧
改善の方向
状態そのものを型で表すと、かなり扱いやすくなります。
改善例
sealed class TodoState {
const TodoState();
}
class TodoInitial extends TodoState {
const TodoInitial();
}
class TodoLoading extends TodoState {
const TodoLoading();
}
class TodoSuccess extends TodoState {
const TodoSuccess(this.todos);
final List<Todo> todos;
}
class TodoEmpty extends TodoState {
const TodoEmpty();
}
class TodoError extends TodoState {
const TodoError(this.message);
final String message;
}
class TodoController extends ChangeNotifier {
TodoState state = const TodoInitial();
Future<void> fetchTodos() async {
state = const TodoLoading();
notifyListeners();
try {
final todos = await Future.value(<Todo>[]);
if (todos.isEmpty) {
state = const TodoEmpty();
} else {
state = TodoSuccess(todos);
}
} catch (e) {
state = const TodoError('取得に失敗しました');
}
notifyListeners();
}
}
状態が増えるほど、boolの寄せ集めより列挙的な表現のほうが強いです。
3. APIレスポンスをそのままUIに流している
これもかなり多いです。
APIのレスポンスモデルをそのままWidgetに渡して、UI側で変換してしまうパターンです。
よくあるコード
class UserResponse {
final String firstName;
final String lastName;
final String createdAt;
final bool isPremium;
UserResponse({
required this.firstName,
required this.lastName,
required this.createdAt,
required this.isPremium,
});
}
Text('${user.lastName} ${user.firstName}');
Text('登録日: ${user.createdAt.substring(0, 10)}');
Text(user.isPremium ? 'プレミアム会員' : '通常会員');
これを複数画面でやると、変換ロジックがあちこちに散ります。
改善の方向
UIで使うためのViewDataやUiModelを用意します。
改善例
class UserViewData {
final String displayName;
final String joinedDateText;
final String membershipLabel;
const UserViewData({
required this.displayName,
required this.joinedDateText,
required this.membershipLabel,
});
}
UserViewData toUserViewData(UserResponse response) {
return UserViewData(
displayName: '${response.lastName} ${response.firstName}',
joinedDateText: '登録日: ${response.createdAt.substring(0, 10)}',
membershipLabel: response.isPremium ? 'プレミアム会員' : '通常会員',
);
}
Text(userViewData.displayName);
Text(userViewData.joinedDateText);
Text(userViewData.membershipLabel);
これだけでWidgetの責務がかなり減ります。
4. onPressedの中が長すぎる
イベント処理をその場で書き続けると、Widgetの見通しが一気に悪くなります。
よくあるコード
ElevatedButton(
onPressed: () async {
if (titleController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('タイトルを入力してください')),
);
return;
}
setState(() {
isSaving = true;
});
try {
await TodoApi().createTodo(titleController.text);
if (!mounted) return;
Navigator.pop(context, true);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存に失敗しました')),
);
} finally {
setState(() {
isSaving = false;
});
}
},
child: const Text('保存'),
)
その場では早いですが、イベントが増えるほど破綻します。
改善の方向
イベントは呼び出しだけに寄せて、ロジック本体は外へ出します。
改善例
ElevatedButton(
onPressed: () => controller.save(titleController.text),
child: const Text('保存'),
)
class TodoFormController extends ChangeNotifier {
bool isSaving = false;
String? errorMessage;
bool saved = false;
Future<void> save(String title) async {
if (title.isEmpty) {
errorMessage = 'タイトルを入力してください';
notifyListeners();
return;
}
isSaving = true;
errorMessage = null;
saved = false;
notifyListeners();
try {
await Future.delayed(const Duration(milliseconds: 300));
saved = true;
} catch (e) {
errorMessage = '保存に失敗しました';
} finally {
isSaving = false;
notifyListeners();
}
}
}
Widget側はイベントの入口だけにすると、読みやすさがかなり変わります。
5. Repositoryを置かずに画面やNotifierから直接通信している
中級者のコードでよく見るのが、上位レイヤーがDioやFirebaseを直接触るパターンです。
よくあるコード
class UserNotifier extends ChangeNotifier {
Future<void> fetchUser() async {
final dio = Dio();
final response = await dio.get('/user');
final json = response.data;
user = User.fromJson(json);
notifyListeners();
}
User? user;
}
これだとNotifierが通信手段に強く依存します。
問題点
- 差し替えしにくい
- テストしにくい
- 通信仕様が上位に漏れる
- 他画面でも同じ取得ロジックが増える
改善例
abstract class UserRepository {
Future<User> fetchUser();
}
class UserRepositoryImpl implements UserRepository {
@override
Future<User> fetchUser() async {
final json = {
'id': 1,
'name': 'Taro',
};
return User.fromJson(json);
}
}
class UserNotifier extends ChangeNotifier {
UserNotifier(this._repository);
final UserRepository _repository;
User? user;
Future<void> fetchUser() async {
user = await _repository.fetchUser();
notifyListeners();
}
}
Repositoryの目的は、かっこいい設計にすることより依存の向きを整理することです。
6. 共通化を急ぎすぎる
中級者になると、同じ見た目が2回出た時点で共通Widgetを作りたくなります。
でも早すぎる共通化は、逆に読みにくさを生みます。
よくあるコード
class AppText extends StatelessWidget {
const AppText({
super.key,
required this.text,
this.size,
this.weight,
this.color,
this.align,
this.maxLines,
this.overflow,
this.height,
this.letterSpacing,
});
final String text;
final double? size;
final FontWeight? weight;
final Color? color;
final TextAlign? align;
final int? maxLines;
final TextOverflow? overflow;
final double? height;
final double? letterSpacing;
@override
Widget build(BuildContext context) {
return Text(
text,
maxLines: maxLines,
overflow: overflow,
textAlign: align,
style: TextStyle(
fontSize: size,
fontWeight: weight,
color: color,
height: height,
letterSpacing: letterSpacing,
),
);
}
}
汎用に見えますが、実際には標準のTextより意味が曖昧になることも多いです。
改善の方向
本当に再利用パターンが固まるまで、無理に共通化しないことです。
改善例
Text(
'プロフィール',
style: Theme.of(context).textTheme.titleLarge,
)
もしくは意味が明確な共通化に寄せます。
class SectionTitle extends StatelessWidget {
const SectionTitle(this.text, {super.key});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: Theme.of(context).textTheme.titleLarge,
);
}
}
何でも使える部品より、意味が明確な部品のほうが保守しやすいです。
7. エラー処理が全部同じになっている
通信失敗、入力不正、認証切れ、サーバーエラー。
これらを全部まとめて同じ文言で処理してしまうのもよくあるアンチパターンです。
よくあるコード
try {
await repository.fetchItems();
} catch (e) {
state = const ItemError('エラーが発生しました');
}
これではユーザー体験も改善しづらいですし、原因調査もしにくいです。
改善の方向
少なくともUI向けのエラー分類は持ったほうがいいです。
改善例
sealed class AppError {
const AppError();
}
class NetworkError extends AppError {
const NetworkError();
}
class UnauthorizedError extends AppError {
const UnauthorizedError();
}
class ValidationError extends AppError {
const ValidationError(this.message);
final String message;
}
class UnknownError extends AppError {
const UnknownError();
}
String errorMessage(AppError error) {
switch (error) {
case NetworkError():
return '通信環境を確認してください';
case UnauthorizedError():
return '再ログインが必要です';
case ValidationError(:final message):
return message;
case UnknownError():
return '予期しないエラーが発生しました';
}
}
エラーを分けるだけで、アプリの質がかなり上がります。
8. ローディング中でも連打できる
一見小さいですが、実務ではかなり事故りやすいポイントです。
保存ボタンや購入ボタンが連打できると、重複登録や二重送信の原因になります。
よくあるコード
ElevatedButton(
onPressed: () async {
await controller.submit();
},
child: const Text('送信'),
)
改善例
ElevatedButton(
onPressed: controller.isSubmitting
? null
: () async {
await controller.submit();
},
child: controller.isSubmitting
? const CircularProgressIndicator()
: const Text('送信'),
)
class SubmitController extends ChangeNotifier {
bool isSubmitting = false;
Future<void> submit() async {
if (isSubmitting) return;
isSubmitting = true;
notifyListeners();
try {
await Future.delayed(const Duration(seconds: 1));
} finally {
isSubmitting = false;
notifyListeners();
}
}
}
見た目以上に大事な対策です。
9. disposeやmountedを雑に扱っている
非同期処理のあとに、すでに閉じた画面へsetStateやNavigatorを呼ぶ問題も中級者が一度は通るポイントです。
よくあるコード
Future<void> fetchData() async {
final result = await repository.fetch();
setState(() {
data = result;
});
}
画面が閉じられたあとにこのsetStateが走ると例外につながります。
改善例
Future<void> fetchData() async {
final result = await repository.fetch();
if (!mounted) return;
setState(() {
data = result;
});
}
Controller側で完結させる場合でも、画面側で副作用を起こすなら mounted は常に意識したほうが安全です。
10. フォームのバリデーションが画面内に散らばっている
入力フォームが増えると、バリデーションがその場しのぎで散りやすいです。
よくあるコード
if (emailController.text.isEmpty) {
errorText = 'メールアドレスを入力してください';
} else if (!emailController.text.contains('@')) {
errorText = 'メールアドレスの形式が不正です';
}
似た処理がログイン画面、登録画面、プロフィール変更画面に増えていきます。
改善の方向
入力チェックは関数やValue Object的な形でまとめたほうが再利用しやすいです。
改善例
String? validateEmail(String value) {
if (value.isEmpty) {
return 'メールアドレスを入力してください';
}
if (!value.contains('@')) {
return 'メールアドレスの形式が不正です';
}
return null;
}
TextFormField(
validator: (value) => validateEmail(value ?? ''),
)
ロジックを外へ出すだけでも重複がかなり減ります。
11. フォルダ構成が思想だけ立派で実態に合っていない
Clean Architectureっぽい名前だけ先に導入して、実際にはどこに何を書くべきか分からなくなっているケースもあります。
よくある状態
lib/
core/
domain/
application/
infrastructure/
presentation/
見た目は立派ですが、小規模アプリだと逆に追いづらいこともあります。
中級者のうちは、正しい思想より、影響範囲が追いやすいことを優先したほうがいいです。
改善例
lib/
features/
auth/
data/
presentation/
todo/
data/
presentation/
profile/
data/
presentation/
機能単位でまとまっているだけでも、かなり分かりやすくなります。
12. 状態管理ライブラリを入れれば整理されると思っている
これはかなり本質的なアンチパターンです。
RiverpodでもBlocでもProviderでも、ライブラリを入れただけでは整理されません。
よくあるコード
class TodoNotifier extends StateNotifier<TodoState> {
TodoNotifier() : super(const TodoInitial());
final titleController = TextEditingController();
final scrollController = ScrollController();
final formKey = GlobalKey<FormState>();
Future<void> createTodo() async {
// API通信
}
void navigateToDetail(BuildContext context, int id) {
Navigator.pushNamed(context, '/detail/$id');
}
String formatDate(DateTime dateTime) {
return '${dateTime.year}/${dateTime.month}/${dateTime.day}';
}
}
これでは状態管理クラスが何でも屋になっているだけです。
改善の方向
状態管理クラスは、状態とユースケースの橋渡しに寄せることです。
- TextEditingControllerは画面側
- ナビゲーションは画面側か専用責務
- 日付整形はformatterやViewData側
- 通信とビジネスロジックは外部責務
状態管理ライブラリは整理を助ける道具であって、責務分離の代わりではありません。
13. 先回りしすぎて抽象化が増えすぎる
将来の拡張を考えて、まだ1パターンしかないのに抽象化を増やすのも中級者がハマりやすい罠です。
よくあるコード
abstract class BaseCardFactory {
Widget createTitle();
Widget createDescription();
Widget createFooter();
}
現時点で実装が1つしかないなら、説明コストのほうが高くなりがちです。
改善の方向
3回以上同じ形が出てから共通化を考えるくらいでちょうどいいです。
FlutterはUI中心なので、抽象化しすぎると逆に読みづらくなります。
14. とりあえずextensionを増やしすぎる
extensionは便利ですが、乱用すると探索コストが上がります。
よくあるコード
extension StringX on String {
String toDisplayDate() {
return substring(0, 10).replaceAll('-', '/');
}
String toPlanLabel() {
return this == 'premium' ? 'プレミアム' : '通常';
}
}
文字列に何でも生やしていくと、責務が曖昧になります。
改善の方向
- その型に自然な振る舞いか
- どこから見ても意味が分かるか
- ViewDataやFormatterで持つほうが自然ではないか
この視点で増やしすぎを防ぐと、コードの見通しが保ちやすいです。
15. 成功パターンしか考えずに実装している
中級者のコードが本番で崩れやすい大きな理由の1つです。
成功時は動くけど、失敗時や空データ時の扱いが薄いパターンです。
よくあるコード
Future<void> fetchItems() async {
final items = await repository.fetchItems();
state = ItemSuccess(items);
}
これだけだと、次のケースが抜けます。
- 通信失敗
- 空データ
- 認証切れ
- タイムアウト
- リトライ導線
- 二重実行防止
改善例
Future<void> fetchItems() async {
state = const ItemLoading();
notifyListeners();
try {
final items = await repository.fetchItems();
if (items.isEmpty) {
state = const ItemEmpty();
} else {
state = ItemSuccess(items);
}
} on UnauthorizedException {
state = const ItemError('再ログインが必要です');
} on NetworkException {
state = const ItemError('通信環境を確認してください');
} catch (_) {
state = const ItemError('取得に失敗しました');
}
notifyListeners();
}
成功以外の状態も最初から設計に入れると、あとで崩れにくくなります。
じゃあ何から直せばいいのか
ここまでアンチパターンを並べましたが、全部を一気に直す必要はありません。
まずは次の順番で直すのがおすすめです。
最優先で見直したいもの
- 画面から通信処理を減らす
- boolだらけの状態を整理する
- onPressedの長い処理を外へ出す
- エラー処理を分ける
次に見直したいもの
- APIモデルとUIモデルを分ける
- 共通化のやりすぎを減らす
- ローディング中の二重実行を防ぐ
- フォルダ構成を機能単位で整理する
この順番なら、学習コストに対して改善効果が大きいです。
まとめ
Flutter中級者がやりがちなアンチパターンは、特殊なミスというより、動くものを早く作ろうとした結果として自然に起きるものが多いです。
でも、次の観点を持つだけでコードはかなり変わります。
- 画面を何でも屋にしない
- 状態をboolで増やしすぎない
- APIレスポンスをそのままUIに流さない
- イベント処理をWidgetに詰め込みすぎない
- 状態管理ライブラリに整理そのものを期待しすぎない
- 成功時以外の流れも最初から考える
Flutter中級者の次の成長は、もっと難しい文法を覚えることではなく、壊れにくく増やしやすい形に寄せることだと思います。
アプリは作れる。
その次は、作り続けられるコードにしていく段階です。
もし最近、Flutterを書いていてしんどさが増えてきたなら、今回のアンチパターンのどれかが入り込んでいないか見直してみるとかなり変わるはずです。
さいごに
Flutterを基礎から中級者がつまずきやすいポイントまで体系的に学びたい方向けに、Udemyで講座も出しています。
以下のリンクは、30日間使える半額クーポン付きです。