Dart and Flutter Patterns
Original work by ECC. Credit goes to ECC.
When to Use
Use this skill when you:
- Start a Flutter feature.
- Write or review Dart code.
- Pick BLoC, Riverpod, or Provider.
- Add routes with GoRouter.
- Call an API with Dio.
- Store data on the device.
- Add a WebView.
- Use Freezed to make state classes.
- Write unit or widget tests.
- Split a large app into clear layers.
Core Rules
- Keep null safety on.
- Avoid
!. Check for null first. - Keep state fixed. Make new state instead of changing old state.
- Use
constwhen all values are known. - Split large widgets into small widget classes.
- Do not use helper methods to build large widget trees.
- Check
context.mountedafterawait. - Show loading, success, empty, and error states.
- Cancel work when a screen or provider is closed.
- Do not put secret keys in app code.
- Do not log tokens, passwords, or private user data.
- Test each state and each error path.
Pick One State Tool
Use one main state tool in each feature.
- Use
setStatefor small, local screen state. - Use Provider for simple shared state.
- Use Riverpod for safe shared state and easy test setup.
- Use Cubit when state changes are simple.
- Use BLoC when each user action should be a named event.
Do not mix these tools without a clear need.
Null Safety
Prefer checks, ?., ??, and pattern matching.
String displayName(User? user) {
if (user case User(name: final name) when name.isNotEmpty) {
return name;
}
return 'Guest';
}Use ! only when the value is proven to exist and the proof is close to that line.
Fixed State
Use sealed classes or Freezed. List all valid states.
sealed class LoadState<T> {
const LoadState();
}
final class Loading<T> extends LoadState<T> {
const Loading();
}
final class Success<T> extends LoadState<T> {
const Success(this.data);
final T data;
}
final class Empty<T> extends LoadState<T> {
const Empty();
}
final class Failure<T> extends LoadState<T> {
const Failure(this.error, [this.stackTrace]);
final Object error;
final StackTrace? stackTrace;
}Do not change a list inside old state. Make a new list.
final nextItems = [...state.items, newItem];
emit(state.copyWith(items: nextItems));Async Work
Run work at the same time only when the tasks do not depend on each other.
final results = await Future.wait([
loadUser(),
loadPosts(),
]);Run tasks in order when one task needs the result of another.
Check that a widget still exists after await.
Future<void> save(BuildContext context) async {
await repository.save();
if (!context.mounted) return;
Navigator.of(context).pop();
}Catch errors at the right layer. Keep the first error and stack trace.
try {
final data = await repository.load();
emit(Success(data));
} catch (error, stackTrace) {
emit(Failure(error, stackTrace));
}Avoid starting the same request more than once. Disable the button or track the request state.
Widget Design
Make small widget classes. Pass only the data and actions they need.
class UserName extends StatelessWidget {
const UserName({
required this.name,
super.key,
});
final String name;
@override
Widget build(BuildContext context) {
return Text(name);
}
}Keep rebuilds small. Read state near the widget that needs it. Add keys to list rows when items can move.
Close controllers, streams, and timers in dispose.
Routes and Sign-In
Make route guards react when sign-in state changes. Avoid a loop between the login page and a locked page.
final router = GoRouter(
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final signedIn = authCubit.state is AuthSignedIn;
final onLogin = state.matchedLocation == '/login';
if (!signedIn && !onLogin) {
final from = Uri.encodeComponent(state.uri.toString());
return '/login?from=$from';
}
if (signedIn && onLogin) {
final from = state.uri.queryParameters['from'];
return from == null ? '/' : Uri.decodeComponent(from);
}
return null;
},
routes: [
// Add routes here.
],
);Validate route values before use. Show a safe page when a route is missing or bad.
Dio and Token Refresh
Set time limits. Map network errors to app errors. Retry a request at most once after a token refresh.
Use one shared refresh job. This stops many failed calls from starting many refresh calls.
Do not retry these cases:
- The request was canceled.
- The user signed out.
- The refresh call failed.
- The request was already retried.
- The server says the request is not safe to repeat.
Do not log request headers or bodies that may hold private data.
Local Data and WebViews
For local data:
- Store tokens in secure storage.
- Use plain storage only for safe settings.
- Handle old data after an app update.
- Clear private data at sign-out.
For WebViews:
- Allow only trusted links.
- Block unknown URL plans.
- Check all messages from page code.
- Do not turn on file access unless it is needed.
- Send outside links to a safe browser when needed.
Error Handling
Show a kind message to the user. Keep full details for local debug work.
Add top-level error hooks for Flutter and async errors. Do not hide errors with an empty catch.
Use ErrorWidget.builder only as a last safe screen. It does not replace local error states.
Crash report tools must be set up by the app owner. Do not add them, send data, or make outside calls unless the user asks.
Testing
Test:
- Loading, success, empty, and failure states.
- Null and bad input.
- A widget removed during
await. - Route guard changes.
- Token refresh success and failure.
- The one-retry limit.
- Request cancel steps.
- Data upgrade steps.
Use fakes when a small fake is easy to read. Use mocks only when call checks matter.
For Riverpod widget tests, replace providers in ProviderScope.
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepositoryProvider.overrideWithValue(FakeUserRepository()),
],
child: const MaterialApp(home: ProfilePage()),
),
);Full Usage Example
Task: Load a profile with Riverpod and show every state.
final profileProvider =
AsyncNotifierProvider<ProfileNotifier, User>(ProfileNotifier.new);
class ProfileNotifier extends AsyncNotifier<User> {
@override
Future<User> build() {
return ref.read(userRepositoryProvider).loadProfile();
}
Future<void> reload() async {
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref.read(userRepositoryProvider).loadProfile(),
);
}
}
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(profileProvider);
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: profile.when(
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stackTrace) => Center(
child: FilledButton(
onPressed: () {
ref.read(profileProvider.notifier).reload();
},
child: const Text('Try again'),
),
),
data: (user) => UserName(name: user.name),
),
);
}
}This example keeps state fixed, limits rebuilds, shows errors, and lets the user try again.
Final Check
Before you finish:
- Run
dart format. - Run
flutter analyze. - Run the tests.
- Check phone and tablet sizes.
- Check light and dark themes.
- Check slow network and no network.
- Check screen reader labels.
- Check that no secret or private data is logged.