State, navigation and release builds

Where app state should live, how routes are pushed, and the commands that produce a store-ready bundle.

Managing state

ApproachBest forWatch out for
setStateState used by one screenRebuilds the whole element subtree below it
ValueNotifier + ValueListenableBuilderOne changing value shared by a few widgetsManual wiring once the graph grows
InheritedWidget / InheritedNotifierFramework-level sharing (theme, locale)Verbose to write by hand
Provider / RiverpodApp-wide models with dependency injectionChoose one at the start; mixing two is painful
Bloc / CubitLarge apps with complex event flowsMore boilerplate for simple screens
class NoteStore extends ChangeNotifier {
  final List<String> _notes = [];
  List<String> get notes => List.unmodifiable(_notes);

  void add(String title) {
    if (title.trim().isEmpty) return;
    _notes.add(title.trim());
    notifyListeners();
  }
}

// expose it once, above the routes that need it
ChangeNotifierProvider(
  create: (_) => NoteStore(),
  child: const NotesApp(),
)

// and read it where it matters
final store = context.watch<NoteStore>();

The rule that keeps Flutter codebases sane: state lives above every widget that needs it and no higher. Local UI state stays in State; data that several screens share goes into a store provided from above.

// imperative push and pop
Navigator.of(context).push(
  MaterialPageRoute(builder: (_) => const NoteDetail()),
);
Navigator.of(context).pop('saved');

// declarative routes with go_router
final router = GoRouter(
  routes: [
    GoRoute(path: '/', builder: (_, __) => const NotesPage()),
    GoRoute(
      path: '/note/:id',
      builder: (context, state) => NoteDetail(id: state.pathParameters['id']!),
    ),
  ],
);
  • Pushing keeps the previous route alive, so passing a value back with pop(result) is often simpler than a shared store.
  • Deep links, browsers and the Android back gesture all need a route name or path — a declarative router gives you that for free.
  • Guard routes in one place (auth, onboarding) instead of repeating the check inside every screen.
💡
Build navigation around data, not screens. A route that takes an id and loads its own data survives a cold start from a deep link, whereas a route that expects an object passed in memory does not.

Building for release

flutter build appbundle --release --obfuscate --split-debug-info=build/symbols
flutter build apk --release --split-per-abi
flutter build ipa --release --export-method app-store
flutter build web --release --base-href /app/
  • Android release builds must be signed. Put the keystore path and passwords in android/key.properties, load them in build.gradle.kts, and keep that file out of version control.
  • --split-debug-info shrinks the binary and keeps a symbol map; upload the symbols so crash reports stay readable.
  • iOS needs a distribution certificate, a provisioning profile and an App Store Connect app record before flutter build ipa can be uploaded.
  • Release performance is very different from debug: measure with --profile and the DevTools timeline, never from a debug build.

FAQ

Which state management package should I pick?
Start with setState and a couple of ChangeNotifiers. Move to Riverpod or Bloc when the app has enough shared, testable state to justify the extra structure. The right time is when manual wiring starts to hurt.
Why is my release build different from debug?
Release enables tree shaking and AOT compilation, disables assertions, and strips const-evaluated debug output. If behaviour differs, look for code that relies on an assertion or on a debug-only conditional.

Widgets and layout Setup and your first app

Last refreshed 2026-09-18.