Skip to main content

2 posts tagged with "Riverpod"

View All Tags

Beyond build_runner: Implementing Dart Macros and Metaprogramming in Large-Scale Apps

Published: · 12 min read
Don Peter
Cofounder and CTO, Appxiom

Modern Flutter apps lean heavily on code generation for DTOs, dependency injection, providers, and mapping layers. In large repos, build_runner becomes a tax: slow rebuilds, watch-mode flakiness, generated-file churn in PRs, cache invalidation, and complex CI. Dart macros metaprogramming is the emerging Flutter build_runner alternative that moves generation into the compiler and analyzer - no sidecar build processes, no .g.dart files.

This post shows how to evaluate, adopt, and scale Dart macros in production Flutter codebases. We’ll cover when macros are the right fit, how they impact Dart code generation, Flutter serialization performance, and static analysis, and how to migrate incrementally from build_runner with minimal risk.

Note on versions and stability:

  • Dart macros are under active development. As of Dart 3.x, macros are available as a preview feature in the SDK and tooling. Expect APIs and flags to evolve; always check the official “Static Metaprogramming” docs for your specific Dart/Flutter channel.
  • The guidance below is designed for incremental adoption: you can keep shipping with build_runner while enabling macros for select features in parallel.

Why macros, and why now?

  • Compiler-integrated generation. Macros expand during compilation/analyzer phases, not via a separate process. This removes a whole class of “watch” and caching issues.
  • Fewer files, fewer conflicts. No checked-in .g.dart files (unless you choose to), which eliminates codegen churn in PRs and monorepo conflicts.
  • Faster edit-refresh in large apps. Incremental rebuilds happen inside the frontend server/analyzer without shelling out to build_runner.
  • Better Dart static analysis. Macros can emit structured diagnostics (errors, warnings, hints) at compile-time, enabling powerful design-guardrails beyond lints.

Typical high-impact areas in large Flutter apps:

  • Provider scaffolding (e.g., Riverpod)
  • Serialization and mapping
  • Boilerplate for equality, copy, union/sealed classes
  • Dependency registration and module wiring
  • Design-rule enforcement (diagnostics)

Prerequisites

  • Flutter 3.22+ and Dart 3.4+ recommended.
  • IDE with up-to-date Dart/Flutter plugins.
  • For macro authoring or using preview packages, you may need to opt into the macros experiment in your toolchain (consult the SDK release notes for flags on your channel).

Tip: Keep your existing build_runner workflows intact while piloting macros in a small, isolated module to validate tooling and CI.

How Dart macros metaprogramming works (in practice)

  • You add annotations like @Foo() to your types, methods, or fields.
  • At compile time, the Dart frontend/analyzer invokes macro code (in a separate package) to:
    • Augment your declarations (e.g., inject toJson, fromJson, providers, etc.).
    • Optionally emit diagnostics (e.g., “non-final field in an @immutable class”).
  • Generated augmentations don’t need to live as source files. They’re fed directly into the compiler/analyzer pipeline, which improves incremental build performance and reduces repo noise.

This is a conceptual replacement for many source_gen builders run by build_runner.

Where macros fit vs build_runner

Use macros when:

  • You want less infrastructure and fewer generated files.
  • You need compile-time guarantees or diagnostics that feel like “first-class” analysis errors.
  • You want faster incremental builds in very large projects.

Keep build_runner when:

  • You depend on packages that haven’t adopted macros yet (e.g., json_serializable today in many apps).
  • You need stable, fully documented generation paths across all channels, including CI that can’t enable experiments yet.

Most large teams will run both in parallel for some time.

A production-ready path: Riverpod macro adoption (no build_runner)

Many teams start with provider generation because it delivers immediate DX wins with minimal risk. Riverpod has been an early adopter of macros; depending on your Riverpod version, you can often use annotations without running build_runner in dev.

Example: fetching paginated articles with Dio + Riverpod macros.

Dependencies:

# pubspec.yaml
environment:
sdk: ">=3.3.0 <4.0.0"

dependencies:
flutter:
sdk: flutter
dio: ^5.5.0
riverpod: ^2.5.0
# Annotations for macros-based APIs (version may vary by channel)
riverpod_annotation: ^2.3.0

