Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
ofershap avatar

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-practices

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs3
repo stars1
Last updatedFebruary 21, 2026
Repositoryofershap/flutter-best-practices

What it does

Helps with ai & agent building tasks.

Files

SKILL.mdMarkdownGitHub ↗

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 wrong

Correct:

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 error

Why: 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.

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.