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 theonDispose
callback. As we're about to see, this is triggered when we invalidate theappStartupProvider
itself inside our widget. - To ensure the
sharedPreferencesProvider
gets initialized, we’re usingawait
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:
- As soon as the app starts, the
AppStartupWidget
is loaded - This causes the
appStartupProvider
to be initialized (along with all the providers it depends on) - While the provider is loading, we show a custom
AppStartupLoadingWidget
- If there’s an error, we show an
AppStartupErrorWidget
with a retry option - 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, bothAppStartupLoadingWidget
andAppStartupErrorWidget
need to return aMaterialApp
. 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.
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
(usingawait
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: