Skip to main content

2026-09-30-flutter-state-and-commands


title: "One way to hold state and one way to send a command: dartway check fails the rest" affects: dartway_core_flutter: "0.21.0-dev.14" dartway_cli: "0.20.0"​

Who is affected​

Every project whose Flutter package's lib/ — core/, shared/ and ui_kit/ included — holds a StatefulWidget (or ConsumerStatefulWidget, StatefulHookWidget, StatefulHookConsumerWidget), a setState, a StatefulBuilder, or a ChangeNotifier/ValueNotifier it keeps state in: dart run dartway_cli:dartway check fails on it now (forbiddenStateHolder). And every project that sends dw.command from widgets/ or shared/, wraps a command in try/catch, runs a <Feature>Commands method from a widget outside dw.action, or reads DwCallOk, DwCallRefused, DwCallFailed or valueOrThrow in a widget (forbiddenCommandCall).

What to change​

A widget's own state becomes hooks. StatefulWidget → HookWidget (ConsumerStatefulWidget → HookConsumerWidget), and what the State held moves into build:

- late final TextEditingController _controller;
- void initState() { … _controller = TextEditingController(text: widget.value); }
- void dispose() { _controller.dispose(); … }
+ final controller = useTextEditingController(text: value);

ScrollController, FocusNode, TabController and AnimationController (its ticker mixin gone) are useScrollController, useFocusNode, useTabController, useAnimationController. A Timer, a StreamSubscription or an observer is a useEffect that returns its cleanup; WidgetsBindingObserver for the lifecycle is useOnAppLifecycleStateChange. A flag is useState. didUpdateWidget is a useEffect(…, [prop]), or useValueChanged(prop, (old, _) { … }) when the old value matters. A listener registered once that reads the latest props reads them through final latest = useRef(this)..value = this;.

