Start Debugging
2026-06-18 Updated 2026-06-18 migrationflutterriverpod Edit on GitHub

Migrate from FutureBuilder to a Riverpod AsyncNotifier in Flutter (flutter_riverpod 3.3.2)

A step-by-step migration from an inline FutureBuilder widget to a Riverpod AsyncNotifier in a real Flutter app: move the async work out of build, expose it as a provider, render with .when() or switch pattern matching, and add refresh and mutation methods. Tested on Flutter 3.44, Dart 3.x, flutter_riverpod 3.3.2.

Moving a screen from FutureBuilder to a Riverpod AsyncNotifier is usually a 30-to-60 minute job per screen, and most of that time is spent deleting code rather than writing it. What breaks: the Future you used to create inside build moves into a provider, the widget loses its StatefulWidget boilerplate, and any manual setState retry logic gets replaced by ref.invalidate. It is worth doing the moment a second widget needs the same data, you want caching across navigation, or you need to trigger a refresh from somewhere other than the widget that owns the FutureBuilder. If a screen genuinely owns a one-shot Future that nothing else touches, leave it as a FutureBuilder — this migration buys you nothing there.

This guide uses Flutter 3.44, Dart 3.x, and flutter_riverpod 3.3.2. The code-generation snippets assume riverpod_annotation 3.x and riverpod_generator 3.x with build_runner.

Why move off FutureBuilder

What breaks

AreaBefore (FutureBuilder)After (AsyncNotifier)Severity
Where the Future livesCreated in build or initStatebuild() method of the notifierhigh
Widget typeUsually StatefulWidgetConsumerWidget (stateless)medium
Loading/error renderingsnapshot.connectionState + snapshot.hasErrorAsyncValue.when or switchmedium
RetryRebuild + recreate Futureref.invalidate(provider)low
MutationssetState after awaitmethod + AsyncValue.guardmedium
Cancellation on disposeManual mounted checksAutomatic via ref.onDisposelow

The one genuinely high-severity item is where the Future lives: everything else follows from moving it.

Pre-flight checklist

// Flutter 3.44, flutter_riverpod 3.3.2
void main() {
  runApp(const ProviderScope(child: MyApp()));
}

The starting point: an inline FutureBuilder

Here is the pattern we are migrating away from. A profile screen fetches a user and renders three states by hand. The bug baked into it is the classic one: repo.fetchUser(userId) runs again on every rebuild because the Future is created inside build.

// Flutter 3.44, Dart 3.x -- the BEFORE
class ProfileScreen extends StatelessWidget {
  const ProfileScreen({super.key, required this.userId});
  final String userId;

  @override
  Widget build(BuildContext context) {
    final repo = UserRepository();
    return FutureBuilder<User>(
      future: repo.fetchUser(userId), // re-runs on every rebuild
      builder: (context, snapshot) {
        if (snapshot.connectionState != ConnectionState.done) {
          return const Center(child: CircularProgressIndicator());
        }
        if (snapshot.hasError) {
          return Center(child: Text('Failed: ${snapshot.error}'));
        }
        final user = snapshot.data!;
        return Text(user.name);
      },
    );
  }
}

Migration steps

  1. Declare the provider. Move the async call into a notifier. There are two ways to write it; pick one and stay consistent across the codebase.

Code-generation style (recommended for new code):

// flutter_riverpod 3.3.2, riverpod_annotation 3.x
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'profile_controller.g.dart';

@riverpod
class ProfileController extends _$ProfileController {
  @override
  Future<User> build(String userId) {
    return ref.watch(userRepositoryProvider).fetchUser(userId);
  }
}

The userId parameter on build makes this a family: profileControllerProvider(userId) gives you one cached notifier per id. Run the generator and verify it produces the .g.dart file with no errors:

# verify: the build completes and emits profile_controller.g.dart
dart run build_runner build --delete-conflicting-outputs

Manual style (no code generation):

// flutter_riverpod 3.3.2
final profileControllerProvider =
    AsyncNotifierProvider.family<ProfileController, User, String>(
  ProfileController.new,
);

class ProfileController extends FamilyAsyncNotifier<User, String> {
  @override
  Future<User> build(String userId) {
    return ref.watch(userRepositoryProvider).fetchUser(userId);
  }
}

Both produce a provider whose value is an AsyncValue<User>. The notifier’s build runs once per userId and the result is cached until invalidated. Note that you no longer construct UserRepository() by hand: inject it through another provider so it is testable and shared.

  1. Convert the widget to a ConsumerWidget. The StatefulWidget/StatelessWidget becomes a ConsumerWidget, and build gains a WidgetRef. Read the provider with ref.watch, then render the AsyncValue.