dev_dependencies:
flutter_test:
sdk: flutter
# Keep build_runner only if you still have generator-based features elsewhere.
build_runner: ^2.4.9

A DTO you can keep hand-written for now (or still use json_serializable while you pilot macros elsewhere):

// lib/features/articles/data/article.dart
class Article {
final String id;
final String title;
final String body;

const Article({
required this.id,
required this.title,
required this.body,
});

factory Article.fromJson(Map<String, Object?> json) => Article(
id: json['id'] as String,
title: json['title'] as String,
body: json['body'] as String,
);

Map<String, Object?> toJson() => {
'id': id,
'title': title,
'body': body,
};
}

Repository:

// lib/features/articles/data/article_repository.dart
import 'package:dio/dio.dart';
import 'article.dart';

class ArticleRepository {
final Dio _client;
const ArticleRepository(this._client);

Future<List<Article>> fetchPage({required int page, int pageSize = 20}) async {
final res = await _client.get<Map<String, Object?>>(
'/articles',
queryParameters: {'page': page, 'pageSize': pageSize},
);
final data = res.data?['data'] as List<dynamic>? ?? const [];
return data
.cast<Map<String, Object?>>()
.map(Article.fromJson)
.toList(growable: false);
}
}

Provider with Riverpod macros:

// lib/features/articles/providers.dart
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/article.dart';
import '../data/article_repository.dart';

part 'providers.g.dart';

// Dio singleton
@Riverpod(keepAlive: true)
Dio dio(DioRef ref) => Dio(BaseOptions(baseUrl: 'https://api.example.com'));

// Repository
@Riverpod(keepAlive: true)
ArticleRepository articleRepository(ArticleRepositoryRef ref) =>
ArticleRepository(ref.watch(dioProvider));

// Paginated list
@riverpod
Future<List<Article>> articles(ArticlesRef ref, {int page = 1}) async {
final repo = ref.watch(articleRepositoryProvider);
return repo.fetchPage(page: page);
}

Notes

  • With macros enabled in your toolchain, Riverpod will synthesize providers without build_runner.
  • Many teams keep build_runner in the repo for other features while gradually flipping provider code to macros. That’s safe and incremental.

UI:

// lib/features/articles/articles_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers.dart';

