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
| Approach | Best for | Watch out for |
|---|---|---|
setState | State used by one screen | Rebuilds the whole element subtree below it |
ValueNotifier + ValueListenableBuilder | One changing value shared by a few widgets | Manual wiring once the graph grows |
InheritedWidget / InheritedNotifier | Framework-level sharing (theme, locale) | Verbose to write by hand |
| Provider / Riverpod | App-wide models with dependency injection | Choose one at the start; mixing two is painful |
| Bloc / Cubit | Large apps with complex event flows | More 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.
Pushing routes
// 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 inbuild.gradle.kts, and keep that file out of version control. --split-debug-infoshrinks 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 ipacan be uploaded. - Release performance is very different from debug: measure with
--profileand 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.
Related
Widgets and layout Setup and your first app
Last refreshed 2026-09-18.