// flutter_riverpod 3.3.2 -- the AFTER
class ProfileScreen extends ConsumerWidget {
  const ProfileScreen({super.key, required this.userId});
  final String userId;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(profileControllerProvider(userId));
    return userAsync.when(
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (err, stack) => Center(child: Text('Failed: $err')),
      data: (user) => Text(user.name),
    );
  }
}

Verify: hot-restart the screen. It should fetch exactly once. Navigate away and back — it should not refetch (the cache is alive as long as something keeps the provider mounted). That single-fetch behaviour is the whole point of the migration.

  1. Render with switch pattern matching (optional but cleaner). Dart 3 pattern matching reads better than .when() for some teams and lets you keep stale data visible during a refresh. The full treatment of these patterns lives in showing loading and error states with AsyncValue, but the short version:
// Dart 3.x switch over AsyncValue
final userAsync = ref.watch(profileControllerProvider(userId));
return switch (userAsync) {
  AsyncData(:final value) => Text(value.name),
  AsyncError(:final error) => Text('Failed: $error'),
  _ => const Center(child: CircularProgressIndicator()),
};

Verify: this compiles with no non-exhaustive switch warning. The _ catch-all handles AsyncLoading.

  1. Replace retry with ref.invalidate. The old retry path recreated the Future by rebuilding. Now retry is one line. Add a button to the error branch:
// flutter_riverpod 3.3.2
error: (err, stack) => Center(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Text('Failed: $err'),
      ElevatedButton(
        onPressed: () => ref.invalidate(profileControllerProvider(userId)),
        child: const Text('Retry'),
      ),
    ],
  ),
),

ref.invalidate discards the cached value and re-runs build, which flips the AsyncValue back to loading and then to data or error. Verify: force an error (turn off the network), tap Retry with the network back on, confirm it transitions loading then data.

  1. Add mutations with AsyncValue.guard. This is the capability FutureBuilder never had. To update the user and reflect the result, add a method to the notifier. AsyncValue.guard wraps the async call so a thrown exception becomes an AsyncError instead of an unhandled crash.
// flutter_riverpod 3.3.2
@riverpod
class ProfileController extends _$ProfileController {
  @override
  Future<User> build(String userId) {
    return ref.watch(userRepositoryProvider).fetchUser(userId);
  }

  Future<void> rename(String newName) async {
    final repo = ref.read(userRepositoryProvider);
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      await repo.rename(userId, newName);
      return repo.fetchUser(userId);
    });
  }
}

Call it from the widget with ref.read(...).rename(...) inside a callback (use read, not watch, in callbacks). Verify: trigger a rename, watch the UI go loading then show the new name; trigger a failing rename and confirm the error state renders instead of throwing.

Verification

Run this checklist after migrating a screen:

// flutter_test + flutter_riverpod 3.3.2
test('loads the user', () async {
  final container = ProviderContainer(overrides: [
    userRepositoryProvider.overrideWithValue(FakeUserRepository()),
  ]);
  addTearDown(container.dispose);

  final user = await container.read(profileControllerProvider('42').future);
  expect(user.name, 'Ada');
});

Rollback plan

This migration is reversible per screen because you can convert one widget at a time. To roll back a single screen, restore the FutureBuilder widget and delete its provider; nothing else depends on it if you migrated incrementally. The only one-way door is removing the old StatefulWidget plumbing across many screens in a single commit — do not do that. Keep each screen’s migration in its own commit so a revert is a one-line git revert.

Gotchas we hit

ref.watch inside callbacks rebuilds nothing useful. In an onPressed handler use ref.read. watch is for build; using it in a callback subscribes at the wrong time and is a common source of “my button does not refresh the screen” confusion.

The family parameter must be stable. profileControllerProvider(userId) keys the cache on userId. If you accidentally pass a freshly constructed object (a new User instance, a map) instead of a value-equal key, you get a fresh notifier every rebuild and the cache never hits. Use primitives or types with proper ==.

Disposed ref after an await. If a mutation awaits and the provider is disposed mid-flight (the user navigated away), touching ref afterwards throws. Riverpod 3 surfaces this clearly; the fix and the exact message are in fixing “Cannot use ref after the widget was disposed”. Guard with ref.mounted if you must touch ref after an await in a long mutation.

Provider gets disposed too eagerly. By default a provider with no listeners is disposed. If you navigate away and back and see a refetch you did not want, that is auto-dispose doing its job. Keep it alive deliberately with ref.keepAlive() inside build, or accept the refetch as correct cache behaviour.

Do not mix this with provider package state. If your app still uses the legacy provider package elsewhere, migrate that separately; the two coexist but blur the mental model. The provider to Riverpod migration covers that path. And if you are still deciding whether AsyncNotifier is even the right call for a given widget, the FutureBuilder vs Riverpod AsyncValue decision guide draws the line.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back