class ArticlesPage extends ConsumerWidget {
const ArticlesPage({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(articlesProvider(page: 1));

return Scaffold(
appBar: AppBar(title: const Text('Articles')),
body: state.when(
data: (items) => ListView.builder(
itemCount: items.length,
itemBuilder: (c, i) => ListTile(
title: Text(items[i].title),
subtitle: Text(items[i].body, maxLines: 2, overflow: TextOverflow.ellipsis),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('Error: $e')),
),
);
}
}

This is a concrete example of using a macros-backed feature today, in a way you can deploy and maintain.

Authoring a simple macro for compile-time checks (Dart static analysis)

Beyond generation, macros shine at guardrails. Here’s a small, practical macro that enforces DTO naming in large repos - useful when you’re standardizing layers.

Create a separate Dart package for your macro, e.g., naming_conventions_macro.

pubspec.yaml:

name: naming_conventions_macro
environment:
sdk: ">=3.3.0 <4.0.0"

dependencies:
macros: ^0.1.0

Macro implementation (API is subject to change between SDK releases):

// lib/naming_conventions.dart
import 'package:macros/macros.dart';

/// Annotate data-transfer objects to enforce a naming scheme in large repos.
class Dto implements ClassTypesMacro {
const Dto();

@override
Future<void> buildTypesForClass(
ClassDeclaration clazz,
TypeBuilder builder,
) async {
final name = clazz.identifier.name;
if (!name.endsWith('Dto')) {
// Emit a compile-time diagnostic (appears like analyzer error/hint)
builder.report(
Diagnostic(
DiagnosticMessage(
'Classes annotated with @Dto must end with "Dto".',
target: clazz.identifier,
),
Severity.error,
),
);
}
}
}

Use it in your app:

# app/pubspec.yaml
dependencies:
naming_conventions_macro:
path: ../naming_conventions_macro
// lib/shared/models/user_model.dart
import 'package:naming_conventions_macro/naming_conventions.dart';

@Dto() // This will fail the build if the class name doesn’t end with Dto
class User { // <- change to UserDto to satisfy the macro
final String id;
const User(this.id);
}

What this demonstrates:

  • “Dart static analysis” with macros lets you enforce architectural rules at compile time without maintaining a custom analyzer plugin.
  • In large-scale apps, these checks stop drift (e.g., domain vs DTO vs entity suffixes), improving readability and refactor safety.

Enabling in your toolchain:

  • Depending on your Dart/Flutter channel, you may need to enable the macros experiment to see diagnostics during dart analyze, flutter test, or IDE analysis. Consult the release notes for your SDK version.

What about Dart code generation for JSON? Serialization performance

Serialization is the heaviest codegen footprint in most apps. Today, json_serializable + build_runner is production-proven and fast at runtime, but can be slow to rebuild and noisy in repos. Macros promise equivalent runtime performance with a better dev story.

Options you can adopt now:

  • Keep json_serializable for DTOs while you move providers/DI to macros.
  • For hand-written mappers (low-volume DTOs), write simple fromJson/toJson as shown above.
  • Track packages adding macros support (e.g., Riverpod) and migrate those first.

Runtime performance considerations:

  • Whether generated via build_runner or macros, serializer speed is dominated by the generated Dart. A good macro-based serializer that directly reads maps and casts (as T) will match json_serializable and blow reflection-based approaches out of the water.
  • For hot paths, favor:
    • Final fields and const constructors where possible.
    • Avoid intermediate maps; parse directly from Map<String, Object?>.
    • Prefer typed lists and pre-allocated buffers for large batches.

Micro-benchmark (works without macros; shows structure you can adapt):

// test/serialization_benchmark_test.dart
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';

class Article {
final String id;
final String title;
final String body;
const Article({required this.id, required this.title, required this.body});

factory Article.fromJson(Map<String, Object?> json) => Article(
id: json['id'] as String,
title: json['title'] as String,
body: json['body'] as String,
);

Map<String, Object?> toJson() => {
'id': id,
'title': title,
'body': body,
};
}

void main() {
test('serialization throughput', () {
final rnd = Random(42);
final data = List.generate(
10000,
(i) => {
'id': '$i',
'title': 'T$i',
'body': 'B${rnd.nextDouble()}',
},
growable: false,
);

final sw = Stopwatch()..start();
final articles = data.map(Article.fromJson).toList(growable: false);
sw.stop();

// Simple sanity assertion + timing log
expect(articles.length, 10000);
// Use print so results appear in test logs
// On modern devices this should be well under ~20ms.
// This represents the cost floor a macro-generated serializer should match.
// ignore: avoid_print
print('fromJson 10k items: ${sw.elapsedMilliseconds}ms');
});
}

As macros-based JSON solutions mature, they should hit the same numbers as the hand-written code above without any build_runner process.

Incremental migration plan for large apps

  1. Stabilize your baseline

    • Freeze build_runner and generator versions.
    • Record current CI times (analyze, test, build) to compare against macros.
  2. Pilot macros on a low-risk vertical

    • Providers (e.g., Riverpod macros) are a great first target.
    • Keep DTOs on json_serializable for now.
  3. Codify guardrails with diagnostics

    • Introduce one or two simple macros to enforce architectural rules (naming, immutability, layer boundaries).
    • Measure how often they catch issues vs generate noise.
  4. Phase out codegen files where safe

    • For features supported by macros, stop committing .g.dart artifacts.
    • Update contributor docs and CI (no more “please run build_runner”).
  5. Track ecosystem readiness for JSON and DI

    • Migrate DTOs once a macro-based serializer meets your needs and is test-hardened.
  6. Bake it into CI

    • Ensure dart analyze, dart test, and flutter test pick up macro diagnostics in your channel.
    • Run a nightly job on the latest dev/beta to catch macro/tooling regressions early.

Tooling, testing, and CI notes

  • Editor support: Keep your Dart/Flutter plugins up to date to see macro-driven diagnostics and navigation in the IDE.
  • Hot reload/hot restart: Macro augmentations may require a restart to take effect depending on your channel/tooling.
  • Testing: Treat macro-generated behavior as ordinary code. Write unit tests for DTOs, providers, and modules the same way. Avoid snapshot-testing macro outputs; instead, assert functional behavior.
  • CI flags: If your channel requires an experiment flag for macros, configure it in your CI runners consistently for analyze, test, and build steps. Pin SDK versions in CI for reproducibility.

Common pitfalls and how to fix them

  • “This SDK/channel doesn’t support macros”
    • Solution: Either enable the macros experiment for your channel or gate macros usage behind a feature flag while staying on build_runner.
  • Duplicate members (method already defined)
    • You’re mixing generator output and macro augmentations for the same type. Remove the generator or disable the macro for that target.
  • Macro packages pinned to older macros API
    • Align versions across all macro-using packages. Prefer caret constraints with a narrow upper bound, and audit changelogs before bumping.
  • Slower-than-expected rebuilds
    • Check you haven’t left build_runner watch running in parallel. Also reduce unnecessary “catch-all” macro annotations on large class graphs.

Architecture patterns that pair well with macros

  • Clean Architecture and modularization
    • Macros enforce module boundaries (diagnostics) and keep domain entities free of framework details.
  • Riverpod/BLoC
    • Macros remove provider/bloc boilerplate and centralize wiring.
  • DI containers
    • Registration macros reduce manual wiring in large feature modules.
  • DTO mapping
    • Macro-based serializers keep mapping code local, fast, and free of generator artifacts.

Frequently asked questions

  • Are macros faster at runtime than build_runner code?
    • Runtime performance depends on the generated code, not the mechanism. Properly-written macro output matches generator output.
  • Do macros work in release/AOT builds?
    • Yes, macro expansion happens at build time. You ship only the resulting program, not the macro runner itself.
  • Can I delete all .g.dart files now?
    • Only for features handled by macros. Keep .g.dart where generators are still in use.

Key takeaways

  • Dart macros metaprogramming is a practical Flutter build_runner alternative for many workflows, especially providers/DI and compile-time design rules.
  • Start with low-risk, high-DX areas (e.g., Riverpod macros). Keep build_runner only where needed.
  • Expect equal runtime performance for Dart code generation (including Flutter serialization performance) once macro-based serializers mature for your stack.
  • Use macros to enhance Dart static analysis: emit meaningful diagnostics that protect your architecture at compile time.
  • Migrate incrementally, pin toolchains, and measure before/after to validate the investment.

Next steps:

  • Pilot a macros-backed provider flow in one feature module.
  • Add one compile-time diagnostic macro that enforces a rule your team repeatedly reviews in PRs.
  • Track updates to macro-enabled ecosystems (Riverpod, DI, serializers), and expand usage as they stabilize on your channel.

Eliminating 'setState() called after dispose()' in Flutter Navigator 2.0 and Async Flows

Published: · 10 min read
Sandra Rosa Antony
Software Engineer, Appxiom

When you mix Navigator 2.0 (page-based routing) with async work - network calls, timers, streams, animations - you’ll eventually hit the dreaded Flutter error: “setState() called after dispose()”. It usually appears as a flaky crash only users can reproduce, often right after a navigation change. This post explains exactly why it happens, how Navigator 2.0 makes it easier to trigger, and how to eliminate it with production-ready patterns you can standardize across your codebase.

Prerequisites

The problem, reproduced

A common scenario: you push a screen, kick off an async request, and the user navigates back before it completes.

import 'package:flutter/material.dart';

class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});

