update method
Handles a message and returns the new model state and optional command.
This is the heart of the Elm Architecture. When a message arrives (from user input, timers, async operations, etc.), this method:
- Examines the message
- Computes the new model state
- Optionally returns a command to execute
The returned tuple contains:
- The new model (can be
thisif unchanged) - An optional command to execute (or
null)
Pattern Matching
Dart's pattern matching makes update functions clean and readable:
@override
(Model, Cmd?) update(Msg msg) {
return switch (msg) {
// Match key type
KeyMsg(key: Key(type: KeyType.enter)) =>
(submitForm(), null),
// Match specific character
KeyMsg(key: Key(type: KeyType.runes, runes: [0x71])) =>
(this, Cmd.quit()),
// Match with modifier
KeyMsg(key: Key(ctrl: true, runes: [0x73])) => // Ctrl+S
(this, saveFile()),
// Match custom message with destructuring
DataLoadedMsg(:final items) =>
(copyWith(items: items, loading: false), null),
// Match window resize
WindowSizeMsg(:final width, :final height) =>
(copyWith(width: width, height: height), null),
// Default case - no change
_ => (this, null),
};
}
Immutability
Models should be immutable. Create new instances rather than modifying existing ones:
// ✓ Good - create new instance
return (CounterModel(count + 1), null);
// ✓ Good - use copyWith pattern
return (copyWith(count: count + 1), null);
// ✗ Bad - mutating state
count++;
return (this, null);
Implementation
@override
(Model, Cmd?) update(Msg msg) {
final cmds = <Cmd>[];
if (TuiTrace.enabled) {
TuiTrace.log('widget_app.update start ${msg.runtimeType}');
}
if (msg is KeyMsg) {
_recordKeyTimestamp();
}
if (msg is FrameTickMsg) {
if (_debugOverlayEnabled) {
_overlayDirty = true;
}
if (!handleFrameTick) {
return (this, null);
}
}
// --- F12 toggles the built-in debug overlay ---
if (msg is KeyMsg && msg.key.type == KeyType.f12) {
_debugOverlayEnabled = !_debugOverlayEnabled;
_runtimeDebugOverlay = _runtimeDebugOverlay.setEnabled(
_debugOverlayEnabled,
);
_runtimeDebugOverlay = _positionRuntimeOverlay(_runtimeDebugOverlay);
_overlayDirty = true;
return (this, null);
}
if (msg is _RenderMetricsInjectionMsg) {
_applyRenderMetricsInjection(msg.injection);
return (this, null);
}
// Store runtime render metrics but avoid forcing full-tree rebuilds for
// WidgetApp's built-in overlay. We compose that overlay outside the tree
// from a cached base view (split-dashboard style).
if (msg is RenderMetricsMsg) {
_applyRenderMetricsInjection(
RenderMetricsInjection(metrics: msg.metrics),
);
return (this, null);
}
if (msg is WindowSizeMsg) {
_tree.setRootConstraints(
BoxConstraints.tight(Size(msg.width.toDouble(), msg.height.toDouble())),
);
_mediaQueryData = MediaQueryData(
size: Size(msg.width.toDouble(), msg.height.toDouble()),
);
_tree.update(
_MediaQueryHost(
key: _mediaQueryKey,
data: _mediaQueryData,
metricsHolder: _metricsHolder,
child: _currentRoot(),
),
);
_runtimeDebugOverlay = _runtimeDebugOverlay.copyWith(
terminalWidth: msg.width,
terminalHeight: msg.height,
);
_runtimeDebugOverlay = _positionRuntimeOverlay(_runtimeDebugOverlay);
if (_debugOverlayEnabled) {
_overlayDirty = true;
}
_dirty = true;
}
if (msg is MouseMsg) {
// --- Mouse capture: active drag/press owner gets the event first ---
final capture = _tree.mouseCapture;
if (capture != null) {
final Stopwatch? dispatchSw = TuiTrace.enabled ? Stopwatch() : null;
dispatchSw?.start();
final cmd = _tree.dispatchTo(capture, msg);
dispatchSw?.stop();
if (TuiTrace.captureDispatchEnabled) {
TuiTrace.log(
'widget_app.capture_dispatch ${capture.widget.runtimeType} '
'dt=${dispatchSw?.elapsedMicroseconds ?? -1}us',
);
}
if (cmd != null) cmds.add(cmd);
root = _currentRoot();
_dirty = _dirty || _tree.hasDirty || _tree.hasPaintDirty;
if (TuiTrace.enabled) {
TuiTrace.log('widget_app.update end (capture) dirty=$_dirty');
}
return (this, _coalesceCommands(cmds));
}
// --- Render-tree hit-testing (default for widget apps) ---
if (useHitTesting) {
final Stopwatch? hitSw = TuiTrace.enabled ? Stopwatch() : null;
hitSw?.start();
final hits = _tree.hitTestAt(msg.x.toDouble(), msg.y.toDouble());
hitSw?.stop();
if (TuiTrace.enabled) {
TuiTrace.log(
'widget_app.hitTest count=${hits.length} '
'mouse=(${msg.x},${msg.y}) '
'dt=${hitSw?.elapsedMicroseconds ?? -1}us',
);
}
if (hits.isNotEmpty) {
// Dispatch a HitTestMouseMsg starting from the deepest hit element,
// bubbling UP to ancestors. This ensures StatefulWidgets like
// GestureDetector (which have no render object of their own)
// receive the event when their child's render object is hit.
//
// Track which StatefulElements have already been visited so that
// when multiple hit entries (child render objects) bubble up to
// the same GestureDetector, it only processes the event once.
final visited = <Element>{};
for (final hit in hits) {
final hitMsg = HitTestMouseMsg(
event: msg,
localX: hit.localX,
localY: hit.localY,
);
final cmd = _tree.dispatchBubbleUp(
hit.element,
hitMsg,
visited: visited,
);
if (cmd != null) {
cmds.add(cmd);
break;
}
}
// Capture dirty state BEFORE broadcasting the raw MouseMsg.
// The hit-test dispatch above may have triggered setState() calls
// (e.g., onWheel, onEnter) that marked elements dirty. If we
// broadcast the raw MouseMsg first, _tree.dispatch() rebuilds
// those dirty elements, clearing hasDirty/hasPaintDirty before we
// can propagate it to `_dirty`.
root = _currentRoot();
_dirty = _dirty || _tree.hasDirty || _tree.hasPaintDirty;
// Broadcast raw mouse only when needed for out-of-hit housekeeping
// (hover-exit and selection-finalize outside bounds). Avoid doing
// this for wheel/press to prevent whole-tree traversals during
// scroll bursts.
final isWheelLike =
msg.action == MouseAction.wheel ||
msg.button == MouseButton.wheelUp ||
msg.button == MouseButton.wheelDown ||
msg.button == MouseButton.wheelLeft ||
msg.button == MouseButton.wheelRight;
// Press events are already delivered through hit-test bubbling.
// Re-broadcasting press globally can let unrelated widgets react to
// the same click (and potentially steal mouse capture), which breaks
// controls like draggable scroll thumbs.
final shouldBroadcastRawMouse =
!isWheelLike && msg.action != MouseAction.press;
if (shouldBroadcastRawMouse) {
final broadcastCmd = _tree.dispatch(msg);
if (broadcastCmd != null) cmds.add(broadcastCmd);
}
// Pick up any additional dirty flags from the broadcast.
root = _currentRoot();
_dirty = _dirty || _tree.hasDirty || _tree.hasPaintDirty;
if (TuiTrace.enabled) {
TuiTrace.log('widget_app.update end (hitTest) dirty=$_dirty');
}
return (this, _coalesceCommands(cmds));
}
}
}
// Capture dirty state before dispatch — external setState() calls
// (e.g., from OverlayEntry.markNeedsBuild) mark elements dirty between
// frames. dispatch() rebuilds them, clearing hasDirty, so we must
// record the flag beforehand to ensure view() invalidates the cache.
final hadDirtyBeforeDispatch = _tree.hasDirty || _tree.hasPaintDirty;
final Stopwatch? dispatchSw = TuiTrace.enabled ? Stopwatch() : null;
dispatchSw?.start();
final cmd = _tree.dispatch(msg);
dispatchSw?.stop();
if (TuiTrace.enabled) {
TuiTrace.log(
'widget_app.dispatch ${msg.runtimeType} '
'dt=${dispatchSw?.elapsedMicroseconds ?? -1}us',
);
}
if (cmd != null) cmds.add(cmd);
root = _currentRoot();
if (msg is WindowSizeMsg || msg is BackgroundColorMsg) {
_dirty = true;
} else {
_dirty =
_dirty ||
hadDirtyBeforeDispatch ||
_tree.hasDirty ||
_tree.hasPaintDirty;
}
if (TuiTrace.enabled) {
TuiTrace.log('widget_app.update end dirty=$_dirty');
}
return (this, _coalesceCommands(cmds));
}