The view's rules: what logic may stay in a widget
The official recommendations put "do not put logic in widgets" at the highest priority — but it is not a blanket ban. The guide lists the permitted exceptions explicitly. A widget may keep:
- Simple `if` statements — showing or hiding a widget based on a flag from the view model.
- Animation logic — animations that rely on widget calculations.
- Layout based on device information — screen size, orientation, platform.
- Simple routing logic.
The practical value of that list: it lets code review answer "can this code stay in the widget?" objectively. If it is not on the list, it moves to the view model.
Examples of what is off-limits (that is, belongs to the view model):
- Any logic related to data: filtering, sorting, computing, grouping.
- Decisions based on error kind: whether to show retry, whether to redirect.
- Validation rules (a form's
validatormay check simple format, but not a business rule). - Managing an async operation: awaiting a
Future, storing its outcome.
| Code | In the widget? | Why |
|---|---|---|
| `if (state.isAdmin) AdminPanel()` | ✅ Allowed | The flag comes from the view model; the widget only renders |
| `if (constraints.maxWidth > 600) Row() else Column()` | ✅ Allowed | Layout from device information — a permitted exception |
| `AnimationController` and `Tween` calculations | ✅ Allowed | Animation is tied to the widget's lifetime |
| `orders.where((o) => o.status.isActive).toList()` | ❌ Not allowed | Data logic; it recomputes on every rebuild and cannot be tested |
| `if (error is NetworkFailure) showRetry()` | ⚠️ Borderline | The decision belongs to the presentation layer but should live in a function like `presentFailure(…)` |
| `await repository.fetchMine()` | ❌ Not allowed | Direct access to the data layer; it bypasses the view model entirely |
| `total * 1.18` (tax calculation) | ❌ Not allowed | A business rule — it belongs to a domain model or a use case |
| `NumberFormat.currency(…).format(total)` | ✅ Allowed | Display formatting; it depends on the locale and belongs to the UI layer |
// ══════ ❌ MƏNTİQLİ WIDGET ══════
class BadOrdersPage extends ConsumerStatefulWidget {
const BadOrdersPage({super.key});
@override
ConsumerState<BadOrdersPage> createState() => _BadOrdersPageState();
}
class _BadOrdersPageState extends ConsumerState<BadOrdersPage> {
OrderFilter _filter = OrderFilter.all; // ⚠️ state widget-də
bool _loading = false;
List<Order> _orders = [];
@override
void initState() {
super.initState();
_load(); // ⚠️ async idarəsi widget-də
}
Future<void> _load() async {
setState(() => _loading = true);
// ⚠️ data qatına birbaşa müraciət — view model keçilir
_orders = await ref.read(orderRepositoryProvider).fetchMine();
setState(() => _loading = false);
}
@override
Widget build(BuildContext context) {
if (_loading) return const CircularProgressIndicator();
// ⚠️ süzgəc hər rebuild-də yenidən hesablanır
final visible = _orders.where((o) => switch (_filter) {
OrderFilter.all => true,
OrderFilter.active => o.status.isActive,
OrderFilter.completed => o.status == OrderStatus.completed,
}).toList();
// ⚠️ biznes qaydası widget-də
final total = visible.fold<double>(0, (s, o) => s + o.total) * 1.18;
return Column(children: [
Text('Cəmi (ƏDV ilə): ${total.toStringAsFixed(2)}'),
Expanded(child: OrderListView(orders: visible)),
]);
}
}
// ══════ ✅ TƏMİZ WIDGET ══════
class OrdersPage extends ConsumerWidget {
const OrdersPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(orderListNotifierProvider);
final visible = ref.watch(visibleOrdersProvider); // törəmə provider
final summary = ref.watch(orderSummaryProvider); // ƏDV domain-də
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).ordersTitle),
bottom: FilterTabs(
// Sadə callback — icazəli
onChanged: (f) => ref
.read(orderListNotifierProvider.notifier)
.setFilter(f),
),
),
// Cihaz məlumatına görə layout — icazəli istisna
body: LayoutBuilder(
builder: (context, constraints) => switch (state) {
AsyncData() when visible.isEmpty => const EmptyOrders(),
AsyncData() => constraints.maxWidth > 600
? OrdersWideLayout(orders: visible, summary: summary)
: OrdersNarrowLayout(orders: visible, summary: summary),
AsyncError(:final error) => ErrorView(
error: error,
onRetry: () => ref.invalidate(orderListNotifierProvider),
),
_ => const Center(child: CircularProgressIndicator()),
},
),
);
}
}
// Diqqət: `build`-də nə `await`, nə `setState`, nə `where`, nə `* 1.18`.
// Formatlaşdırma isə widget-də qalır — o, göstərmə məntiqidir:
// Text(formatMoney(summary.total, Localizations.localeOf(context)))The same screen: a widget full of logic, and a clean one. In the second, `build` only renders.
`StatefulWidget` is still needed. The rule "all state goes in the notifier" can be taken too far. Widget-owned objects belong in a `StatefulWidget`:
TextEditingController,FocusNode,ScrollController,PageControllerAnimationControllerandTickerProviderGlobalKey,FormState
The reason is simple: creating and disposing these objects is tied to the widget's lifetime. Keeping them in a notifier creates two problems: the controller leaks when the notifier is disposed, or a stale reference remains when the widget rebuilds while the controller lives on.
A practical split (a search field):
TextEditingController→ in theStatefulWidget.- The search text's value and the results → in the notifier.
- The controller's
onChangedcalls the notifier's method.
On widget size. The official recommendations advise folders like ui/core/ in their naming section; in practice there is a simpler criterion for splitting a widget: if the build method cannot be read without scrolling (roughly more than 60-80 lines), break it into child widgets. Child widgets improve both readability and rebuild scope — a separate widget with a const constructor is not rebuilt when its parent rebuilds.
Practice. Pick a screen in your project and audit its build method. For each line ask: does this fall into one of the four officially permitted exceptions (a simple if, animation, device-based layout, simple routing)?
Move every line that does not: data logic → the notifier or a derived provider; a business rule → the domain; an error decision → a presentFailure function.
Done means: build contains no await, no setState, no where/fold/sort and no direct repository call — while TextEditingController still lives in the StatefulWidget (which is correct).
📚 Sources and documentation
- Architecture recommendations: logic in widgetsofficialdocs.flutter.dev
The official list of the four exceptions a widget may keep — the source for this topic.
- Case study: the UI layerofficialdocs.flutter.dev
What a view looks like in real code and how it relates to the view model.
- Flutter: performance best practicesofficialdocs.flutter.dev
Avoiding heavy work in `build` and narrowing rebuild scope — the second reason to split widgets.
- Flutter: StatefulWidgetofficialapi.flutter.dev
Managing controllers with `initState`/`dispose` — why they do not live in a notifier.