@override
State<ProfileScreen> createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen> {
String? _name;

@override
void initState() {
super.initState();
_load(); // Fire-and-forget
}

Future<void> _load() async {
// Simulate network latency
await Future.delayed(const Duration(seconds: 2));
// If user popped this page in the meantime, setState will crash:
setState(() => _name = 'Ada Lovelace');
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Center(child: Text(_name ?? 'Loading...')),
);
}
}

Navigate back before 2 seconds elapse and you’ll see:

  • setState() called after dispose(): _ProfileScreenState#…

Why? When a route is popped (Navigator 1.0) or your RouterDelegate/go_router updates the pages list (Navigator 2.0), Flutter disposes that State object. Any async callback that tries to call setState() after disposal throws.

Navigator 2.0 makes this more likely because routes can be removed by declarative updates triggered from outside the widget (deep links, auth redirects, URL changes), while your async task is still running.

Root cause in one sentence

You’re calling setState or using BuildContext after an “async gap” (await, then, timer tick, stream event, animation tick) when the widget has already been disposed by navigation.

The baseline fix every async function needs

Always guard UI updates after awaits:

Future<void> _load() async {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return; // or: if (!context.mounted) return;
setState(() => _name = 'Ada Lovelace');
}
  • Use mounted inside State, and context.mounted from anywhere with a BuildContext.
  • Add the check after every await where you reference widget state or context.

