Launch Sale!

33% off all my courses

Code with Andrea Pro has launched!

View Offer

How to Build a Robust Flutter App Initialization Flow with Riverpod

Source code on GitHub

When users launch your Flutter app, it’s crucial to make a great first impression with a smooth onboarding process. A glitch during startup could lead them to delete your app and leave a bad review.

So, how can you prevent issues and ensure your app startup code is robust and works as intended?

That’s what we’re going to find out in this article.

We'll start by learning how to use a StatefulWidget to tackle these concerns:

  • Show some loading UI while the app is initializing
  • Handle errors and retry if something goes wrong

Then, we’ll go one step further and learn about eager provider initialization with Riverpod. This technique makes it easy to initialize our dependencies upfront so we can access them synchronously using requireValue later on.

In the past, I relied on provider overrides for initializing asynchronous dependencies. But as we'll see, this approach is outdated. Eager provider initialization combined with requireValue offers a superior alternative.

Ready? Let’s go! 👇

Handling App Startup Errors: The Basics

To get started, let’s consider this code:

void main() async { await someAsyncCodeThatMayThrow(); runApp(const MainApp()); }

What happens if the code above throws an exception?

The answer is that runApp will not execute, and the app will remain stuck on the splash screen - ouch! 😱

As a small improvement, we could wrap our code like this:

void main() async { try { await someAsyncCodeThatMayThrow(); runApp(const MainApp()); } catch (e, st) { log(e.toString(), stackTrace: st); runApp(const AppStartupErrorWidget(e)); } }

Or, for even better error tracking, we can use the runZonedGuarded function:

void main() { runZonedGuarded( () async { await someAsyncCodeThatMayThrow(); return const MainApp(); }, (e, st) { log(e.toString(), stackTrace: st); runApp(const AppStartupErrorWidget(e)); }, ); }

Either way, we’re calling runApp(AppStartupErrorWidget(e)) when an error occurs so we can show some error UI to the user when things go wrong.

However, with this approach, we can’t “retry” and recover gracefully. If the startup process fails, our only option is to force-close the app and restart it.

So, let’s try to do better!

Improved Error Handling with a StatefulWidget

To improve the user experience, let’s consider some additional requirements:

  • Display a loading screen during initialization
  • If the initialization fails, show an error message with a “Retry” button
  • If all goes well, show the main app UI

We can manage these scenarios—loading, error, and success—using a custom StatefulWidget that becomes responsible for the app initialization logic:

class AppStartupWidget extends StatefulWidget { const AppStartupWidget({super.key}); @override State<AppStartupWidget> createState() => _AppStartupWidgetState(); } class _AppStartupWidgetState extends State<AppStartupWidget> { // declare state variables @override void initState() { // handle async initialization super.initState(); } @override Widget build(BuildContext context) { /* * if (success) return MainApp() * if (loading) return AppStartupLoadingWidget() * if (error) return AppStartupErrorWidget(error, onRetry: () { ... }) */ } }

And that simplifies our main() method to just this line:

void main() { runApp(const AppStartupWidget()); }

To flesh out this widget, we can consider these steps:

