Sparround

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 validator may check simple format, but not a business rule).
  • Managing an async operation: awaiting a Future, storing its outcome.
CodeIn the widget?Why
`if (state.isAdmin) AdminPanel()`✅ AllowedThe flag comes from the view model; the widget only renders
`if (constraints.maxWidth > 600) Row() else Column()`✅ AllowedLayout from device information — a permitted exception
`AnimationController` and `Tween` calculations✅ AllowedAnimation is tied to the widget's lifetime
`orders.where((o) => o.status.isActive).toList()`❌ Not allowedData logic; it recomputes on every rebuild and cannot be tested
`if (error is NetworkFailure) showRetry()`⚠️ BorderlineThe decision belongs to the presentation layer but should live in a function like `presentFailure(…)`
`await repository.fetchMine()`❌ Not allowedDirect access to the data layer; it bypasses the view model entirely
`total * 1.18` (tax calculation)❌ Not allowedA business rule — it belongs to a domain model or a use case
`NumberFormat.currency(…).format(total)`✅ AllowedDisplay formatting; it depends on the locale and belongs to the UI layer
dart
// ══════ ❌ 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, PageController
  • AnimationController and TickerProvider
  • GlobalKey, 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 the StatefulWidget.
  • The search text's value and the results → in the notifier.
  • The controller's onChanged calls 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