skip to content

In Flutter, how do you read a saved language and an onboarding flag from shared_preferences before the first frame, avoiding a flash of the wrong screen?

level: middleimportance: should knowfreq 40%

answer

  1. plugins need the binding first
  2. await before runApp
  3. create() loads only allow-listed keys
  4. pass the instance down
  5. FutureBuilder shows a default first

basics

~10 s

In main, call WidgetsFlutterBinding.ensureInitialized(), await SharedPreferencesWithCache.create with an allowList of the two keys, then pass the instance into runApp so the first build reads locale and onboarding state synchronously.

solid answer

~40 s

Plugins talk to the platform over channels, so before `runApp` you must call `WidgetsFlutterBinding.ensureInitialized()` or the first plugin call fails with "Binding has not yet been initialized". Then I `await SharedPreferencesWithCache.create(...)` with an `allowList` of just `languageCode` and `onboardingDone` — it loads only those keys — and pass the instance into the app. The first `build` reads both synchronously, so `MaterialApp` gets the right `locale` and the right home screen on frame one. Loading inside a `FutureBuilder` instead renders a default first and then swaps, which is the flash users notice. The native splash stays up while `main` awaits, so the read must stay small.

code

dart · 30 lines
dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferencesWithCache.create(
    cacheOptions: const SharedPreferencesWithCacheOptions(
      allowList: <String>{'languageCode', 'onboardingDone'},
    ),
  );
  runApp(App(prefs: prefs));
}

class App extends StatelessWidget {
  const App({super.key, required this.prefs});

  final SharedPreferencesWithCache prefs;

  @override
  Widget build(BuildContext context) {
    final code = prefs.getString('languageCode');
    final onboarded = prefs.getBool('onboardingDone') ?? false;
    return MaterialApp(
      locale: code == null ? null : Locale(code),
      home: Scaffold(
        body: Center(child: Text(onboarded ? 'Home' : 'Welcome tour')),
      ),
    );
  }
}

go deeper

for a junior

Recall that main must call WidgetsFlutterBinding.ensureInitialized() before using a plugin, and that preferences can be loaded before runApp.

for a middle

Explain why awaiting a small allow-listed load in main avoids the flash that FutureBuilder or initState reads cause, and how the values reach the root widget.

for a senior

Keep start-up reads minimal, measure time to first frame, and make one source of truth for onboarding state across routing and UI.

for a principal

Set a start-up budget for the team: what may be awaited before the first frame, and what must load lazily afterwards.

## The problem Two settings decide what the very first screen looks like: the language the user chose and whether they finished onboarding. If the app draws before it knows them, the user sees English for a moment and then Portuguese, or glimpses onboarding before being bounced to the home screen. Getting both values before the first frame avoids that. ## Step 1: initialise the binding `shared_preferences` is a plugin; its calls cross a **platform channel**, which needs the services binding. `runApp` normally creates the binding, but code that runs before `runApp` has to create it itself: ```dart WidgetsFlutterBinding.ensureInitialized(); ``` Skip it and the first platform call throws a `FlutterError` whose summary reads "Binding has not yet been initialized." This is one of the most common start-up errors in Flutter projects that load preferences in `main`. ## Step 2: load only what the first frame needs `SharedPreferencesWithCache.create` is asynchronous: it fetches the allow-listed keys from the platform once, and afterwards every getter is synchronous. With an explicit `allowList`, that initial fetch reads only those keys rather than everything in the store. - `allowList: {'languageCode', 'onboardingDone'}` — two small values, a fast start. - `allowList: null` — every key the store holds is loaded, which grows as the app ages. `SharedPreferencesAsync` works too — you would `await` both getters in `main` — but the cached API lets widgets read the same values synchronously later, for example in a settings screen. ## Step 3: hand the values to the widget tree Pass the instance (or the two values) into the root widget through its constructor, or through whatever dependency-injection mechanism the app uses. The root `build` then decides synchronously: - `MaterialApp.locale` from `languageCode` (with `null` meaning follow the device); - the `home` — or the router's initial location — from `onboardingDone`. When the user later changes language, write it with `setString` and trigger a rebuild of the root through the app's state mechanism; the cache is already updated, so a synchronous read returns the new value. ## What the alternatives cost | Approach | First frame | Drawback | |---|---|---| | Await in `main`, then `runApp` | correct locale and screen | native splash stays up for the read | | `FutureBuilder` in the root widget | a placeholder or default | a visible flash or an extra loading screen | | Read in `initState` and `setState` | defaults | a rebuild after the first frame, same flash | | Hard-code defaults, fix later | wrong for returning users | returning users see the wrong screen | Awaiting in `main` delays the first Flutter frame, and the platform's launch screen stays visible meanwhile. For two keys that is a short wait; the lesson is to keep this path small, not to load the app's whole state before `runApp`. ## Common mistakes 1. Forgetting `ensureInitialized()` and getting the binding error. 2. Calling `SharedPreferences.getInstance()` inside `build`, creating a `Future` on every rebuild. 3. Caching everything with a `null` allowList and slowing every launch. 4. Using the onboarding flag to gate navigation in one place but reading it again, differently, in another, so the two disagree. ## In practice For the language-and-onboarding app, `main` initialises the binding, creates the cached preferences with a two-key allowList, and passes them to the root widget. The first frame shows the right language and the right screen, and nothing depends on a second read racing the first.

  • Why does calling SharedPreferences before runApp fail without ensureInitialized?
    Plugin calls go over platform channels, which need the services binding. `runApp` creates the binding, but code in `main` before it runs too early, so the first channel call throws "Binding has not yet been initialized". `WidgetsFlutterBinding.ensureInitialized()` creates it up front.
  • Why not simply use a FutureBuilder around MaterialApp?
    The first frame renders before the future completes, so you must show something: a spinner, which adds a screen, or defaults, which flash when the real values arrive. Awaiting in `main` keeps the native splash up instead and makes frame one correct.

saying these in an interview costs you the question

  • Plugins can be called in main before runApp with no extra setup.
  • Calling SharedPreferences.getInstance() inside build is fine because it is cached.
  • Awaiting in main blanks the screen, so a FutureBuilder is always better.
  • An allowList of null makes create() faster because nothing is filtered.
  • SharedPreferencesWithCache getters must be awaited.