  • Declare some sealed classes to represent the three possible states
  • Add the async code to initState() and update the state on success or error
  • Use a switch expression to map the state to the UI in the build() method
  • Add the retry logic

However, this approach requires quite a bit of work.

Moreover, if we have to boot up some dependencies and pass them around the whole app, relying solely on our AppStartupWidget falls short. We'd benefit from integrating a dependency injection framework or a service locator.

With that in mind, let’s take a closer look at the asynchronous dependency setup. 👇

Asynchronous Dependency Initialization with Riverpod

The initialization code I shared earlier looks like this:

await someAsyncCodeThatMayThrow();

But in a real-world app, you'll likely have dependencies that need to be ready for later use. Riverpod providers are perfect for this job. For example:

// A regular provider for accessing a dependency that is initialized *synchronously* @Riverpod(keepAlive: true) FirebaseAuth firebaseAuth(FirebaseAuthRef ref) => FirebaseAuth.instance;

Yet, some dependencies are initialized asynchronously, and for those, we can use a FutureProvider:

// A FutureProvider for accessing a dependency that is initialized *asynchronously* @Riverpod(keepAlive: true) Future<SharedPreferences> sharedPreferences(SharedPreferencesRef ref) => SharedPreferences.getInstance(); // returns a Future

Here, we want the dependency to be initialized as soon as the app starts.

But keep in mind, Riverpod providers are lazily initialized by default—they are built when first used, not when declared. And the documentation says that if we want to eagerly initialize a provider, we can do so with a child widget.

Note how I’ve used keepAlive: true in the declarations above. This makes sense when we want dependencies to be initialized only once, during app startup.

Eager Provider Initialization with a Child Widget

Let's continue with our example. We'll create a custom appStartupProvider:

@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { ref.onDispose(() { // ensure we invalidate all the providers we depend on ref.invalidate(sharedPreferencesProvider); }); // all asynchronous app initialization code should belong here: await ref.watch(sharedPreferencesProvider.future); }

A couple of things to note:

  • We invalidate the sharedPreferencesProvider provider inside the onDispose callback. As we're about to see, this is triggered when we invalidate the appStartupProvider itself inside our widget.
  • To ensure the sharedPreferencesProvider gets initialized, we’re using await with the .future syntax. This trick is explained here.

Now, let's redefine our AppStartupWidget like this:

/// Widget class to manage asynchronous app initialization class AppStartupWidget extends ConsumerWidget { const AppStartupWidget({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // 2. eagerly initialize appStartupProvider (and all the providers it depends on) final appStartupState = ref.watch(appStartupProvider); return appStartupState.when( // 3. loading state loading: () => const AppStartupLoadingWidget(), // 4. error state error: (e, st) => AppStartupErrorWidget( message: e.toString(), // 5. invalidate the appStartupProvider onRetry: () => ref.invalidate(appStartupProvider), ), // 6. success - now load the main app data: (_) => MainApp(), ); } } void main() { // 1. load it when the app starts runApp(const AppStartupWidget()); }

Guess what?

Our setup ticks all the boxes:

  1. As soon as the app starts, the AppStartupWidget is loaded
  2. This causes the appStartupProvider to be initialized (along with all the providers it depends on)
  3. While the provider is loading, we show a custom AppStartupLoadingWidget
  4. If there’s an error, we show an AppStartupErrorWidget with a retry option
  5. If the retry option is selected, we invalidate the appStartupProvider

And if the initialization is successful, the MainApp widget takes the stage.

Note that since AppStartupWidget is a top-level widget, both AppStartupLoadingWidget and AppStartupErrorWidget need to return a MaterialApp. The full source code for this example can be found here.

Accessing Eagerly-Initialized Providers with requireValue

An important detail to note is that once MainApp is loaded, the sharedPreferencesProvider is guaranteed to have a value.

That means whenever we need it, we can do this:

@override Widget build(BuildContext context, WidgetRef ref) { final sharedPrefs = ref.watch(sharedPreferencesProvider).requireValue; }

Put simply, by using requireValue, we're stating: “I know this provider was set up asynchronously, but by the time I'm calling it here, it always has a value.”

This works because the provider is a FutureProvider that is eagerly initialized before MainApp is loaded. Hence, every descendant widget of MainApp can assume that it has a value.

A diagram showing the various widget trees loaded at different stages of the app startup process
A diagram showing the various widget trees loaded at different stages of the app startup process

Heads-up: If you try to access requireValue on a provider that isn't ready yet, you'll hit an exception. If that happens, it's time to debug and revisit your assumptions.

The “old” way: Provider Overrides (don’t use this)

The old way of doing things was to declare a normal provider that throws an UnimplementedError:

@Riverpod(keepAlive: true) SharedPreferences sharedPreferences(SharedPreferencesRef ref) => throw UnimplementedError();

And then, we'd override it in the main function:

void main() async { final sharedPreferences = await SharedPreferences.getInstance(); runApp(ProviderScope( overrides: [ sharedPreferencesProvider.overrideWithValue(sharedPreferences) ], child: const MainApp(), )); }

If you’re working on an old codebase, you may come across this solution - or a variant that uses ProviderContainer and UncontrollerProviderScope to initialize the provider eagerly.

But as we have seen, we should not initialize dependencies inside main as we can’t recover if something goes wrong. For this reason, eager provider initialization inside a child widget is safer, and works great in tandem with requireValue.

Common Questions

Having delved deep into the topic, let's tackle a couple of questions you may have.

How to Eagerly Initialize Multiple Providers?

If we need to initialize (and dispose) multiple providers in one place, then using appStartupProvider is a smart move:

@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { ref.onDispose(() { // ensure we invalidate all the providers we depend on ref.invalidate(sharedPreferencesProvider); ref.invalidate(sembastDatabaseProvider); }); // all asynchronous app initialization code should belong here: await ref.watch(sharedPreferencesProvider.future); await ref.watch(sembastDatabaseProvider.future); }

But it's not just providers that can benefit from asynchronous setups. For example, in my time tracker app, I’ve decided to move the Firebase initialization logic inside the appStartupProvider:

@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { ref.onDispose(() { // ensure we invalidate all the providers we depend on ref.invalidate(onboardingRepositoryProvider); }); // Firebase init (note: this is a side effect) await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // list of providers to be warmed up await ref.watch(onboardingRepositoryProvider.future); }

If you want, you can even use Future.wait if your dependencies don’t depend on each other:

@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { ref.onDispose(() { // ensure we invalidate all the providers we depend on ref.invalidate(onboardingRepositoryProvider); }); // await for all initialization code to be complete before returning await Future.wait([ // Firebase init (note: this is a side effect) Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform), // list of providers to be warmed up ref.watch(onboardingRepositoryProvider.future) ]); }

This way you may be able to shave a few milliseconds off your app startup time.

Usually, it’s not a good idea to perform side effects inside a provider body (since providers and widgets can be built many times, and it's beyond your control). But in this scenario, it seems okay since we're talking about a one-off operation at app launch.

How to Implement the Retry Logic?

If we’re initializing multiple providers and an exception is thrown, how do we know which provider failed?

Unless we implement a more complex error-handling flow, we simply don’t know.

That’s why, in the onDispose callback, I’m invalidating all the providers the appStartupProvider depends on:

@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { ref.onDispose(() { // ensure we invalidate all the providers we depend on ref.invalidate(sharedPreferencesProvider); ref.invalidate(sembastDatabaseProvider); }); // all asynchronous app initialization code should belong here: await ref.watch(sharedPreferencesProvider.future); await ref.watch(sembastDatabaseProvider.future); }

Essentially, this says: “I don’t know which provider failed, so reload them all just in case”.

This is probably fine for most apps - and a much better approach than initializing dependencies in main and ignoring the errors.

But if you want to go the extra mile, you could try using ref.listen to track errors and selectively rebuild providers on retry:

@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { // add one listener for each provider we depend on ref.listen(onboardingRepositoryProvider, (previous, current) { if (current.hasError) { // keep track of error so the provider can be rebuilt on retry } }); ... }

Can I make the App Startup Logic more reusable?

Yes. One way is to improve the AppStartupWidget by adding an onLoaded argument:

/// Widget class to manage asynchronous app initialization class AppStartupWidget extends ConsumerWidget { const AppStartupWidget({super.key, required this.onLoaded}); final WidgetBuilder onLoaded; @override Widget build(BuildContext context, WidgetRef ref) { final appStartupState = ref.watch(appStartupProvider); return appStartupState.when( // use an external widget builder to decide what to return data: (_) => onLoaded(context), loading: () => const AppStartupLoadingWidget(), error: (e, st) => AppStartupErrorWidget( message: e.toString(), onRetry: () { ref.invalidate(onboardingRepositoryProvider); ref.invalidate(appStartupProvider); }, ), ); } }

This way, we can specify which widget to load from the outside:

runApp(ProviderScope( child: AppStartupWidget( onLoaded: (context) => const MyApp(), ), ));

However, AppStartupWidget still depends on some app-specific providers (such as onboardingRepositoryProvider and appStartupProvider). So keep in mind that some tweaks are needed if you want to reuse this.

Can Any Provider be Eagerly Initialized?

No. The main purpose of the appStartupProvider is to initialize asynchronous dependencies that don't change after the app has started.

As such, providers that may change their state should not be eagerly initialized, as this may trigger unwanted rebuilds.

How to Transition Between the Splash, Loading, and Main UI Screens?

Up until runApp is called, the Flutter app shows a native splash screen (which can be configured with a package such as flutter_native_splash).

If you want to ensure a smooth transition, customize your loading screen so that it matches the native splash screen, and overlay your loading UI with some animations.

Likewise, you can animate between the loading screen and the main screen UI once the initialization is complete.

Conclusion

When it comes to mobile apps, offering a delightful onboarding experience is your chance to wow your users.

To avoid frustration, your app startup logic should be robust and handle errors gracefully, and the techniques covered in this article should help you with this.

Here are the key points:

  • Initialize all the asynchronous providers inside an appStartupProvider (using await and .future)
  • Eagerly initialize the appStartupProvider inside a top-level widget
  • Show some loading UI, handle errors, and provide a “retry” mechanism
  • Access your asynchronous dependencies with requireValue

That’s it! You now have a repeatable process for writing robust app startup code. Feel free to use my time-tracking app on GitHub as a reference if you need a real-world example.

Of course, there’s more to cover. For example, we haven’t talked about error monitoring and crash reporting, and these may be topics for future articles.

But I hope you found this guide useful. Happy coding! 🙂

Flutter Foundations Course Now Available

I launched a brand new course that covers state management with Riverpod in great depth, along with other important topics like app architecture, routing, testing, and much more:

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.