App bootstrap
Getting an app from main() to its first real screen usually means a sequence of async steps - initialize the SDK, restore the session, fetch remote config - and the obvious place to put them is main():
// DON'T - sequential bootstrap in main() with manual flags
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(); // <- First frame blocked until this resolves
final config = await ConfigService().fetch(); // <- One failure and the app never starts
final session = await AuthService().restoreSession(); // <- No retry, no reactivity
runApp(MyApp(config: config, session: session)); // <- A frozen snapshot of boot data
}
This blocks the first frame, has no retry path, and freezes the boot data into widget constructors. The hooks approach models each bootstrap step as an ordinary global state that reports whether it is ready - reactive, retryable, observable - and gates the splash screen on an aggregate of those flags. main() shrinks to three lines: bind the framework, build the dependency injector, run the app.
// DO - main() does the minimum; bootstrap lives in global states
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await AppServices.initialize(); // <- Builds the Injector (see Dependency injection)
runApp(const MyApp());
}
Everything else becomes a global state. (Wrapping main in an app-wide error zone so async bootstrap failures are reported is covered in Error handling.)
Bootstrap steps are HasInitialized states
Each step is a global state whose state class extends HasInitialized - a base class with a single isInitialized flag. The "download" hook useAutoComputedState already exposes exactly that flag, so a step is usually a one-liner over the work it performs:
class FirebaseState extends HasInitialized {
const FirebaseState({required super.isInitialized});
}
FirebaseState useFirebaseState() {
final initState = useAutoComputedState<void>(_initializeFirebase);
return FirebaseState(isInitialized: initState.isInitialized);
}
isInitialized is false until the future completes and true once it does. If the future throws, it stays false - which, as you will see, keeps the splash gated rather than routing into a half-initialized app.
The ordered _providers map
All global states are registered in one map at the app root. The map is built in order on the first build, so it doubles as the dependency graph of your bootstrap: each entry may useProvided only the entries registered above it.
// app.dart - the map IS the boot diagram
final _providers = <Type, Object? Function()>{
// --- Architectural ---
Injector: () => AppServices.injector, // built in main(), so it is first
// --- Data states - each may useProvided entries above it ---
FirebaseState: useFirebaseState,
RemoteConfigState: useRemoteConfigState, // gated on FirebaseState
AuthState: useAuthState, // gated on FirebaseState
ProfileState: useProfileState, // gated on AuthState
// --- Aggregate readiness - last, it reads everything above ---
InitializationState: useInitializationState,
};
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return HookProviderContainerWidget(
_providers,
child: const MaterialApp(home: SplashScreen()),
);
}
}
The grouping comments and the aggregate-last placement are the convention - with twenty-plus globals, the map stays readable as the boot order. Registering a state below its dependency is the classic startup bug: useProvided<X>() for an X declared lower in the map throws ProvidedValueNotFoundException on the first build. Reorder the map to fix it.
Gating one step on another
A downstream step gates its work on an upstream step's readiness through shouldCompute. The auth → profile chain is the canonical example:
class AuthState extends HasInitialized {
final String? userId;
const AuthState({required super.isInitialized, required this.userId});
bool get isLoggedIn => userId != null;
}
class ProfileState extends HasInitialized {
final Profile? profile;
const ProfileState({required super.isInitialized, required this.profile});
}
ProfileState useProfileState() {
final auth = useProvided<AuthState>();
final profileState = useAutoComputedState(
() => _loadProfile(auth.userId!),
shouldCompute: auth.isInitialized && auth.isLoggedIn,
keys: [auth.userId], // re-load when the user changes
);
return ProfileState(
// Not-applicable-tolerant: logged out means there is no profile step to wait for.
isInitialized: auth.isInitialized && (!auth.isLoggedIn || profileState.isInitialized),
profile: profileState.valueOrNull,
);
}
Three properties make this correct:
shouldComputeis itself a key ofuseAutoComputedState, so the flip fromfalsetotruere-triggers the compute on its own. Addkeys:only for other compute inputs (here,auth.userId).shouldCompute: falseclears the state immediately. Logging out wipes the profile automatically - no manual cleanup effect.- Every computed state implements
HasInitialized, soprofileState.isInitializedcomposes straight into the readiness formula.
The not-applicable-tolerant formula
Look closely at the isInitialized line above:
isInitialized: auth.isInitialized && (!auth.isLoggedIn || profileState.isInitialized)
The general shape is upstreamReady && (!applicable || stepDone). This is the single most important rule of bootstrap chains. A step that waits unconditionally on work that will never run - loading a profile while logged out, fetching purchases while offline - deadlocks the entire boot: its flag never flips, so the aggregate never flips, so the splash never routes. Always express "this step does not apply right now" as initialized.
The most common bootstrap hang is a step that waits on a prerequisite that never arrives. If the splash never leaves, find the global state whose isInitialized is stuck false and check whether it is waiting on something that does not apply in the current session.
Aggregating readiness
One dedicated global composes every step's flag and is what the splash actually watches. useCombinedInitializationState takes a set of types, resolves each from the provider container, and ANDs their isInitialized flags:
typedef InitializationState = CombinedInitializationState;
// Every type listed here must extend HasInitialized and be registered ABOVE
// InitializationState in _providers.
const Set<Type> _initializationStates = {
FirebaseState,
RemoteConfigState,
AuthState,
ProfileState,
};
InitializationState useInitializationState() => useCombinedInitializationState(_initializationStates);
Adding a bootstrap state to the app is then two edits: one _providers entry and one line in this set. Every type in the set must extend HasInitialized and be registered above InitializationState - a type listed here but registered below (or missing) fails at startup when the aggregate tries to resolve it.
SDK initialization races
All providers build on the container's first build, and useAutoComputedState fires its compute on mount - possibly before Firebase.initializeApp() has finished. A state that touches the SDK in that window throws. The convention: gate every SDK-touching compute on the SDK's own state.
class ItemsState extends HasInitialized {
final List<String>? items;
const ItemsState({required super.isInitialized, required this.items});
}
ItemsState useItemsState() {
final firebase = useProvided<FirebaseState>();
final itemsState = useAutoComputedState(
() async => ['one', 'two'], // touches Firestore in a real app
shouldCompute: firebase.isInitialized, // re-triggers itself when the SDK comes up
);
return ItemsState(isInitialized: itemsState.isInitialized, items: itemsState.valueOrNull);
}
Because shouldCompute is a key, the fetch re-triggers itself the moment FirebaseState reports ready - you do not wire anything up by hand. For stream-backed globals the same gate is a nullable stream factory: return null until the prerequisite is ready, and pass the prerequisite in keys so the factory is re-evaluated when it flips.
One-shot setup with no value
Some startup work produces nothing - configure an SDK, precache assets, run a migration. Model it as a value-less computed state rather than a manual bool with try/catch/finally:
class BillingSetupState extends HasInitialized {
const BillingSetupState({required super.isInitialized});
}
BillingSetupState useBillingSetupState() {
// Readiness IS the computed value: failure leaves it false, refresh() retries.
final setupState = useAutoComputedState<void>(() async {/* service.configure() */});
return BillingSetupState(isInitialized: setupState.isInitialized);
}
Readiness is the computed value: failure leaves isInitialized false (so the aggregate keeps gating), and refresh() is a free retry.
Retryable bootstrap
A failed step keeps isInitialized == false forever, so the splash hangs. Expose an error and a retry on the state so the splash can offer a button:
class RemoteConfigState extends HasInitialized {
final Config? config;
final Object? error; // non-null when bootstrap failed
final Future<void> Function() retry; // travels with the flag the splash reads
const RemoteConfigState({
required super.isInitialized,
required this.config,
required this.error,
required this.retry,
});
bool get isUpdateRequired => config?.isUpdateRequired ?? false;
}
RemoteConfigState useRemoteConfigState() {
final firebase = useProvided<FirebaseState>();
final configState = useAutoComputedState(
_fetchConfig,
shouldCompute: firebase.isInitialized,
isRetryable: true,
);
return RemoteConfigState(
isInitialized: configState.isInitialized,
config: configState.valueOrNull,
error: configState.value.maybeWhen(failed: (e) => e, orElse: () => null),
retry: configState.refresh,
);
}
The retry travels with the flag the splash already reads. Passing isRetryable: true additionally wraps the thrown error so an app-level handler can re-run the original compute - the global retry path is covered in Error handling.
The splash decision tree
The splash screen renders nothing - the native splash still covers the app - waits for the aggregate flag, then routes exactly once. It is a coordinator with no View to protect: a single HookWidget that reads the globals and runs the decision tree inline in build. The usual state/view split exists to keep logic out of the UI, but here there is no UI - build returns SizedBox.shrink() - so the decision tree has nowhere else to live. (This is not the lightweight tier, which is reserved for components that pop themselves and nothing more; the splash pushes routes and reads several globals, so it would otherwise be a full triple. It earns the inline form by rendering nothing, not by being trivial.)
class SplashScreen extends HookWidget {
static const route = '/splash';
const SplashScreen({super.key});
Widget build(BuildContext context) {
final initialization = useProvided<InitializationState>();
final config = useProvided<RemoteConfigState>();
final auth = useProvided<AuthState>();
final profile = useProvided<ProfileState>();
useEffect(() {
if (!initialization.isInitialized) return null; // <- Runs once now, once when the flag flips
// Decision tree - first match wins
final navigator = Navigator.of(context);
if (config.isUpdateRequired) {
navigator.pushReplacementNamed(UpdateRequiredScreen.route);
} else if (!auth.isLoggedIn) {
navigator.pushReplacementNamed(LandingScreen.route);
} else if (profile.profile?.isRegistrationComplete != true) {
navigator.pushReplacementNamed(RegistrationScreen.route);
} else {
navigator.pushReplacementNamed(MainScreen.route);
}
return null;
}, [initialization.isInitialized]);
return const SizedBox.shrink();
}
}
The effect is keyed on the single aggregate flag: it runs once on mount (flag still false, returns early) and once when the flag flips to true. Gate on the aggregate, never on individual flags - a new bootstrap state added to the set is then automatically waited for, with nothing to update here.
See also
- Global state - the per-state
HasInitializedshape and the_providersmap basics - Dependency injection & services - building the
InjectorbeforerunApp - useCombinedInitializationState - the aggregate hook
- useAutoComputedState -
shouldCompute,isInitialized, andrefresh - Error handling - the app-wide error zone and the global retry dialog