This simple rule removes most crashes. But in production you also need cancellation, cleanup, and patterns that scale.

Production patterns that scale

1. Cancel work in dispose

Timers, subscriptions, animations, and cancelable futures should be cleaned up in dispose().

  • Timer:

    Timer? _timer;

    void start() {
    _timer = Timer(const Duration(seconds: 5), _onDone);
    }

    @override
    void dispose() {
    _timer?.cancel();
    super.dispose();
    }
  • StreamSubscription:

    late final StreamSubscription<int> _sub;

    @override
    void initState() {
    super.initState();
    _sub = stream.listen((value) {
    if (!mounted) return;
    setState(() {});
    });
    }

    @override
    void dispose() {
    _sub.cancel();
    super.dispose();
    }
  • AnimationController:

    late final AnimationController _controller;

    @override
    void initState() {
    super.initState();
    _controller = AnimationController(vsync: this);
    }

    @override
    void dispose() {
    _controller.dispose();
    super.dispose();
    }
  • Cancelable futures (async package):

    # pubspec.yaml
    dependencies:
    async: ^2.11.0
    import 'package:async/async.dart';

    CancelableOperation<void>? _op;

    Future<void> fetch() async {
    _op?.cancel();
    _op = CancelableOperation.fromFuture(apiCall());
    await _op!.valueOrCancellation();
    if (!mounted) return;
    setState(() {});
    }

    @override
    void dispose() {
    _op?.cancel();
    super.dispose();
    }
  • Dio CancelToken:

    import 'package:dio/dio.dart';

    final _dio = Dio();
    final _cancelToken = CancelToken();

    Future<void> fetch() async {
    final res = await _dio.get('/user', cancelToken: _cancelToken);
    if (!mounted) return;
    setState(() {});
    }

    @override
    void dispose() {
    _cancelToken.cancel('disposed');
    super.dispose();
    }

2. Don’t await navigation if the callback updates state

Avoid code that awaits a push and then mutates state - because the widget might be gone when the future completes.

Bad:

final result = await context.push('/details'); // go_router or Navigator.push
setState(() => _result = result); // might run after this page is popped

Safer:

context.push('/details').then((result) {
if (!context.mounted) return;
setState(() => _result = result);
});

or explicitly check mounted right after the await:

final result = await context.push('/details');
if (!context.mounted) return;
setState(() => _result = result);

If navigation itself triggers state changes in the current widget, consider not depending on the result at all, or persist the result in shared state (e.g., Riverpod/BLoC), letting the UI rebuild via providers when present.

3. Move long-lived state out of widget trees

Navigator 2.0 encourages declarative routing. Let your page be a pure observer of state, not the owner of it. Managing model state in Riverpod/Bloc/ChangeNotifier outside the route avoids calling setState in disposed pages.

  • Riverpod example:

    dependencies:
    flutter_riverpod: ^2.5.1
    import 'package:flutter_riverpod/flutter_riverpod.dart';

    final profileProvider =
    AsyncNotifierProvider<ProfileController, String?>(ProfileController.new);

    class ProfileController extends AsyncNotifier<String?> {
    @override
    Future<String?> build() async {
    // Load on first use
    return _loadName();
    }

    Future<String?> _loadName() async {
    await Future.delayed(const Duration(seconds: 2));
    return 'Ada Lovelace';
    }

    Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(_loadName);
    }
    }

    class ProfileScreen extends ConsumerWidget {
    const ProfileScreen({super.key});

    @override
    Widget build(BuildContext context, WidgetRef ref) {
    final name = ref.watch(profileProvider);
    return Scaffold(
    appBar: AppBar(title: const Text('Profile')),
    body: Center(
    child: name.when(
    data: (v) => Text(v ?? 'Unknown'),
    loading: () => const CircularProgressIndicator(),
    error: (e, st) => Text('Error: $e'),
    ),
    ),
    floatingActionButton: FloatingActionButton(
    onPressed: () => ref.read(profileProvider.notifier).refresh(),
    child: const Icon(Icons.refresh),
    ),
    );
    }
    }

