
Flutter Best Practices
- 3 installs
- 1 repo stars
- Updated February 21, 2026
- ofershap/flutter-best-practices
Helps with ai & agent building tasks.
About
flutter-best-practices is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- flutter-best-practices
- AI & Agent Building
- AI-coding skill
Flutter Best Practices by the numbers
- 3 all-time installs (skills.sh)
- Ranked #13,657 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ofershap/flutter-best-practices --skill flutter-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 1 |
| Last updated | February 21, 2026 |
| Repository | ofershap/flutter-best-practices ↗ |
What it does
Helps with ai & agent building tasks.
Files
When to use
Use this skill when working with Flutter or Dart code. It prevents common mistakes where AI agents generate deprecated Navigator 1.0 patterns, overuse StatefulWidget, ignore Dart 3 features like sealed classes and records, and produce code that breaks on web or desktop platforms.
Critical Rules
1. Use Dart 3 sealed classes and exhaustive switch instead of if/else chains
Wrong:
Widget buildState(AppState state) {
if (state is Loading) {
return CircularProgressIndicator();
} else if (state is Success) {
return Text(state.data);
} else if (state is Error) {
return Text(state.message);
} else {
return SizedBox.shrink();
}
}Correct:
sealed class AppState {}
class Loading extends AppState {}
class Success extends AppState {
final String data;
Success(this.data);
}
class Failure extends AppState {
final String message;
Failure(this.message);
}
Widget buildState(AppState state) => switch (state) {
Loading() => const CircularProgressIndicator(),
Success(:final data) => Text(data),
Failure(:final message) => Text(message),
};Why: Sealed classes give compile-time exhaustiveness checking — the compiler tells you if you miss a case. No need for a default branch.
2. Use GoRouter for navigation, not Navigator 1.0 push/pop
Wrong:
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const DetailPage()),
);Correct:
// In MaterialApp.router setup
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => const HomePage()),
GoRoute(path: '/detail/:id', builder: (context, state) {
final id = state.pathParameters['id']!;
return DetailPage(id: id);
}),
],
);
// Navigation
context.go('/detail/42');
context.push('/detail/42');Why: GoRouter supports deep linking, web URLs, type-safe routing, and declarative configuration. Navigator 1.0 push/pop doesn't handle web, doesn't support URL-based routing, and creates brittle navigation stacks.
3. Use Riverpod for shared state, not setState everywhere
Wrong:
class CounterPage extends StatefulWidget {
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _count = 0;
List<Item> _items = [];
bool _isLoading = false;
Future<void> _loadItems() async {
setState(() => _isLoading = true);
_items = await api.fetchItems();
setState(() => _isLoading = false);
}
}Correct:
@riverpod
class ItemsNotifier extends _$ItemsNotifier {
@override
FutureOr<List<Item>> build() => api.fetchItems();
Future<void> refresh() async {
state = const AsyncLoading();
state = AsyncData(await api.fetchItems());
}
}
class ItemsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(itemsNotifierProvider);
return items.when(
data: (data) => ListView.builder(...),
loading: () => const CircularProgressIndicator(),
error: (e, st) => Text('Error: $e'),
);
}
}Why: setState doesn't scale beyond local widget state. It can't share state across widgets, has no built-in async handling, and causes full subtree rebuilds.
4. Use Dart 3 records for multiple return values
Wrong:
Map<String, dynamic> getUserInfo() {
return {'name': 'Alice', 'age': 30};
}
final info = getUserInfo();
final name = info['name'] as String;Correct:
(String name, int age) getUserInfo() {
return ('Alice', 30);
}
final (name, age) = getUserInfo();Why: Records are type-safe, lightweight, and support destructuring. Map<String, dynamic> loses type safety and requires casting.
5. Use const constructors everywhere possible
Wrong:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Home')),
body: Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Hello'),
),
),
);
}Correct:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Hello'),
),
),
);
}Why: const widgets are canonicalized — Flutter reuses the same instance and skips rebuilding them. This reduces memory and improves performance.
6. Use extension types for type-safe ID wrappers
Wrong:
Future<User> getUser(String userId) async { ... }
Future<Order> getOrder(String orderId) async { ... }
// Easy to mix up:
final user = await getUser(orderId); // compiles, but wrongCorrect:
extension type UserId(String value) implements String {}
extension type OrderId(String value) implements String {}
Future<User> getUser(UserId userId) async { ... }
Future<Order> getOrder(OrderId orderId) async { ... }
// Won't compile:
final user = await getUser(orderId); // type errorWhy: Zero-cost abstraction at runtime that prevents mixing different ID types at compile time.
7. Prefer StatelessWidget with ConsumerWidget over StatefulWidget
Wrong:
class ProfilePage extends StatefulWidget {
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => TextField(controller: _controller);
}Correct (with hooks):
class ProfilePage extends HookWidget {
@override
Widget build(BuildContext context) {
final controller = useTextEditingController();
return TextField(controller: controller);
}
}Why: Hooks and ConsumerWidget reduce boilerplate. initState/dispose lifecycle is error-prone (forgetting to dispose, order-dependent initialization).
8. Use Theme.of(context) instead of hardcoded values
Wrong:
Text(
'Hello',
style: TextStyle(fontSize: 24, color: Colors.blue, fontWeight: FontWeight.bold),
)Correct:
Text(
'Hello',
style: Theme.of(context).textTheme.headlineMedium,
)Why: Theme-based styling supports dark mode, dynamic color, accessibility scaling, and consistent design tokens across the app.
9. Handle platform differences with kIsWeb and conditional imports
Wrong:
import 'dart:io';
void saveFile() {
final file = File('/tmp/data.txt'); // crashes on web
file.writeAsStringSync('hello');
}Correct:
import 'package:flutter/foundation.dart' show kIsWeb;
void saveFile() {
if (kIsWeb) {
// Use web-specific storage (localStorage, IndexedDB)
return;
}
final file = File('/tmp/data.txt');
file.writeAsStringSync('hello');
}Why: Flutter is cross-platform. dart:io doesn't exist on web. Unconditional use crashes the app.
10. Use slivers for complex scrollable layouts
Wrong:
Column(
children: [
Container(height: 200, child: header),
ListView.builder(
shrinkWrap: true, // kills performance
physics: NeverScrollableScrollPhysics(), // broken scrolling
itemCount: items.length,
itemBuilder: (context, i) => ItemTile(items[i]),
),
],
)Correct:
CustomScrollView(
slivers: [
SliverToBoxAdapter(child: header),
SliverList.builder(
itemCount: items.length,
itemBuilder: (context, i) => ItemTile(items[i]),
),
],
)Why: shrinkWrap forces the list to compute ALL item sizes upfront, destroying lazy loading. Slivers are composable, lazy, and handle nested scrolling correctly.
Patterns
Riverpod async data pattern
@riverpod
Future<List<Product>> products(ref) async {
final repo = ref.watch(productRepoProvider);
return repo.fetchAll();
}
class ProductList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final products = ref.watch(productsProvider);
return products.when(
data: (list) => ListView.builder(
itemCount: list.length,
itemBuilder: (_, i) => ProductTile(list[i]),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Failed: $e')),
);
}
}GoRouter with type-safe route data
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
@override
Widget build(BuildContext context, GoRouterState state) => const HomePage();
}
@TypedGoRoute<DetailRoute>(path: '/detail/:id')
class DetailRoute extends GoRouteData {
final String id;
const DetailRoute({required this.id});
@override
Widget build(BuildContext context, GoRouterState state) => DetailPage(id: id);
}Freezed for immutable data classes
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
@Default(false) bool isActive,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}Anti-Patterns
- Do not use `dynamic` type — use proper types,
Object?, or generics. dynamic disables all
type checking.
- Do not use Navigator.of(context).push — use GoRouter. Navigator 1.0 doesn't support deep
linking or web URLs.
- Do not nest scrollable widgets — a ListView inside a Column or another ListView causes
unbounded height errors or broken scrolling with shrinkWrap.
- Do not use setState for shared or app-level state — it doesn't compose across widgets. Use
Riverpod.
- Do not use BuildContext after an async gap without checking mounted — after any await, check
if (!context.mounted) return before using context for navigation, dialogs, or theme lookups.