Widget Best Practice #
If the previous article discussed what not to do, this article discusses what should be done. Flutter widget best practices aren’t just about performance — they cover readability, maintainability, testability, and team collaboration. These practices are distilled from the official Flutter documentation, Material team guidelines, and experience building Flutter apps at production scale.
1. Design Widgets Like Pure Functions #
Good widgets behave like pure functions: output (display) only depends on input (props). No side effects, no hidden state, no dependencies on global singletons.
// A widget depending on a global singleton -- hard to test
class PriceWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final price = ProductService.instance.latestPrice; // global singleton!
return Text('Rp $price');
}
}
// A widget as a pure function -- easy to test, easy to reuse
class PriceWidget extends StatelessWidget {
final double price;
const PriceWidget({super.key, required this.price});
@override
Widget build(BuildContext context) {
return Text('Rp ${price.toStringAsFixed(0)}');
}
}
// Testing is very easy -- no mocks needed at all
testWidgets('PriceWidget displays the price with correct formatting', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: PriceWidget(price: 150000)),
);
expect(find.text('Rp 150000'), findsOneWidget);
});
2. One Widget, One Responsibility #
Each widget should do one thing well. Widgets that do many things are hard to test, hard to reuse, and hard to optimize.
// TOO MANY responsibilities in one widget
class ComplexProductCard extends StatefulWidget {
final Product product;
const ComplexProductCard({super.key, required this.product});
@override
State<ComplexProductCard> createState() => _ComplexProductCardState();
}
class _ComplexProductCardState extends State<ComplexProductCard> {
bool _isFavorite = false;
int _quantity = 0;
bool _isLoading = false;
// ... fetch data, format prices, animations, navigation -- all here!
}
// BETTER: split by responsibility
class ProductCard extends StatelessWidget {
// Responsibility: display product data
final Product product;
final bool isFavorite;
final int cartQuantity;
final VoidCallback onFavoriteToggle;
final VoidCallback onAddToCart;
const ProductCard({
super.key,
required this.product,
required this.isFavorite,
required this.cartQuantity,
required this.onFavoriteToggle,
required this.onAddToCart,
});
@override
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
ProductImage(url: product.imageUrl), // 1 responsibility
ProductInfo(product: product), // 1 responsibility
ProductActionButtons( // 1 responsibility
isFavorite: isFavorite,
quantity: cartQuantity,
onFavoriteToggle: onFavoriteToggle,
onAddToCart: onAddToCart,
),
],
),
);
}
}
3. Always Accept and Forward Keys #
Widgets that can be used in lists or can change position in the tree must accept a Key through the constructor and forward it to super:
// NOT GOOD: doesn't accept a key
class ItemCard extends StatelessWidget {
final Item item;
const ItemCard({required this.item}); // no key!
@override
Widget build(BuildContext context) => Card(child: Text(item.name));
}
// CORRECT: always accept and forward the key
class ItemCard extends StatelessWidget {
final Item item;
const ItemCard({
super.key, // accept the key from outside
required this.item,
});
@override
Widget build(BuildContext context) => Card(child: Text(item.name));
}
// Usage in a list -- Flutter can track item identity
ListView.builder(
itemBuilder: (context, index) => ItemCard(
key: ValueKey(items[index].id), // unique identity based on data
item: items[index],
),
)
4. Use ValueListenableBuilder for Small, Frequently Changing State #
For small state that changes very often (counters, toggles, input values), ValueListenableBuilder is much more efficient than setState because it only rebuilds a small part of the widget tree. The rebuild flow difference between these two methods can be visualized as follows:
flowchart TD
subgraph Skenario_setState["With setState()"]
SetStateCall["setState() Called"] --> RebuildParent["Rebuild Parent Widget"]
RebuildParent --> RebuildStatic1["Rebuild Static Header (Wasteful)"]
RebuildParent --> RebuildStatic2["Rebuild Static Content (Wasteful)"]
RebuildParent --> RebuildValue1["Rebuild Value Text"]
end
subgraph Skenario_ValueListenable["With ValueListenableBuilder"]
ValueChange["ValueNotifier Updated"] --> RebuildBuilder["Only Rebuild Builder Callback"]
RebuildBuilder --> RebuildValue2["Rebuild Value Text"]
RebuildStatic3["Static Header (Skipped)"] -.->|Safe| NoRebuild1["NOT Rebuilt"]
RebuildStatic4["Static Content (Skipped)"] -.->|Safe| NoRebuild2["NOT Rebuilt"]
end// With setState: rebuilds the entire widget containing the counter
class _ScreenState extends State<Screen> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveStaticHeader(), // unnecessary rebuild!
const ExpensiveStaticContent(), // unnecessary rebuild!
Text('$_count'), // only this needs a rebuild
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('+'),
),
],
);
}
}
// With ValueListenableBuilder: only the Text rebuilds
class Screen extends StatelessWidget {
final _count = ValueNotifier<int>(0);
Screen({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveStaticHeader(), // never rebuilds
const ExpensiveStaticContent(), // never rebuilds
ValueListenableBuilder<int>(
valueListenable: _count,
builder: (context, value, _) => Text('$value'), // only this
),
ElevatedButton(
onPressed: () => _count.value++, // no setState needed!
child: const Text('+'),
),
],
);
}
}
5. RepaintBoundary — Rendering Isolation #
RepaintBoundary makes Flutter create a separate rendering layer for the subtree inside it. Changes inside the boundary don’t cause areas outside the boundary to repaint, and vice versa.
// Use RepaintBoundary for widgets that:
// 1. Repaint often (animations, clocks, real-time graphics)
// 2. Are inside a scrolled list
// 3. Are complex and rarely change
// Example 1: animation inside static content
Column(
children: [
const StaticContent(),
RepaintBoundary( // the animation doesn't cause StaticContent to repaint
child: AnimatedLogo(),
),
const StaticFooter(),
],
)
// Example 2: items inside a scrolled list
ListView.builder(
itemBuilder: (context, index) => RepaintBoundary(
child: ProductCard(product: products[index]),
),
)
// Example 3: complex widgets that rarely change but sit next to
// frequently changing widgets
Row(
children: [
RepaintBoundary( // the chart isn't repainted when the right panel changes
child: const ComplexChart(),
),
Expanded(child: DynamicPanel()),
],
)
Don’t overuse RepaintBoundary. Each boundary needs memory for its offscreen buffer. Use it only where profiling proves a rendering bottleneck, or in situations that will clearly be problematic (animations next to complex static content).
6. Avoid Excessive Nesting — Flatten the Widget Tree #
Deeply nested widget trees make code harder to read and add layout overhead. Each layer adds traversal time for event hit-testing and layout.
// TOO DEEP: unnecessary nesting
Container(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Container(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text('Hello'),
),
),
),
),
)
// BETTER: combine properties in one widget
Center(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 16),
child: const Text('Hello'),
),
)
// Or use properties directly on the widget
Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: const Text('Hello'),
)
Avoid using unnecessary parent layouts like Container, Padding, or Center if they don’t contribute anything meaningful to the UI.
7. Split Widgets Precisely Using extract widget Refactoring #
Break widgets at the right boundaries — not too small (granularity overhead), not too big (losing isolation benefits):
// TOO GRANULAR: too many meaningless small widgets
class SingleIcon extends StatelessWidget {
@override
Widget build(BuildContext context) => const Icon(Icons.star);
}
class SingleText extends StatelessWidget {
final String text;
const SingleText(this.text);
@override
Widget build(BuildContext context) => Text(text);
}
// JUST RIGHT: split at meaningful semantic boundaries
class ProfileHeader extends StatelessWidget {
final User user;
const ProfileHeader({super.key, required this.user});
// the whole profile header part -- meaningful as a unit
}
class ProfileStatistics extends StatelessWidget {
final UserStats stats;
const ProfileStatistics({super.key, required this.stats});
// the whole statistics part -- meaningful as a unit
}
// Guidelines for when to extract:
// ✓ Parts that can be const independently
// ✓ Parts that will be reused elsewhere
// ✓ Parts that have their own state or logic
// ✓ Parts that are too long (>30-40 lines of build)
// ✗ A single widget that only contains one other widget without logic
// ✗ Widgets used only once with no isolation benefit
8. Precompute Data Before It Enters the Widget #
Processed data should be sent to widgets in a ready-to-display form. Widgets shouldn’t need to do heavy data transformations:
// The widget receives raw data and processes it itself -- not ideal
class ReportWidget extends StatelessWidget {
final List<Transaction> transactions; // raw data
const ReportWidget({super.key, required this.transactions});
@override
Widget build(BuildContext context) {
// This happens on every rebuild!
final totalIncome = transactions
.where((t) => t.type == 'income')
.fold(0.0, (sum, t) => sum + t.amount);
final totalExpense = transactions
.where((t) => t.type == 'expense')
.fold(0.0, (sum, t) => sum + t.amount);
final perSource = _groupBySource(transactions); // O(n)
return ReportView(
income: totalIncome,
expense: totalExpense,
perSource: perSource,
);
}
}
// The widget receives an already-processed ViewModel -- ideal
class ReportViewModel {
final double totalIncome;
final double totalExpense;
final Map<String, double> perSource;
const ReportViewModel({
required this.totalIncome,
required this.totalExpense,
required this.perSource,
});
// Build from raw data -- done once in the state layer
factory ReportViewModel.from(List<Transaction> transactions) {
return ReportViewModel(
totalIncome: transactions
.where((t) => t.type == 'income')
.fold(0.0, (sum, t) => sum + t.amount),
totalExpense: transactions
.where((t) => t.type == 'expense')
.fold(0.0, (sum, t) => sum + t.amount),
perSource: _groupBySource(transactions),
);
}
}
class ReportWidget extends StatelessWidget {
final ReportViewModel viewModel; // data is ready to display
const ReportWidget({super.key, required this.viewModel});
@override
Widget build(BuildContext context) {
// build() is lightweight -- only assembling widgets
return ReportView(viewModel: viewModel);
}
}
9. Use Profiling — Measure Before Optimizing #
Don’t optimize based on intuition — measure first. Flutter DevTools provides very complete tools for identifying real bottlenecks.
Flutter DevTools -- main profiling tools:
Performance tab:
✓ Frame timeline: see which frames exceed 16ms (60fps) or 8ms (120fps)
✓ Widget rebuild tracker: identify the widgets that rebuild most often
✓ Raster thread analysis: detect shader compilation jank
Widget Inspector tab:
✓ Visualization of the running widget tree
✓ Highlight repaint areas (enable "Highlight Repaints")
✓ Show performance overlay
Memory tab:
✓ Detect memory leaks
✓ View object allocations over time
✓ Snapshot the current heap
# Run in profile mode for accurate measurements
# (debug mode is slower because of assertion overhead)
flutter run --profile
# Open DevTools from the terminal
flutter pub global activate devtools
flutter pub global run devtools
// Enable visual debugging in code
import 'package:flutter/rendering.dart';
void main() {
// Show rebuild borders -- widgets turn green when rebuilt
debugRepaintRainbowEnabled = true;
// Show text baselines
debugPaintBaselinesEnabled = true;
runApp(const MyApp());
}
10. Use Semantic Widgets for Accessibility #
A good widget isn’t just visually nice — it’s also accessible to users with disabilities. Use semantic widgets or add a Semantics wrapper:
// LESS ACCESSIBLE: GestureDetector without a label
GestureDetector(
onTap: _delete,
child: const Icon(Icons.delete, color: Colors.red),
)
// BETTER: use a widget that already has semantics
IconButton(
onPressed: _delete,
icon: const Icon(Icons.delete),
tooltip: 'Delete item', // screen readers will read this
color: Colors.red,
)
// For custom widgets: add Semantics
Semantics(
label: 'Delete product ${product.name} button',
button: true,
child: GestureDetector(
onTap: _delete,
child: const Icon(Icons.delete, color: Colors.red),
),
)
// ExcludeSemantics for decorative elements that don't need reading
ExcludeSemantics(
child: const Icon(Icons.fiber_manual_record, size: 8), // decorative bullet
)
11. Widget Tests to Verify Behavior #
Good widgets can be tested automatically. Write widget tests to verify appearance and behavior:
// widget_test.dart
import 'package:flutter_test/flutter_test.dart';
testWidgets('ProductCard displays name and price', (tester) async {
// Arrange
final product = Product(id: '1', name: 'Flutter Book', price: 150000);
// Act
await tester.pumpWidget(
MaterialApp(
home: ProductCard(
product: product,
isFavorite: false,
cartQuantity: 0,
onFavoriteToggle: () {},
onAddToCart: () {},
),
),
);
// Assert
expect(find.text('Flutter Book'), findsOneWidget);
expect(find.text('Rp 150000'), findsOneWidget);
expect(find.byIcon(Icons.favorite_border), findsOneWidget);
});
testWidgets('ProductCard shows the active favorite icon when isFavorite is true', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: ProductCard(
product: Product(id: '1', name: 'Test', price: 0),
isFavorite: true, // favorite active
cartQuantity: 0,
onFavoriteToggle: () {},
onAddToCart: () {},
),
),
);
expect(find.byIcon(Icons.favorite), findsOneWidget); // active icon
expect(find.byIcon(Icons.favorite_border), findsNothing); // not the empty icon
});
testWidgets('onAddToCart is called when the button is pressed', (tester) async {
bool called = false;
await tester.pumpWidget(
MaterialApp(
home: ProductCard(
product: Product(id: '1', name: 'Test', price: 0),
isFavorite: false,
cartQuantity: 0,
onFavoriteToggle: () {},
onAddToCart: () => called = true, // verifiable callback
),
),
);
await tester.tap(find.text('Add to Cart'));
expect(called, isTrue);
});
Widget Review Checklist #
Use this checklist before merging widgets into the codebase:
STRUCTURE:
□ Constructor uses const and accepts super.key
□ All fields are final
□ The widget does only one thing (single responsibility)
□ The widget is split at the right semantic boundaries
□ No helper functions that should be widgets
PERFORMANCE:
□ All static widgets use const
□ No heavy computation in build()
□ setState only in widgets that truly need it
□ Not using shrinkWrap: true for long lists
□ Animations use FadeTransition, not Opacity
RESOURCE MANAGEMENT:
□ All controllers and subscriptions are disposed in dispose()
□ All async callbacks check mounted before setState
□ GlobalKeys are only used when truly necessary
MAINTAINABILITY:
□ Descriptive widget, variable, and function names
□ The widget receives data (not fetching from global singletons)
□ The widget can be tested independently
ACCESSIBILITY:
□ Interactive buttons have tooltips or semantic labels
□ Decorative elements use ExcludeSemantics
□ Color contrast meets WCAG AA (minimum 4.5:1 ratio)
TESTING:
□ There's a widget test for the main display
□ There's a widget test for main interactions
□ There are widget tests for edge cases (loading, error, empty)
Summary #
- Design widgets as pure functions — output only depends on input (props), no hidden dependencies. This makes widgets easy to test and reuse.
- Apply single responsibility — each widget does one thing well. Split at meaningful semantic boundaries, not too granular and not too monolithic.
- Always accept
super.keyin constructors — this lets parents identify widgets for optimization and state preservation.- Use
ValueListenableBuilderfor small, frequently changing state — only the UI part depending on that value rebuilds without needing setState.RepaintBoundaryisolates rendering areas — use it around animations adjacent to complex static content, or items in lists.- Flatten the widget tree — avoid unnecessary nesting. Each extra layer adds layout and hit-testing overhead.
- Send already-processed data to widgets (ViewModel pattern) — widgets shouldn’t do heavy data transformations in build().
- Profile with DevTools before optimizing — measure first, then fix. Enable
--profilemode for accurate measurements.- Add accessibility semantics to custom interactive widgets — tooltips, Semantics widgets, and ExcludeSemantics for decorative elements.
- Write widget tests for every widget — test main display, interactions, and edge cases.