Because the provider outlives the page, its async finishes regardless of navigation. Widgets subscribe/unsubscribe safely; no setState is called on disposed pages.

Riverpod navigation tip: when navigating from providers/listeners, always check context.mounted or use ref.mounted (when interacting with context after an await inside a WidgetRef):

ref.listen(profileProvider, (prev, next) async {
if (next is AsyncData && next.value == null) {
await Future.delayed(const Duration(milliseconds: 200));
if (!ref.mounted) return;
if (!context.mounted) return;
context.push('/createProfile');
}
});
  • BLoC tip: Use BlocListener instead of manual subscriptions. Flutter_bloc disposes listeners with the widget, preventing late setStates. Still guard navigation with context.mounted after awaits.

4. Defer UI side effects to a post-frame callback

If you need to trigger navigation/snackbars after build (e.g., based on initState values), use a post-frame callback so the first frame presents the widget before side effects run:

@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _ensureSomething();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ready')),
);
});
}

Still check mounted - post-frame callbacks can also run after disposal if you scheduled them just before a pop.

5. A reusable SafeAsyncState mixin

Standardize this across your StatefulWidgets to reduce footguns.

import 'dart:async';
import 'package:async/async.dart';
import 'package:flutter/widgets.dart';

mixin SafeAsyncState<T extends StatefulWidget> on State<T> {
final List<CancelableOperation<dynamic>> _ops = [];
final List<StreamSubscription<dynamic>> _subs = [];

void safeSetState(VoidCallback fn) {
if (mounted) setState(fn);
}

Future<S?> track<S>(Future<S> future) {
final op = CancelableOperation<S>.fromFuture(future);
_ops.add(op);
return op.valueOrCancellation().whenComplete(() {
_ops.remove(op);
});
}

S addSub<S>(StreamSubscription<S> sub) {
_subs.add(sub as StreamSubscription<dynamic>);
return sub;
}

@override
void dispose() {
for (final op in List.of(_ops)) {
op.cancel();
}
for (final sub in List.of(_subs)) {
sub.cancel();
}
super.dispose();
}
}

Usage:

class OrdersPage extends StatefulWidget {
const OrdersPage({super.key});
@override
State<OrdersPage> createState() => _OrdersPageState();
}

class _OrdersPageState extends State<OrdersPage> with SafeAsyncState {
List<String> _orders = const [];

@override
void initState() {
super.initState();
_load();
}

Future<void> _load() async {
final result = await track(fetchOrders()); // cancels on dispose
if (!mounted) return;
safeSetState(() => _orders = result ?? const []);
}

@override
Widget build(BuildContext context) {
// ...
return ListView(children: _orders.map(Text.new).toList());
}
}

6. Navigator 2.0 specifics (RouterDelegate, go_router)

  • RouterDelegate

    • If you own a custom RouterDelegate that listens to app state, ensure you stop notifying listeners after disposal.

    • Example:

      class AppRouterDelegate extends RouterDelegate<RoutePath>
      with ChangeNotifier, PopNavigatorRouterDelegateMixin<RoutePath> {
      final AppState _state;
      bool _disposed = false;

      AppRouterDelegate(this._state) {
      _state.addListener(_onState);
      }

      void _onState() {
      if (!_disposed) notifyListeners();
      }

      @override
      void dispose() {
      _disposed = true;
      _state.removeListener(_onState);
      super.dispose();
      }

      // build(), setNewRoutePath(), etc.
      }
    • When async mutations update the pages list, they can instantly dispose pages. Any pending futures in those pages must be guarded with mounted/cancellation.

  • go_router

    • Never call setState or imperative navigation from inside a builder synchronously to respond to async events. Prefer redirect or GoRouterRefreshStream/ChangeNotifier to trigger re-evaluation.
    • If you perform navigation after an await in a widget using context.go/push, always add if (!context.mounted) return; before using context.
    • Redirect must be fast and synchronous. Avoid awaiting inside redirect; instead, keep the auth state in a provider and let redirect sync off that state.
  • MaterialApp.router vs MaterialApp

    • With MaterialApp.router, route disposal can be triggered by external changes (URL, deep links), not just user gestures. Make every async handler robust against sudden disposal.