The kit's copies from the skeleton. A project's lib/ui_kit/ holds its own copies of AppTextFormField and MultiLinkText (and, from the example, a masked PhoneTextField). Replace each file with the hooks version, keeping your own styling: template/dartway_starter_flutter/lib/ui_kit/1_essentials/app_text_form_field.dart and multi_link_text.dart, and example/dartway_example_flutter/lib/ui_kit/2_frequent/phone_text_field.dart (the template's own phone_text_field.dart is stateless already). Their constructors are unchanged. AppTextFormField.fromStringNotifier is gone with its ValueNotifier; a caller passes the value and the setter of whoever holds it:

- AppTextFormField.fromStringNotifier(valueNotifier: name, labelText: …)
+ AppTextFormField(value: name.value, onChanged: (v) => name.value = v, labelText: …)

with name a useState('') of the calling HookWidget, or value: state.name, onChanged: controller.editName over a controller.

A StatefulBuilder becomes a HookBuilder (HookConsumer when it needs a ref), the state inside it a useState:

- StatefulBuilder(builder: (context, setState) => Checkbox(value: on, onChanged: (v) => setState(() => on = v!)))
+ HookBuilder(builder: (context) {
+ final on = useState(false);
+ return Checkbox(value: on.value, onChanged: (v) => on.value = v!);
+ })

A mixin or an extension on State<…> that calls setState (actions split out of a big page) becomes a hook function of your own — a top-level use… that calls hooks and returns what the page needs — called from the page's build:

- mixin _UndoActions on State<ActivityLogPage> {
- UndoAction? pendingUndo;
- void offerUndo(UndoAction action) => setState(() => pendingUndo = action);
- }
+ ({UndoAction? pending, void Function(UndoAction) offer}) useUndoOffer() {
+ final pending = useState<UndoAction?>(null);
+ return (pending: pending.value, offer: (action) => pending.value = action);
+ }
+ // in ActivityLogPage.build: final undo = useUndoOffer();

A State in a file of its own (a part of the widget's library) is a finding of its own. Convert it with its widget: the fields become hooks in the widget's build, and the part file goes. Where the State stays because an API needs it, the marker goes on each class it stands on — the widget's and the State's, whichever files they are in — and both are listed.

State shared between widgets, or a flow with logic, becomes a controller: a Riverpod Notifier named <Thing>Controller in the feature's logic/, usually NotifierProvider.autoDispose.family<…Controller, …, Key>(…Controller.new) keyed by what it is about. A ChangeNotifier controller and a ValueNotifier field on a session object move there; widgets ref.watch its state and call its methods through ref.read(provider.notifier). A Notifier of the project's not named <Thing>Controller is renamed with it (AuthState → AuthController, authControllerProvider).

An API that truly needs a State subclass or a Listenable of its own keeps its class and takes one line above it, with the reason:

+ // dw:allow-stateful DwAppRouter re-runs its guards on a Listenable
class AppRouterState extends ChangeNotifier {

The skeleton's lib/core/router/app_router_state.dart needs exactly this line. Every run lists the marked classes after the tally. Not for long: with dartway_router 3.0.0 the router follows a provider and AppRouterState becomes a record, marker and all — router-state-provider.

Commands are sent from logic/ and run inside dw.action. App-wide wiring that no button starts — registering a push token, a bootstrap step — stays in lib/core/ (for example lib/core/push.dart), which may send dw.command and read its result; everything a user triggers moves to a feature. Move a dw.command out of a widget into the feature's logic/<feature>_commands.dart (<Feature>Commands, command senders only) or into the flow's controller, and wrap the call on the button:

- onTap: () async { try { await dw.command(PayInvoice(invoiceId: id)); } catch (_) { … } },
+ onTap: dw.action((_) => InvoiceCardCommands.pay(invoice)),

A shared visual component that sends a command is a feature in common/, with its sender in common/<feature>/logic/; a passive kit component takes a DwUiAction from the owning feature. shared/ is for pure helpers and has no logic/ escape hatch. A helper several features need that uses the framework without owning UI (for example, resolving a private file URL) belongs in core/storage/; it may read valueOrThrow there.

A callback need not become flags on the receiving widget. Build the DwUiAction where the command is known and pass it to the kit component, which runs it through DwActionBuilder. A local final onDone = (_) => InvoiceCommands.pay(invoice); followed by dw.action(onDone) is recognized by CLI 0.25.0-dev.0 or later when every use of that closure is in the first argument of dw.action. The checker cannot trace a raw callback through another file's widget into its action; use the action value at that boundary.

A pure helper that sat on a <Feature>Commands class (building a patch from a draft) moves to a file of its own in logic/ — a call to <Feature>Commands from a widget outside dw.action fails.

A widget reads no result. A value the widget needs is unwrapped in logic/ and arrives in the follow-up, which runs on success only:

- final result = await dw.action((_) => ACommands.create(title))(context);
- if (result case DwCallOk(:final value)) selected.value = value.id;
+ // logic: static Future<Thing> create(String title) async =>
+ // (await dw.command(CreateThing(title: title))).valueOrThrow;
+ await dw.action(
+ (_) => ACommands.create(title),
+ followUpIfMountedAction: (_, created) => selected.value = created.id,
+ )(context);

followUpIfMountedAction: (_, result) { if (result is DwCallOk) … } is just the call. A command placed in followUpIfMountedAction is outside the action — only its first argument counts.

A refusal's text comes only from the app's catalogue, DwFlutterConfig.refusalText in lib/core/; a flow that reacts to a refusal code (resetting a step) does so in its controller and returns the result for dw.action to show. This one is stated, not held: the checker cannot tell a sentence built from a refusal code from any other string — search your logic/ for refusal.code and isCode( used to pick a text, and move those texts into the catalogue. Commands sent through a plugin's method or dw.files are not seen by the checker either; move them to logic/ the same way. dw.files.getLink and dw.files.upload results are read in logic/ the same way.

When the command outcome is the screen's state, use a flow controller in logic/ to own loading, success, refusal and failure. See inline command outcomes for how to keep one message and retain incident reporting. Returning the same refused result to dw.action after storing it for an error view displays it twice; onError does not suppress the action's notification. A state-owning operation remains a command if it changes server state.

How to check​

dart run dartway_cli:dartway check in the Flutter package: no forbiddenStateHolder or forbiddenCommandCall, and 🔓 Allowed by dw:allow-stateful lists only the classes you meant.