Flutter Development
Intro
In Flutter, everything is a widget — compose small StatelessWidgets
and StatefulWidgets into screens, prefer const constructors, and
keep build() methods lightweight. Pick the simplest state-management
solution that fits the complexity, not the most fashionable one.
Overview
Widget architecture
Use StatelessWidget when there's no mutable state and
StatefulWidget when the widget owns animations, controllers, or
form input. Extract sub-widgets into top-level classes (not helper
methods) so they can be const and rebuild independently. Compose by
wrapping, never by extending. Name widgets by purpose
(UserProfileCard), not by position or sequence.
Layout system
Row and Column are the primary axis-based layouts; Expanded and
Flexible distribute space among children. Stack plus Positioned
handles overlap and absolute placement. Use ListView.builder /
GridView.builder for any list that may grow — they only build
visible items. SizedBox for fixed gaps, Spacer for flexible gaps,
Padding/Container for spacing and decoration. Reach for
LayoutBuilder and MediaQuery when the layout depends on the
viewport.
State management
Pick the smallest tool that fits:
- Local widget state →
StatefulWidget+setState - Shared state across a few screens → Provider or Riverpod
- Complex async flows with many events → BLoC
Riverpod is type-safe and avoids BuildContext plumbing; BLoC enforces
event-driven structure for large apps. Avoid putting all state in a
single global provider — split by domain so unrelated screens don't
rebuild together.
Navigation with GoRouter
Define routes declaratively and navigate with context.go(...) to
replace or context.push(...) to stack. Use ShellRoute /
StatefulShellRoute for tabbed layouts that need to preserve state
per tab. Authentication guards live in the redirect callback. Deep
linking works automatically once routes are declared.
Theming
Configure theme on MaterialApp with
ThemeData(useMaterial3: true, colorScheme: ColorScheme.fromSeed(...)).
Read theme values via Theme.of(context) rather than hardcoding font
sizes or colors. Provide a matching darkTheme and let Flutter pick
based on platform brightness.
Networking and data
Use http or dio for REST. Serialize JSON via json_serializable
and build_runner for type-safe parsing. Wrap APIs in repositories
(UserRepository over a raw client). In the UI, render
loading/error/data states explicitly with FutureBuilder or
StreamBuilder — never show a blank screen while waiting.
Testing
Three layers: pure-Dart unit tests with test(), widget tests with
testWidgets() and find.byType(), and full integration tests with
the integration_test package. Use pumpWidget to mount and pump
to advance frames. Mock external boundaries with mocktail or
mockito. Use golden tests (matchesGoldenFile) for visual
regression on critical screens.
Gotchas
Agent-specific failure modes — provider-neutral pause-and-self-check items:
- Nested
Scaffoldwidgets. AScaffoldinside anotherScaffoldproduces double app bars, broken navigation behavior, and unpredictable layout. Each route gets exactly oneScaffoldat the top level. setStatecalled afterdispose(). CallingsetStateon a widget that has been removed from the tree throws aFlutterErrorin debug mode and causes silent bugs in release mode. Guard all async callbacks withif (!mounted) return;before callingsetState.- Forgetting to call
dispose()on controllers, animations, and stream subscriptions. Objects that own platform resources or listeners are not garbage-collected automatically. Not disposing them causes memory leaks and "stream already closed" errors. Overridedispose()and clean up every controller and subscription. - Using helper methods instead of top-level
StatelessWidgetclasses for sub-widgets. A helper method returning aWidgetrebuilds on every parent rebuild regardless of whether its inputs changed. A top-level widget class enablesconstinstantiation and independent rebuild boundaries. - Unbounded
TextorColumninside aRow. ATextwidget without a size constraint in aRowthrows a "RenderFlex overflowed" error. Wrap withExpandedorFlexibleto constrain it, or useSizedBoxwith a fixed width. - Blocking the event loop in a widget's
build()method.build()is called every frame. Any synchronous heavy computation inside it drops frames and causes jank. Move expensive work intoinitState, an async handler, or a compute isolate. - Using
ListView()with all items materialized for a long list.ListView()with achildrenlist builds all items up front. UseListView.builderso only the visible items are built; this scales to thousands of items.
Full reference
Layout widget recipes
// Row — horizontal layout
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [Text('Left'), Text('Right')],
)
// Column — vertical layout
Column(
mainAxisSize: MainAxisSize.min, // shrink-wrap
children: [
Text('Title'),
SizedBox(height: 8),
Text('Subtitle'),
],
)
// Stack — overlapping children
Stack(
children: [
Image.network(url),
Positioned(
bottom: 16, left: 16,
child: Text('Overlay text'),
),
],
)
// Wrap — flow layout with wrapping
Wrap(
spacing: 8, runSpacing: 4,
children: tags.map((t) => Chip(label: Text(t))).toList(),
)
Scrollable widgets
// ListView.builder — efficient for large lists
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(
title: Text(items[index].name),
subtitle: Text(items[index].description),
leading: CircleAvatar(child: Text(items[index].initials)),
trailing: Icon(Icons.chevron_right),
onTap: () => context.push('/items/${items[index].id}'),
),
)
// GridView.builder
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisSpacing: 8, mainAxisSpacing: 8,
),
itemCount: products.length,
itemBuilder: (context, i) => ProductCard(products[i]),
)
// CustomScrollView with slivers
CustomScrollView(
slivers: [
SliverAppBar(floating: true, title: Text('Feed')),
SliverList.builder(
itemCount: posts.length,
itemBuilder: (context, i) => PostCard(posts[i]),
),
],
)
Form input
// TextField
TextField(
controller: _controller,
decoration: InputDecoration(
labelText: 'Email',
hintText: 'you@example.com',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
errorText: _hasError ? 'Invalid email' : null,
),
keyboardType: TextInputType.emailAddress,
onChanged: (value) => setState(() => _email = value),
)
// Form with validation
Form(
key: _formKey,
child: Column(children: [
TextFormField(
validator: (v) => v == null || v.isEmpty ? 'Required' : null,
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) { /* submit */ }
},
child: Text('Submit'),
),
]),
)
Async builders
// FutureBuilder — one-shot async
FutureBuilder<User>(
future: _userFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
if (snapshot.hasError) return Text('Error: ${snapshot.error}');
final user = snapshot.data!;
return Text(user.name);
},
)
// StreamBuilder — continuous updates
StreamBuilder<int>(
stream: _counterStream,
builder: (context, snapshot) {
return Text('Count: ${snapshot.data ?? 0}');
},
)
Common patterns
// Responsive layout
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
return Row(children: [Sidebar(), Expanded(child: Content())]);
}
return Content(); // mobile: no sidebar
},
)
// Pull-to-refresh
RefreshIndicator(
onRefresh: () async => await _loadData(),
child: ListView.builder(...),
)
// Empty state
items.isEmpty
? Center(child: Text('No items yet'))
: ListView.builder(
itemCount: items.length,
itemBuilder: (ctx, i) => ItemTile(items[i]),
)
Material 3 navigation
NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: (i) => setState(() => _currentIndex = i),
destinations: [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
],
)
Platform channels
Prefer existing packages (camera, geolocator, path_provider)
over hand-rolled channels. When you must call native code, use
MethodChannel for one-off calls and EventChannel for streams.
Platform-specific source goes in android/ and ios/. Use
Platform.isIOS / Platform.isAndroid for branching in Dart.
Performance
- Use
constwidgets aggressively — they short-circuit rebuilds ListView.builderfor any non-trivial list- Avoid
setStatehigh in the tree; it rebuilds every descendant - Wrap expensive painters in
RepaintBoundaryto isolate them - Prefer
Opacityonly when necessary — it forces an off-screen layer - Cache network images with
CachedNetworkImage - Profile rebuild counts and frame times in Flutter DevTools
Common mistakes
- Nested
Scaffolds — leads to double app bars and broken navigation setStateafter dispose — guard withmounted, cancel timers and stream subscriptions indispose()- Unbounded children in
Row/Column— wrap withExpandedorFlexible - Forgetting
dispose()— controllers, animation controllers, and stream subscriptions all need explicit disposal - Hardcoded user-facing strings — use
intloreasy_localization