7. Prefer builder widgets that manage lifecycles

  • FutureBuilder/StreamBuilder prevent you from manually calling setState on async completions. The framework updates the builder when async values change and stops updating after dispose.
  • This doesn’t absolve you from canceling sources you created (timers/subscriptions), but it reduces manual setState calls.
FutureBuilder<User>(
future: repo.getUser(),
builder: (context, snap) {
if (!snap.hasData) return const CircularProgressIndicator();
return Text(snap.data!.name);
},
);

Common pitfalls and their fixes

  • Starting async in initState with await, then setState without mounted check. Fix: add if (!mounted) return; after the await.
  • Using showDialog/ScaffoldMessenger after navigation:
    final result = await showDialog(...);
    if (!context.mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(...);
  • Timer.periodic updating UI after a pop. Fix: cancel timer in dispose and guard setState.
  • Bloc/stream events arriving after a pop. Fix: prefer BlocListener or cancel StreamSubscription in dispose; guard updates with mounted.
  • AnimationController/Ticker errors combined with disposed pages. Fix: always dispose controllers.
  • Awaiting Navigator.push and mutating state. Fix: check mounted after the await or use then(callback with mounted check).
  • Using context after push/pop inside async. Fix: if (!context.mounted) return; before any context usage.

Testing you’re safe

Write a widget test that pops before completion and asserts no exceptions:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
testWidgets('No setState after dispose on early pop', (tester) async {
await tester.pumpWidget(const MaterialApp(home: ProfileScreen()));
await tester.pump(const Duration(milliseconds: 100));
// Pop immediately
await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle();
// Let the async complete
await tester.pump(const Duration(seconds: 3));

expect(tester.takeException(), isNull);
});
}

If your screen doesn’t have a back button, simulate a Router pages update or call Navigator.of(context).pop() within the test.

Performance and correctness notes

  • Checking mounted/context.mounted is O(1) and effectively free. Add it liberally after awaits.
  • Cancelation avoids wasted work and reduces jank by preventing UI updates for off-screen pages.
  • Moving state to providers reduces setState usage and allows Navigator 2.0 to freely add/remove pages without crashing your UI.
  • For network calls, prefer cancelable clients (Dio with CancelToken). The http package cannot cancel in-flight requests; you can ignore late results but still consume network and CPU - budget accordingly.

Troubleshooting quick reference

  • Error: setState() called after dispose()

    • Cause: you called setState or used context after the widget was removed
    • Fix: add mounted/context.mounted check; cancel timers/streams/futures; move state to providers
  • Error when showing SnackBar after a pop

    • Fix: if (!context.mounted) return; before ScaffoldMessenger calls
  • go_router redirect loops or side effects in builders

    • Fix: keep redirect pure and fast; perform side effects in listeners post-frame with mounted checks
  • Custom RouterDelegate notifying after dispose

    • Fix: guard notifyListeners with a _disposed flag; unregister listeners in dispose

A ready-to-use checklist

  • Add if (!mounted) return; after every await that leads to setState or uses context.
  • Cancel timers, streams, animations, CancelableOperations in dispose.
  • Avoid awaiting navigation futures before mutating local state, or check mounted after.
  • Prefer provider/BLoC-managed state for long-lived async data.
  • Use FutureBuilder/StreamBuilder where practical.
  • In go_router/RouterDelegate, keep redirects pure; perform side effects with listeners and mounted checks.
  • Cover with a widget test that pops before async completion.

Conclusion

Eliminating “setState() called after dispose()” in Flutter Navigator 2.0 and async flows is about respecting lifecycles in a declarative, page-based world. The reliable recipe is simple but non-negotiable: guard UI mutations with mounted/context.mounted, cancel work in dispose, and move long-lived state out of ephemeral pages. Combined with provider/BLoC patterns and cancelable operations, your app will navigate freely without race-condition crashes - no matter how users move through your routes.

Next steps

  • Refactor your async functions to add mounted checks after awaits.
  • Introduce the SafeAsyncState mixin (or equivalent) in your codebase.
  • Adopt Riverpod/BLoC for shared state and let pages become passive observers.
  • Add widget tests that simulate early pops to prevent regressions.