# Flutter Best Practices

> Modern Flutter and Dart 3 patterns — stop agents from generating deprecated Navigator 1.0, StatefulWidget-heavy, and pre-Dart 3 code

- Skill: `ofershap/flutter-best-practices` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ofershap/flutter-best-practices`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ofershap/flutter-best-practices/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ofershap (https://skillmd.com/u/ofershap)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ofershap/flutter-best-practices

---


## 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:**

```dart
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:**

```dart
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:**

```dart
Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => const DetailPage()),
);
```

**Correct:**

```dart
// 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:**

```dart
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:**

```dart
@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:**

```dart
Map<String, dynamic> getUserInfo() {
  return {'name': 'Alice', 'age': 30};
}

final info = getUserInfo();
final name = info['name'] as String;
```

**Correct:**

```dart
(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:**

```dart
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: Text('Home')),
    body: Center(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Text('Hello'),
      ),
    ),
  );
}
```

**Correct:**

```dart
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:**

```dart
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:**

```dart
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:**

```dart
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):**

```dart
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:**

```dart
Text(
  'Hello',
  style: TextStyle(fontSize: 24, color: Colors.blue, fontWeight: FontWeight.bold),
)
```

**Correct:**

```dart
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:**

```dart
import 'dart:io';

void saveFile() {
  final file = File('/tmp/data.txt'); // crashes on web
  file.writeAsStringSync('hello');
}
```

**Correct:**

```dart
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:**

```dart
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:**

```dart
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

```dart
@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

```dart
@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

```dart
@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.

