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.

Important Note About URL Navigation and Deep Links

As we have seen, we can eagerly initialize our dependencies by inserting an AppStartupWidget at the very top of the widget tree.

But if our app needs to support navigation by URL or via deep links, the root widget needs to return a MaterialApp.router configured with a GoRouter instance (or equivalent), and this not what the AppStartupWidget does.

To address this, we need to take a step back and restore the original setup:

void main() { runApp(const ProviderScope( // * Use MainApp, not AppStartupWidget child: MainApp(), )); }

Then, we can ensure the MainApp uses the router API:

class MainApp extends ConsumerWidget { const MainApp({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final goRouter = ref.watch(goRouterProvider); return MaterialApp.router( routerConfig: goRouter, ..., ); } }

Finally, we can watch the appStartupProvider inside the GoRouter provider, and use the redirect callback to check the state and open a /startup route that will return the AppStartupWidget like this:

@riverpod GoRouter goRouter(GoRouterRef ref) { // rebuild GoRouter when app startup state changes final appStartupState = ref.watch(appStartupProvider); return GoRouter( ..., redirect: (context, state) { // * If the app is still initializing, show the /startup route if (appStartupState.isLoading || appStartupState.hasError) { return '/startup'; } ... }, routes: [ GoRoute( path: '/startup', pageBuilder: (context, state) => NoTransitionPage( child: AppStartupWidget( // * This is just a placeholder // * The loaded route will be managed by GoRouter on state change onLoaded: (_) => const SizedBox.shrink(), ), ), ), ... ], ); }

The net result is that we will still see the AppStartupWidget during app startup, without losing the ability to navigate by URL and process deep links.

The snippets above only show the most important lines of code. For a full example, see this PR on my Time Tracker app on GitHub.

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 multiple providers in one place, then using appStartupProvider is a smart move:

@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { // all asynchronous app initialization code should belong here: await ref.watch(sharedPreferencesProvider.future); await ref.watch(sembastDatabaseProvider.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 { // await for all initialization code to be complete before returning await Future.wait([ ref.watch(sharedPreferencesProvider.future), ref.watch(onboardingRepositoryProvider.future) ]); }

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

What About Programmer or Configuration Errors?

In general, it makes sense to eagerly load dependencies inside appStartup if their initialization may fail due to an unexpected exception.

But if the initialization can only fail due to a programmer error, I still recommend performing it inside main.

An example of this is the classic Firebase initialization code:

Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); // * Initialize Firebase await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // * Entry point of the app runApp(const ProviderScope( child: MainApp(), )); }

In this case, any configuration errors can be immediately spotted (and fixed) when running the app, so it’s best to keep this code inside main and ensure it works as intended.

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.

But to keep things simple, I’m invalidating the appStartupProvider in my retry flow:

AppStartupErrorWidget( message: e.toString(), onRetry: () { ref.invalidate(appStartupProvider); }, )

Then, inside the provider, I invalidate all the providers with the onDispose callback:

@Riverpod(keepAlive: true) Future<void> appStartup(AppStartupRef ref) async { ref.onDispose(() { // ensure dependent providers are disposed as well ref.invalidate(onboardingRepositoryProvider); ref.invalidate(sembastDatabaseProvider); }); // await for all initialization code to be complete before returning await ref.watch(onboardingRepositoryProvider.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, feel free to implement a more robust error-handling flow.

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( data: (_) => onLoaded(context), loading: () => const AppStartupLoadingWidget(), error: (e, st) => AppStartupErrorWidget( message: e.toString(), onRetry: () { ref.invalidate(appStartupProvider); }, ), ); } }

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

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

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
  • If your app supports URL navigation and deep links, move the relevant initialization logic inside your GoRouter instance

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.