A comprehensive guide for Flutter development, covering fundamentals, architecture, state management, UI/UX, API integration, performance, testing, and best practices.
- StatelessWidget: Immutable, UI does not depend on state changes. Rebuilds only when parent changes.
Example: Static content (splash screen, about page). - StatefulWidget: Mutable via
Stateclass, UI depends on dynamic data. Lifecycle methods:initState,didUpdateWidget,dispose.
Example: Forms, toggles, dynamic content.
-
StatelessWidget:
build(): Called when the widget is inserted into the tree or when its parent rebuilds.
-
StatefulWidget:
-
createState(): Creates the mutable state object. -
initState(): Called once when the state is created. Initialize data, subscribe to streams.- Do: Initialize controllers, listeners, or fetch initial data here.
- Don't: Call
BuildContext-dependent methods (likeshowDialog) directly ininitState.
-
didChangeDependencies(): Called afterinitStateand when dependencies change (e.g.,InheritedWidgetupdates).- Do: Use for actions that depend on inherited widgets (like
Theme.of(context)). - Don't: Perform heavy work here; it may be called multiple times.
- Do: Use for actions that depend on inherited widgets (like
-
build(): Called whenever the widget needs to be rendered.- Do: Keep build methods fast and side-effect free.
- Don't: Start async operations or mutate state directly in
build().
-
didUpdateWidget(): Called when the parent widget changes and needs to update the state.- Do: Compare old and new widget properties to react to changes.
- Don't: Forget to call
super.didUpdateWidget(oldWidget).
-
setState(): Triggers a rebuild by marking the widget as dirty.- Do: Only call when the widget is mounted and you need to update the UI.
- Don't: Call after
dispose()or in synchronous loops.
-
deactivate(): Called when the widget is removed from the tree temporarily.- Do: Pause animations or listeners if needed.
- Don't: Dispose resources here; use
dispose()instead.
-
dispose(): Called when the state object is permanently removed. Clean up controllers, listeners, etc.- Do: Cancel timers, close streams, and dispose controllers.
- Don't: Call
setState()here.
To respond to theme changes (e.g., light/dark mode) in Flutter, use
Theme.of(context)inside your widget’sbuildmethod or listen for changes viaMediaQueryorWidgetsBindingObserver.Example: React to Theme Changes in Build
@override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Container( color: isDark ? Colors.black : Colors.white, child: Text( isDark ? 'Dark Mode' : 'Light Mode', style: theme.textTheme.bodyLarge, ), ); }
Advanced: Listen to Platform Brightness Changes
If you need to react outside of build (e.g., trigger logic), use
WidgetsBindingObserver:class ThemeListenerWidget extends StatefulWidget { @override State<ThemeListenerWidget> createState() => _ThemeListenerWidgetState(); } class _ThemeListenerWidgetState extends State<ThemeListenerWidget> with WidgetsBindingObserver { Brightness? _platformBrightness; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness; } @override void didChangePlatformBrightness() { setState(() { _platformBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness; }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override Widget build(BuildContext context) { return Text('Platform brightness: $_platformBrightness'); } }
Tip: For most UI updates, just use
Theme.of(context)in your build method. UseWidgetsBindingObserveronly for side effects or logic outside the widget tree.Here’s a more illustrative example for each lifecycle callback, showing a simple counter that demonstrates how state and dependencies change, and how the widget responds:
class LifecycleDemo extends StatefulWidget { final String title; const LifecycleDemo({super.key, required this.title}); @override State<LifecycleDemo> createState() => _LifecycleDemoState(); } class _LifecycleDemoState extends State<LifecycleDemo> { late TextEditingController _controller; int _counter = 0; String _dependency = "Initial dependency"; @override void initState() { super.initState(); _controller = TextEditingController(); _counter = 1; // Demonstrate initialization print('initState: counter=$_counter'); } @override void didChangeDependencies() { super.didChangeDependencies(); // Simulate dependency change _dependency = "Dependency at ${DateTime.now()}"; print('didChangeDependencies: $_dependency'); } @override void didUpdateWidget(covariant LifecycleDemo oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.title != widget.title) { print('didUpdateWidget: title changed from "${oldWidget.title}" to "${widget.title}"'); } } @override void deactivate() { print('deactivate: Widget is being removed from the tree temporarily.'); super.deactivate(); } @override void dispose() { print('dispose: Cleaning up controller.'); _controller.dispose(); super.dispose(); } void _incrementCounter() { setState(() { _counter++; print('setState: counter incremented to $_counter'); }); } @override Widget build(BuildContext context) { print('build: counter=$_counter'); return Scaffold( appBar: AppBar(title: Text(widget.title)), body: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ Text('Counter: $_counter'), Text('Dependency: $_dependency'), TextField(controller: _controller), ElevatedButton( onPressed: _incrementCounter, child: const Text('Increment Counter'), ), ], ), ), ); } }
-
Tip: Always pair resource allocation (controllers, streams) in
initStatewith cleanup indispose()to prevent memory leaks. Tip: Always dispose resources indispose()to prevent memory leaks.
-
Purpose: Retain widget identity across rebuilds, ensuring correct state and UI updates.
-
Types & Usage:
Key: Base class, rarely used directly.ValueKey: Use when you have a unique value (like an ID) for each widget, e.g., in lists with stable identifiers.ListView( children: items.map((item) => ListTile( key: ValueKey(item.id), title: Text(item.name), )).toList(), )
ObjectKey: Use when the identity of an object instance matters (e.g., model objects).ListView( children: models.map((model) => MyWidget(key: ObjectKey(model), model: model)).toList(), )
UniqueKey: Generates a unique key every time; use for widgets that must always be treated as new.ListView( children: List.generate(3, (i) => Container(key: UniqueKey())), )
GlobalKey: Allows access to widget state and context across the widget tree; use for forms, scaffold, or when you need to call methods on a widget from outside its build context.final formKey = GlobalKey<FormState>(); Form(key: formKey, child: ...); formKey.currentState?.validate();
-
Navigator Key:
- A special
GlobalKey<NavigatorState>used to control navigation from outside the widget tree (e.g., in services or BLoC).final navigatorKey = GlobalKey<NavigatorState>(); MaterialApp(navigatorKey: navigatorKey, ...); navigatorKey.currentState?.pushNamed('/home');
- A special
-
When Not to Use Keys:
- Avoid keys unless you need to preserve state, reorder items, or access widget state.
- Overusing keys (especially
GlobalKey) can hurt performance and break widget optimizations. - Do not use
UniqueKeyin lists unless you want every item to be treated as new on each rebuild (loses state).
-
Use Cases:
- Optimizing
ListViewperformance (withValueKeyorObjectKey) - Maintaining state during item reordering
ReorderableListView( onReorder: ..., children: items.map((item) => ListTile( key: ValueKey(item.id), title: Text(item.name), )).toList(), )
- Accessing widget state (
GlobalKeyfor forms, scaffold, or navigation)
- Optimizing
Add new functionality to existing classes without subclassing.
extension CapExtension on String {
String capitalize() => this[0].toUpperCase() + substring(1);
}- Benefits: Improves readability, keeps utilities near relevant types.
- Property in
Stateobjects to check if widget is in the tree. - Prevents
setState()afterdispose(). - Best Practice: Check
if (mounted)before callingsetStatein async callbacks.
- Tools:
flutter_localizations,intl,.arbfiles. - Command:
flutter gen-l10n - Pluralization:
Intl.plural(...) - Challenges: Syncing
.arbfiles, key naming, malformed JSON.
- Use:
LayoutBuilder,MediaQuery,FractionallySizedBox,Flexible. - Packages:
flutter_screenutil,responsive_builder,sizer. - Design adaptive layouts for tablets and phones.
MediaQuery rebuilds:
Widgets rebuild only if they read MediaQuery and the data changes (e.g., orientation).
Responsive Grid Example:
LayoutBuilder(
builder: (context, constraints) {
int columns = 1;
if (constraints.maxWidth >= 1200) columns = 4;
else if (constraints.maxWidth >= 800) columns = 3;
else if (constraints.maxWidth >= 600) columns = 2;
return GridView.count(
crossAxisCount: columns,
children: List.generate(20, (index) => Card(child: Text('Item $index'))),
);
},
)ConstrainedBox: Set min/max constraints.
ConstrainedBox(
constraints: BoxConstraints(
minWidth: 100, maxWidth: 200, minHeight: 50, maxHeight: 100,
),
child: YourWidget(),
)SizedBox: Fixes width/height or acts as spacing.
Flexible vs Expanded
Expanded(child: ...)=Flexible(fit: FlexFit.tight, child: ...)Flexible(flex: 2, child: ...)usesFlexFit.looseby default.
Row(
children: [
Flexible(flex: 1, child: Text("Short")),
Flexible(flex: 2, child: Container(width: 50, color: Colors.red)),
],
);- To force expansion:
Flexible(flex: 2, fit: FlexFit.tight, child: ...)orExpanded(flex: 2, child: ...)
FractionallySizedBox: Sizes child relative to parent.
FractionallySizedBox(
widthFactor: 0.8, // 80% of parent width
heightFactor: 0.5, // 50% of parent height
child: YourWidget(),
)Sizer: Sizing widgets as a percentage of the screen.
Text('Hello', style: TextStyle(fontSize: 12.sp));
Container(height: 20.h, width: 50.w);- Limitations: Not pixel-perfect, limited padding/margin scaling.
flutter_screenutil: Advanced scaling based on design size.
ScreenUtilInit(
designSize: Size(360, 690),
builder: () => MyApp(),
)
Container(width: 100.w, height: 50.h);
Text("Hello", style: TextStyle(fontSize: 14.sp));- Benefits: Precise scaling, supports paddings/margins/border radii.
Flutter supports both implicit and explicit animations for smooth, interactive, and visually appealing UI transitions.
- What: Animate property changes automatically when widget properties change.
- Widgets:
AnimatedContainer,AnimatedOpacity,AnimatedAlign,AnimatedPadding,AnimatedPositioned,AnimatedCrossFade,AnimatedDefaultTextStyle,AnimatedPhysicalModel,AnimatedSwitcher, etc.
- Do: Use for simple transitions (size, color, opacity, alignment, padding, etc.).
- Don't: Use for complex, multi-property, or sequenced animations.
Example: Implicit Animation with AnimatedContainer
class AnimatedBox extends StatefulWidget {
@override
State<AnimatedBox> createState() => _AnimatedBoxState();
}
class _AnimatedBoxState extends State<AnimatedBox> {
double _size = 100;
Color _color = Colors.blue;
BorderRadiusGeometry _borderRadius = BorderRadius.circular(8);
void _toggle() {
setState(() {
_size = _size == 100 ? 200 : 100;
_color = _color == Colors.blue ? Colors.red : Colors.blue;
_borderRadius = _borderRadius == BorderRadius.circular(8)
? BorderRadius.circular(32)
: BorderRadius.circular(8);
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _toggle,
child: AnimatedContainer(
width: _size,
height: _size,
decoration: BoxDecoration(
color: _color,
borderRadius: _borderRadius,
),
duration: Duration(milliseconds: 600),
curve: Curves.easeInOutCubic,
child: Center(child: Text('Tap me')),
),
);
}
}Other Implicit Animation Examples:
- AnimatedOpacity
AnimatedOpacity( opacity: isVisible ? 1.0 : 0.0, duration: Duration(milliseconds: 400), child: YourWidget(), )
- AnimatedSwitcher
AnimatedSwitcher( duration: Duration(milliseconds: 300), child: isFirst ? Icon(Icons.star) : Icon(Icons.star_border), transitionBuilder: (child, animation) => ScaleTransition(scale: animation, child: child), )
- What: Full control over animation timing, values, and sequences.
- Widgets/Classes:
AnimationController,Tween,CurvedAnimation,AnimatedBuilder,AnimatedWidget,Animation,TweenSequence,Interval,ReverseAnimation, etc.
- Do: Use for complex, coordinated, or custom animations (multi-property, staggered, chained, physics-based).
- Don't: Forget to dispose your
AnimationControllerto avoid memory leaks.
Example: Explicit Animation with AnimationController and AnimatedBuilder
class SpinningBox extends StatefulWidget {
@override
State<SpinningBox> createState() => _SpinningBoxState();
}
class _SpinningBoxState extends State<SpinningBox> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _rotation;
late Animation<Color?> _color;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this,
)..repeat(reverse: true);
_rotation = Tween<double>(begin: 0, end: 2 * 3.1416).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_color = ColorTween(begin: Colors.blue, end: Colors.orange).animate(_controller);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _rotation.value,
child: Container(
width: 120,
height: 120,
color: _color.value,
child: Center(child: Text('Spin')),
),
);
},
);
}
}Advanced: Staggered Animation Example
class StaggeredDemo extends StatefulWidget {
@override
State<StaggeredDemo> createState() => _StaggeredDemoState();
}
class _StaggeredDemoState extends State<StaggeredDemo> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _width;
late Animation<double> _height;
late Animation<Color?> _color;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: Duration(seconds: 2), vsync: this);
_width = Tween<double>(begin: 50, end: 200).animate(
CurvedAnimation(parent: _controller, curve: Interval(0.0, 0.5, curve: Curves.ease)),
);
_height = Tween<double>(begin: 50, end: 200).animate(
CurvedAnimation(parent: _controller, curve: Interval(0.5, 1.0, curve: Curves.ease)),
);
_color = ColorTween(begin: Colors.green, end: Colors.purple).animate(
CurvedAnimation(parent: _controller, curve: Interval(0.0, 1.0)),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
width: _width.value,
height: _height.value,
color: _color.value,
);
},
);
}
}- Route/Page Transitions:
PageRouteBuilder,FadeTransition,SlideTransition,ScaleTransition,RotationTransition,SizeTransition,Hero.
- Hero Animations:
- Animate shared elements between routes.
Hero( tag: 'profile-pic', child: CircleAvatar(radius: 40, backgroundImage: ...), )
- Custom Route Transition Example:
Navigator.push( context, PageRouteBuilder( pageBuilder: (_, __, ) => NextPage(), transitionsBuilder: (_, animation, __, child) => FadeTransition(opacity: animation, child: child), ), );
- Curves: Use
Curvesfor non-linear motion (e.g.,Curves.easeInOutBack,Curves.bounceOut). - TweenSequence: Chain multiple tweens for complex effects.
- TickerMode: Enable/disable tickers in widget subtrees for performance.
- DevTools Timeline: Profile animation performance and frame rendering.
- AnimatedList/AnimatedGrid: Animate item insertions/removals in lists/grids.
Do:
- Use implicit widgets for simple property changes.
- Use explicit animations for advanced, coordinated, or custom effects.
- Always dispose
AnimationControllerindispose(). - Use
Herofor shared element transitions. - Profile animations with DevTools for jank or dropped frames.
- Use
AnimatedSwitcherfor smooth widget replacements.
Don't:
- Animate too many widgets at once; it can hurt performance.
- Block the main thread with heavy computations during animations.
- Forget to use
TickerProvider(withSingleTickerProviderStateMixinorTickerProviderStateMixin) for controllers.
Tip: For advanced choreography, use
AnimationControllerwithAnimatedBuilder,AnimatedWidget, orTweenSequencefor maximum flexibility. For physics-based motion, exploreSpringSimulation,BouncingScrollSimulation, or theflutter_sequence_animationpackage.
- Access Token: Short-lived, sent with every request, stored in memory/secure storage.
- Refresh Token: Long-lived, used to get new access tokens, stored securely.
- Separation: Improves security, allows session rotation, scalable backend.
Login Endpoint
- Accepts credentials, issues tokens, limits attempts, hashes passwords, logs IP/user agent.
Refresh Endpoint
- Accepts only valid refresh tokens, rotates on use, blocks reuse, can bind to device/IP, expires after set period.
Token Structure
| Property | Access Token | Refresh Token |
|---|---|---|
| Format | JWT or opaque | Opaque (preferred) |
| Storage | Memory/secure | Secure only |
| Contains | Claims | UUID only |
| Signed | Yes | Preferably yes |
Base Configuration
final dio = Dio(BaseOptions(
baseUrl: 'https://api.example.com/v1',
connectTimeout: Duration(seconds: 10),
receiveTimeout: Duration(seconds: 15),
headers: {'Accept': 'application/json', 'Content-Type': 'application/json'},
));JWT Auth Interceptor
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
final token = yourTokenStorage.getAccessToken();
if (token != null) options.headers['Authorization'] = 'Bearer $token';
return handler.next(options);
},
));Centralized Error Handling
dio.interceptors.add(InterceptorsWrapper(
onError: (DioError err, handler) async {
if (err.response?.statusCode == 401) {
await storage.clear();
navigatorKey.currentState?.pushNamedAndRemoveUntil('/login', (r) => false);
}
return handler.next(err);
},
));Retry on Failure
dio.interceptors.add(
RetryInterceptor(
dio: dio,
retries: 3,
retryDelays: [Duration(seconds: 1), Duration(seconds: 2)],
retryEvaluator: (error) => error.type != DioExceptionType.cancel,
),
);File Upload
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(filePath, filename: 'image.jpg'),
});
final response = await dio.post('/upload', data: formData);Caching
options.headers['Cache-Control'] = 'public, max-age=3600';Request Cancellation
final cancelToken = CancelToken();
dio.get('/longrequest', cancelToken: cancelToken);
cancelToken.cancel("User navigated away");Logging (Debug Only)
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));Sample GET with Query Params
final response = await dio.get('/users', queryParameters: {'page': 1, 'limit': 20});Timeout Settings
dio.get('/slow', options: Options(receiveTimeout: Duration(seconds: 5)));- Prefer
constconstructors for widgets that do not change, to reduce rebuilds. - Use
RepaintBoundaryto isolate parts of the widget tree that update frequently, minimizing unnecessary repaints. - Debounce or throttle user input (e.g., search fields) to avoid excessive rebuilds or API calls.
Example: Using const and RepaintBoundary
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: const Text('Static content'),
);
}
}- Minimize heavy work in
initState. For expensive initialization, useFutureBuilderor deferred loading. - Optimize asset sizes and use compressed images.
-
Feature-First vs. Layer-First
- Feature-First: Organize code by features (e.g.,
/features/auth/,/features/profile/). Improves modularity, makes onboarding easier, and supports parallel development.- Do: Use feature-first for large or growing apps.
- Do: Keep feature folders self-contained (UI, logic, models).
- Don't: Mix unrelated features in the same folder.
- Layer-First: Organize by technical layers (e.g.,
/models/,/views/,/services/). Can lead to cross-feature dependencies and harder navigation in large codebases.- Do: Use for small apps or when layers are minimal.
- Don't: Let layers become dumping grounds for unrelated code.
- Recommendation: Prefer feature-first for large apps; combine with layer separation inside each feature.
- Feature-First: Organize code by features (e.g.,
-
Modularization
- Split code into independent modules/packages (e.g.,
core,shared,feature_x).- Do: Extract shared UI components, utilities, and services into their own modules.
- Do: Use Dart/Flutter packages for isolation and reusability.
- Don't: Over-modularize prematurely; start with clear boundaries.
- Don't: Create circular dependencies between modules.
- Split code into independent modules/packages (e.g.,
-
Package Splitting
- Move reusable features or libraries into separate packages (local or published).
- Do: Use
pathorgitdependencies inpubspec.yamlfor local development. - Do: Write independent tests for each package.
- Don't: Duplicate code across packages.
- Don't: Expose internal implementation details in package APIs.
- Do: Use
- Move reusable features or libraries into separate packages (local or published).
-
Monorepo Strategies
- Store all app packages and modules in a single repository.
- Do: Use tools like Melos for managing packages, scripts, and versioning.
- Do: Set up shared CI/CD pipelines for all packages.
- Don't: Ignore dependency version mismatches between packages.
- Don't: Commit breaking changes without coordinating across affected packages.
- Benefits: Easier refactoring, atomic commits across packages, shared CI/CD.
- Challenges: Requires tooling for dependency management and build orchestration.
- Store all app packages and modules in a single repository.
Tip: Start with a scalable structure early—even small apps can grow quickly. Use clear naming conventions and documentation for each module or package.
Use for low-level dependency injection and propagating data down the widget tree. Most higher-level solutions (like Provider, Riverpod) are built on top of it.
Example: Custom InheritedWidget
class CounterData extends InheritedWidget {
final int counter;
final Widget child;
CounterData({required this.counter, required this.child}) : super(child: child);
static CounterData? of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<CounterData>();
@override
bool updateShouldNotify(CounterData oldWidget) => counter != oldWidget.counter;
}
// Usage in widget tree:
CounterData(
counter: 5,
child: MyHomePage(),
);Do:
- Use for propagating immutable data or callbacks.
- Use
of(context)pattern for access.
Don't:
-
Use for complex or deeply nested state; prefer higher-level solutions.
-
Common Patterns:
- MVVM: Use with Provider or Riverpod for separating UI (View), logic (ViewModel), and data.
- Clean Architecture: Separate domain, data, and presentation layers for testability and scalability.
Example: Provider (MVVM)
class CounterViewModel extends ChangeNotifier { int _count = 0; int get count => _count; void increment() { _count++; notifyListeners(); } } // In main.dart ChangeNotifierProvider( create: (_) => CounterViewModel(), child: MyApp(), ); // In widget Consumer<CounterViewModel>( builder: (context, vm, child) => Text('${vm.count}'), );
Do:
- Use Provider/Riverpod for scalable, testable state management.
- Keep business logic out of widgets.
Don't:
- Mutate state directly in widgets.
- Overuse global state; prefer scoped providers.
-
Modularization:
- Organize code by features or layers.
- Extract shared UI components, utilities, and services into separate packages or modules.
Do:
- Use feature-first structure for large apps.
- Keep shared code in
coreorsharedpackages.
Don't:
- Mix unrelated features in the same folder.
- Duplicate code across modules.
-
Navigator 1.0 (Imperative):
- Use
Navigator.push,Navigator.popfor simple navigation. - Good for small apps or simple flows.
Example:
Navigator.push( context, MaterialPageRoute(builder: (context) => DetailsPage()), ); Navigator.pop(context);
Do:
- Use for straightforward navigation stacks.
Don't:
- Use for deep linking or complex flows.
- Use
-
Navigator 2.0 (Declarative):
- Supports deep linking, browser history, and complex navigation.
- Use packages like
go_routerorauto_routefor easier setup.
Example: go_router Setup
final router = GoRouter( routes: [ GoRoute( path: '/home', builder: (context, state) => HomePage(), ), GoRoute( path: '/details/:id', builder: (context, state) { final id = state.params['id']; return DetailsPage(id: id); }, ), ], errorBuilder: (context, state) => NotFoundPage(), redirect: (context, state) { // Example: Redirect unauthenticated users final loggedIn = checkAuth(); if (!loggedIn && state.location != '/login') return '/login'; return null; }, ); // In MaterialApp.router MaterialApp.router( routerConfig: router, );
Do:
- Use for apps requiring deep linking, web support, or complex navigation.
- Handle unknown routes and errors gracefully.
- Use route guards/redirects for authentication flows.
Don't:
- Mix imperative and declarative navigation in the same flow.
- Forget to test navigation on web and mobile.
Tip: For most apps, start with Navigator 1.0 for simplicity. Migrate to Navigator 2.0 or a package like
go_routeras navigation needs grow.
- Use
Futurefor single async results,Streamfor multiple events. - Bind async data to UI with
FutureBuilderandStreamBuilder.
Example: FutureBuilder
FutureBuilder<int>(
future: fetchValue(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return Text('Value: ${snapshot.data}');
},
)- What: Represents a computation that completes later with a value or error.
- Usage: Use
async/awaitfor readable, sequential async code. - Do:
- Always handle errors with
try/catchor.catchError. - Use
awaitonly insideasyncfunctions. - Chain async calls with
awaitfor clarity.
- Always handle errors with
- Don't:
- Block the UI thread with synchronous heavy work.
- Forget to handle exceptions—unhandled errors can crash the app.
Example:
Future<void> loadData() async {
try {
final data = await fetchData();
// Use data
} catch (e) {
// Handle error
}
}- What: Delivers a sequence of async events (data, error, done).
- Usage: Use for sockets, user input, or continuous data.
- Do:
- Use
StreamBuilderto bind streams to widgets. - Cancel subscriptions in
dispose()to prevent memory leaks. - Use
async*andyieldfor custom stream generation.
- Use
- Don't:
- Forget to close streams or cancel subscriptions.
- Use streams for one-off events—prefer
Futureinstead.
Example: StreamBuilder
StreamBuilder<int>(
stream: counterStream(),
builder: (context, snapshot) {
if (!snapshot.hasData) return CircularProgressIndicator();
return Text('Count: ${snapshot.data}');
},
)- What: Separate memory/thread for heavy computation.
- Usage: Use
compute()for simple background tasks, orIsolatefor advanced use. - Do:
- Use for CPU-intensive work (parsing, encoding, image processing).
- Pass only simple, serializable data to isolates.
- Don't:
- Use isolates for I/O-bound tasks (network, file)—Dart's async model handles these efficiently.
- Share complex objects or open connections between isolates.
Do:
- Use
mountedcheck before callingsetStatein async callbacks. - Dispose controllers, subscriptions, and streams in
dispose(). - Use
Future.microtaskorWidgetsBinding.instance.addPostFrameCallbackfor post-build async work.
Don't:
- Call
setStateafter a widget is disposed. - Start async work in
build()—useinitStateor callbacks. - Ignore unawaited futures; use
unawaited()frompackage:pedanticif intentional.
Tip: Prefer
FutureBuilder/StreamBuilderfor simple async UI. For complex flows, consider state management solutions (Provider, Riverpod, Bloc) to handle async logic and state updates cleanly.
- Use
MethodChannelto call platform-specific code (Android/iOS) from Dart. - Use
PlatformViewto embed native UI components. - Use FFI (Foreign Function Interface) for calling native C/C++ code.
- Handle permissions and platform-specific testing carefully.
Example: MethodChannel
static const platform = MethodChannel('samples.flutter.dev/battery');
Future<void> getBatteryLevel() async {
final int batteryLevel = await platform.invokeMethod('getBatteryLevel');
}- Wrap async code in
try/catchblocks. - Use types like
EitherorResultfor functional error handling. - Show errors to users with
SnackBar, dialogs, or dedicated error screens.
Example: try/catch
try {
final data = await fetchData();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
}- Use GetIt for service locator pattern, Provider or Riverpod for scoped dependency injection.
- Prefer passing dependencies via constructors for testability.
- Use
MultiProviderorProviderScopefor grouping dependencies.
Example: GetIt registration
final getIt = GetIt.instance;
getIt.registerSingleton<ApiService>(ApiServiceImpl());- Do: Centralize theme, use design tokens, and enforce consistency.
- Don't: Override theme locally unless necessary.
ThemeData(
primaryColor: AppColors.primary,
textTheme: AppTextStyles.all,
)A robust error reporting framework helps you detect, log, and respond to errors in production and development. Here’s a unified approach for Flutter:
Set up global error handlers to catch uncaught exceptions in both Flutter and Dart zones, and centralize reporting via a singleton service. Integrate with third-party services (Sentry, Crashlytics), local logging, and user feedback.
Naming:
ErrorReporter is a clear, descriptive name for a centralized error reporting service. Alternatives: AppErrorReporter, ErrorHandler, or ErrorService. Choose based on your project’s naming conventions.
Unified Example:
import 'dart:developer' as developer;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
// Uncomment if using Sentry or Crashlytics
// import 'package:sentry_flutter/sentry_flutter.dart';
// import 'package:firebase_crashlytics/firebase_crashlytics.dart';
class ErrorReporter {
ErrorReporter._();
static final instance = ErrorReporter._();
// Integrate with Sentry
Future<void> reportToSentry(Object error, StackTrace stack) async {
// await Sentry.captureException(error, stackTrace: stack);
}
// Integrate with Crashlytics
Future<void> reportToCrashlytics(Object error, StackTrace stack) async {
// await FirebaseCrashlytics.instance.recordError(error, stack);
}
// Log to console/DevTools
void logError(Object error, StackTrace stack, {String message = ''}) {
developer.log(
message.isEmpty ? 'Unhandled error' : message,
error: error,
stackTrace: stack,
level: 1000,
name: 'app.error',
);
}
// Log to local file
Future<void> logErrorToFile(Object error, StackTrace stack, {String message = ''}) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/error_logs.txt');
final logEntry = '${DateTime.now()}: $message\n$error\n$stack\n\n';
await file.writeAsString(logEntry, mode: FileMode.append);
}
// Unified error reporting
Future<void> report(Object error, StackTrace stack, {String message = ''}) async {
logError(error, stack, message: message);
await logErrorToFile(error, stack, message: message);
// await reportToSentry(error, stack);
// await reportToCrashlytics(error, stack);
}
// Share logs with developers
Future<void> shareLogs() async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/error_logs.txt');
if (await file.exists()) {
await Share.shareXFiles([XFile(file.path)], text: 'App error logs');
}
}
}
void main() {
FlutterError.onError = (FlutterErrorDetails details) {
FlutterError.presentError(details);
ErrorReporter.instance.report(details.exception, details.stack ?? StackTrace.current, message: 'FlutterError');
};
runZonedGuarded(() {
runApp(MyApp());
}, (error, stack) {
ErrorReporter.instance.report(error, stack, message: 'ZoneError');
});
}
// Friendly error screen
ErrorWidget.builder = (FlutterErrorDetails details) {
return Center(child: Text('Something went wrong.'));
};Best Practices:
- Use
ErrorReporterfor all error capture, logging, and reporting. - Integrate with third-party services as needed.
- Store logs locally and provide a UI for users to share logs.
- Show user-friendly error screens instead of crashes.
Flutter provides a rich set of profiling tools to help you analyze, debug, and optimize your app’s performance. The primary tool is Flutter DevTools, which offers multiple tabs for different profiling aspects.
How to launch:
- Run your app in debug or profile mode.
- Execute
flutter pub global activate devtools(if not already). - Start DevTools:
- From terminal:
flutter pub global run devtools - Or via IDE (VS Code/Android Studio): Click the “Open DevTools” button.
- From terminal:
Connect:
- Use the link printed in the console, or connect via your IDE.
- Timeline View:
- Visualizes frame rendering, UI thread, GPU thread, and async events.
- Shows frame rendering times (target: <16ms for 60fps).
- Use: Identify jank, slow frames, and expensive build/layout/paint phases.
- Frame Analysis:
- Inspect each frame for build, layout, paint, and raster times.
- Highlight: Frames exceeding the budget are marked in red.
- CPU Profiler:
- Record and analyze CPU samples for Dart code.
- Use: Find slow functions, heavy computations, and bottlenecks.
- User Flows:
- Record custom user journeys for targeted profiling.
How to use:
- Click “Record” to capture timeline events.
- Interact with your app to reproduce issues.
- Stop recording and analyze the timeline.
- Heap Snapshots:
- Take snapshots of memory usage at any point.
- Use: Compare before/after states to detect leaks.
- Memory Allocation:
- Track live objects, allocations, and garbage collection.
- Dart Objects:
- Inspect instances by type, retainers, and references.
- Leaks:
- Identify objects that are not being disposed.
How to use:
- Take snapshots before and after navigation or heavy operations.
- Look for unexpected growth in object counts.
- Call Tree & Bottom Up:
- Visualize CPU time spent in functions.
- Use: Identify hot spots and optimize slow code.
- Profile Recording:
- Start/stop CPU profiling for specific actions.
- HTTP Traffic:
- View all HTTP requests/responses, headers, payloads, and timings.
- WebSocket:
- Inspect WebSocket messages.
- Use:
- Debug slow or failed network calls.
- Analyze payload sizes and response times.
- Widget Tree:
- Visualize the widget hierarchy in real time.
- Layout Explorer:
- Inspect constraints, sizes, paddings, and flex layouts.
- Highlight Repaints:
- Enable “Repaint Rainbow” to see which widgets repaint.
- Use:
- Debug layout issues, excessive rebuilds, and widget structure.
- Console Output:
- View logs, errors, and debug prints.
- Filtering:
- Filter logs by level or content.
- Size Analysis:
- Analyze the size of your app’s release build.
- Treemap:
- Visualize which packages, assets, or code contribute most to the binary size.
- Use:
- Identify and reduce bloat.
- State Management:
- Inspect provider/bloc/riverpod state, dependencies, and updates.
- Profile Mode:
- Run
flutter run --profilefor near-release performance.
- Run
- Release Mode:
- For final performance, use
flutter run --release.
- For final performance, use
- Widget Rebuild Profiling:
- Use
debugPrintRebuildDirtyWidgets = true;to log widget rebuilds.
- Use
- Repaint Rainbow:
- Enable in DevTools Inspector to visualize repaints.
- Performance Overlay:
- Add
showPerformanceOverlay: trueinMaterialAppfor in-app frame stats.
- Add
- Tracing:
- Use
TimelineAPI for custom event tracing.
- Use
- Do:
- Profile on real devices, not just emulators.
- Use DevTools regularly during development.
- Investigate slow frames, memory leaks, and large app sizes.
- Don't:
- Rely solely on debug mode for performance metrics.
- Ignore warnings about jank or memory growth.
Tip: Always profile in both profile and release modes, as debug mode does not reflect real-world performance.
- Do: Store secrets in secure storage, use HTTPS, and obfuscate builds.
- Obfuscate builds: Use Flutter's obfuscation to make reverse engineering harder.
Run with:flutter build apk --obfuscate --split-debug-info=/<project-directory>/debug-info flutter build appbundle --obfuscate --split-debug-info=/<project-directory>/debug-info
--obfuscate: Enables Dart code obfuscation.--split-debug-info: Stores symbol files for stack trace deobfuscation.- Keep the debug info files safe for crash analysis.
- Obfuscate builds: Use Flutter's obfuscation to make reverse engineering harder.
- Don't: Log sensitive data or hardcode secrets.
Use secure storage (e.g., FlutterSecureStorage) in your data layer or infrastructure layer for handling sensitive data such as tokens, credentials, or secrets. Access it via a repository or service class, not directly from UI widgets. This separation improves testability, security, and maintainability.
Example: Secure Key-Value Storage Service (Singleton, Multi-Key Support)
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureStorageService {
SecureStorageService._();
static final SecureStorageService instance = SecureStorageService._();
final FlutterSecureStorage _storage = const FlutterSecureStorage();
// Save a value for a given key
Future<void> save(String key, String value) async {
await _storage.write(key: key, value: value);
}
// Save multiple key-value pairs
Future<void> saveAll(Map<String, String> values) async {
for (final entry in values.entries) {
await _storage.write(key: entry.key, value: entry.value);
}
}
// Read a value for a given key
Future<String?> read(String key) async {
return await _storage.read(key: key);
}
// Read multiple values by keys
Future<Map<String, String?>> readAllByKeys(List<String> keys) async {
final result = <String, String?>{};
for (final key in keys) {
result[key] = await _storage.read(key: key);
}
return result;
}
// Delete a value for a given key
Future<void> delete(String key) async {
await _storage.delete(key: key);
}
// Delete multiple keys
Future<void> deleteAllByKeys(List<String> keys) async {
for (final key in keys) {
await _storage.delete(key: key);
}
}
// Read all key-value pairs
Future<Map<String, String>> readAll() async {
return await _storage.readAll();
}
// Delete all keys
Future<void> deleteAll() async {
await _storage.deleteAll();
}
}- Use this singleton service in your authentication repository, API client, or anywhere sensitive key-value storage is needed.
- Supports saving, reading, and deleting multiple key-value pairs.
- Inject or access via
SecureStorageService.instance(e.g., with Provider, GetIt, or directly). - Avoid direct storage access in presentation/UI code.
- Store and retrieve multiple keys (e.g., access token, refresh token, userId) as needed.
Automate repetitive code using tools like build_runner, Freezed, and JsonSerializable.
-
Add dependencies in your
pubspec.yaml:dependencies: freezed_annotation: ^2.0.0 json_annotation: ^4.0.0 dev_dependencies: build_runner: ^2.0.0 freezed: ^2.0.0 json_serializable: ^6.0.0
-
Annotate your models:
import 'package:freezed_annotation/freezed_annotation.dart'; part 'user.freezed.dart'; part 'user.g.dart'; @freezed class User with _$User { const factory User({required String id, required String name}) = _User; factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json); }
-
Run code generation:
- One-time/manual:
flutter pub run build_runner build --delete-conflicting-outputs
- Watch mode (auto-regenerates on file changes):
flutter pub run build_runner watch --delete-conflicting-outputs
- One-time/manual:
-
Automate in CI/CD:
- Add the build_runner command to your CI pipeline (e.g., GitHub Actions, Codemagic) to ensure generated files are always up to date.
-
Best Practices:
- Always run code generation after changing annotated files.
- Use watch mode during development for instant updates.
- Commit generated files if required by your team/project.
- Add a pre-commit hook (e.g., with pre-commit or a git hook script) to run code generation before each commit.
Tip: Use
build_runner watchduring development to automatically regenerate code as you edit your models, ensuring your generated files are always in sync with your source code.
- Do: Use for models, DI, and unions.
- Don't: Edit generated files manually.
- Use
.arbfiles,flutter_localizations, andintlfor localization. - For accessibility, use the
Semanticswidget, label images, avoid hardcoded sizes, and test with screen readers.
Example: Semantics
Semantics(
label: 'Play button',
child: Icon(Icons.play_arrow),
)- Write unit, widget, and integration tests.
- Use
flutter_test,mockito,bloc_test, andintegration_testpackages. - Set up CI/CD with GitHub Actions or Codemagic.
- Enforce static analysis with
flutter analyzeand custom lint rules.
Example: Widget test
testWidgets('Counter increments', (tester) async {
await tester.pumpWidget(MyApp());
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
expect(find.text('1'), findsOneWidget);
});