| name | dart-flutter-patterns |
| description | Dart and Flutter patterns for widgets, state management (BLoC, Riverpod, Provider), GoRouter, Dio, and Freezed. Use when building Flutter apps, structuring Dart code, or choosing state management approaches. |
| origin | MCC |
Dart/Flutter Patterns
When to Use
- Starting a new Flutter feature and need idiomatic patterns for state management, navigation, or data access
- Reviewing or writing Dart code and need guidance on null safety, sealed types, or async composition
- Setting up a new Flutter project and choosing between BLoC, Riverpod, or Provider
- Implementing secure HTTP clients, WebView integration, or local storage
- Writing tests for Flutter widgets, Cubits, or Riverpod providers
- Wiring up GoRouter with authentication guards
How It Works
This skill provides copy-paste-ready Dart/Flutter code patterns organized by concern:
- Null safety — avoid
!, prefer ?./??/pattern matching
- Immutable state — sealed classes,
freezed, copyWith
- Async composition — concurrent
Future.wait, safe BuildContext after await
- Widget architecture — extract to classes (not methods),
const propagation, scoped rebuilds
- State management — BLoC/Cubit events, Riverpod notifiers and derived providers
- Navigation — GoRouter with reactive auth guards via
refreshListenable
- Networking — Dio with interceptors, token refresh with one-time retry guard
- Error handling — global capture,
ErrorWidget.builder, crashlytics wiring
- Testing — unit (BLoC test), widget (ProviderScope overrides), fakes over mocks
1. Null Safety Fundamentals
Prefer Patterns Over Bang Operator
// BAD — crashes at runtime if null
final name = user!.name;
// GOOD — provide fallback
final name = user?.name ?? 'Unknown';
// GOOD — Dart 3 pattern matching
final display = switch (user) {
User(:final name, :final email) => '$name <$email>',
null => 'Guest',
};
// GOOD — guard early return
String getUserName(User? user) {
if (user == null) return 'Unknown';
return user.name; // promoted to non-null
}
Avoid late Overuse
Use late only when initialization is guaranteed before first access (e.g., initState()). Prefer nullable with explicit initialization otherwise.
2. Immutable State
Sealed Classes for State Hierarchies
sealed class UserState {}
final class UserInitial extends UserState {}
final class UserLoading extends UserState {}
final class UserLoaded extends UserState {
const UserLoaded(this.user);
final User user;
}
final class UserError extends UserState {
const UserError(this.message);
final String message;
}
// Exhaustive switch — compiler enforces all branches
Widget buildFrom(UserState state) => switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const CircularProgressIndicator(),
UserLoaded(:final user) => UserCard(user: user),
UserError(:final message) => ErrorText(message),
};
Freezed for Boilerplate-Free Immutability
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
@Default(false) bool isAdmin,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
final updated = user.copyWith(name: 'Alice Smith'); // immutable update
3. Async Composition
Structured Concurrency
final (userList, orderList) = await (
users.getAll(),
orders.getRecent(),
).wait; // Dart 3 record destructuring + Future.wait
BuildContext After Await
// CRITICAL — always check mounted after any await in StatefulWidget
Future<void> _handleSubmit() async {
setState(() => _isLoading = true);
try {
await authService.login(_email, _password);
if (!mounted) return; // guard before using context
context.go('/home');
} on AuthException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
4. Widget Architecture
Extract to Classes, Not Methods
// BAD — private method, prevents const optimization
Widget _buildHeader() => Container(...);
// GOOD — separate widget class, enables const
class _PageHeader extends StatelessWidget {
const _PageHeader(this.title);
final String title;
@override
Widget build(BuildContext context) => Container(...);
}
const Propagation and Scoped Rebuilds
Use const constructors to stop rebuild propagation. Isolate reactive parts (e.g., ref.watch) into small dedicated widgets so expensive siblings are not rebuilt.
5. State Management: BLoC/Cubit
class AuthCubit extends Cubit<AuthState> {
AuthCubit(this._authService) : super(const AuthState.initial());
final AuthService _authService;
Future<void> login(String email, String password) async {
emit(const AuthState.loading());
try {
final user = await _authService.login(email, password);
emit(AuthState.authenticated(user));
} on AuthException catch (e) {
emit(AuthState.error(e.message));
}
}
}
6. Error Handling Architecture
void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
crashlytics.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
crashlytics.recordError(error, stack, fatal: true);
return true;
};
runApp(const App());
}
7. Testing Quick Reference
// Unit test
test('GetUserUseCase returns null for missing user', () async {
final repo = FakeUserRepository();
final useCase = GetUserUseCase(repo);
expect(await useCase('missing-id'), isNull);
});
// BLoC test
blocTest<AuthCubit, AuthState>(
'emits loading then error on failed login',
build: () => AuthCubit(FakeAuthService(throwsOn: 'login')),
act: (cubit) => cubit.login('user@test.com', 'wrong'),
expect: () => [const AuthState.loading(), isA<AuthError>()],
);
// Widget test
testWidgets('CartBadge shows item count', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [cartNotifierProvider.overrideWith(() => FakeCartNotifier(count: 3))],
child: const MaterialApp(home: CartBadge()),
),
);
expect(find.text('3'), findsOneWidget);
});
Prefer fakes over mocks for cleaner test code.
Reference Files
References