State And Reactivity
XWidget state is path-based. A dependency path identifies a value in a
Dependencies graph, and a
<ValueListener> listens to one of
those paths.
The important rule is:
A write notifies the notifiers it passes through.
If the write path crosses a ModelValueNotifier, that notifier can rebuild its
listeners. If the write starts below a notifier, that notifier is not part of
the write path and will not be notified.
Dependency Paths
Dependencies stores values in a path-addressable graph. Paths support dot and
bracket notation:
dependencies.setValue('profile.name', 'Chris');
dependencies.setValue('profile.emails[0]', '[email protected]');
Fragments read the same paths with expressions:
ValueListener And Notifiers
<ValueListener> listens to one dependency path:
When the listener is created, XWidget resolves profile and wraps that value in
a ModelValueNotifier if it is not already a notifier.
Conceptually:
The listener is attached to the profile path. It rebuilds when that notifier
is notified.
Writes Through A Notifier
This write starts at the dependency graph and passes through the profile
notifier:
The write path is:
Because the write crosses the profile notifier, a listener on profile can
rebuild.
Writes Below A Notifier
This write starts from the model object itself:
final profile = dependencies.getValue('profile') as ProfileModel;
profile.setValue('name', 'Chris');
The write path is:
That path does not cross the profile notifier in the dependency graph. The
model field changes, but a listener on profile is not notified.
This is normal Dart object mutation. It is useful, but it is not the same
operation as writing through Dependencies.
Field Listeners
You can listen at a more specific path:
When a value is listened to directly, that specific value can be wrapped in a notifier. A direct model write to that exact field can update that field notifier because the target value itself is the notifier.
This does not notify notifiers above the write path. A listener on
profile.name and a listener on profile are listening to different paths.
Replace When Identity Changes
Replace the value at a dependency path when the value represents a different thing.
Use this when moving from channel A to channel B, project A to project B, or one loaded result to a different loaded result.
Markup can listen to the selected object:
Replacing selectedChannel changes the listened dependency value, so the
listener rebuilds.
Mutate When The Same Object Changes
If the object identity is the same, mutate the object.
This is normal Dart object mutation. It is the right shape when the object is still channel A, but channel A's name changed.
If a specific field is listened to, update that field through the path that reaches the field notifier:
<ValueListener varName="selectedChannel.name">
<Text data="${selectedChannel.name}"/>
</ValueListener>
Use A Change Signal For Grouped Object Updates
When several fields inside the same object should cause one section to rebuild,
add an explicit change signal such as changedAt.
class ProjectKeysState extends Model {
ProjectKeysState(super.data);
void markChanged() {
setValue('changedAt', DateTime.now().microsecondsSinceEpoch);
}
}
Publish the state object once:
final state = ProjectKeysState({
'refreshing': false,
'error': null,
'keys': null,
'changedAt': DateTime.now().microsecondsSinceEpoch,
});
dependencies.setValue('projectKeys.state', state);
Then mutate related fields and mark the object changed once:
state.setValue('refreshing', false);
state.setValue('error', null);
state.setValue('keys', keys);
state.markChanged();
Listen to the change signal:
<ValueListener varName="projectKeys.state.changedAt">
<Column>
<Text data="${projectKeys.state.keys.projectKey}"/>
<Text data="${projectKeys.state.error}"/>
</Column>
</ValueListener>
This pattern is useful when a controller owns one page-state object and updates several fields as one logical state transition.
Write Through Dependencies For Parent Listeners
If a parent listener should rebuild, write through that parent path.
<ValueListener varName="projectKeys.state">
<Text data="${projectKeys.state.refreshing}"/>
</ValueListener>
This write passes through projectKeys.state:
A direct write to the model starts below projectKeys.state:
The direct write changes the model, but it does not pass through the
projectKeys.state notifier.
Choosing The Listen Path
Choose the listener path based on what should rebuild.
Use a field path when only one value matters:
Use a change signal when several fields are mutated together:
<ValueListener varName="projectKeys.state.changedAt">
<!-- Reads several fields from projectKeys.state -->
</ValueListener>
Use a parent path when writes will go through that parent path:
<ValueListener varName="projectKeys.state">
<!-- Rebuilds when writes pass through projectKeys.state -->
</ValueListener>
Domain Models And Page State
Model is a structured data object. It can be loaded from API data, passed
through Dart code, stored in Dependencies, and read by fragments.
Use domain models for data loaded from APIs:
class ProjectKeys extends Model {
ProjectKeys(super.data);
String get projectKey => getValue('projectKey') as String? ?? '';
String get storageKey => getValue('storageKey') as String? ?? '';
String? get previousKey => getValue('previousKey') as String?;
}
Use page state for UI concerns:
final state = Model({
'refreshing': false,
'rotating': false,
'error': null,
'keys': null,
'changedAt': DateTime.now().microsecondsSinceEpoch,
});
The page state can compose the domain model:
state.setValue('keys', ProjectKeys(apiJson));
state.setValue('refreshing', false);
state.setValue('error', null);
state.setValue('changedAt', DateTime.now().microsecondsSinceEpoch);
This avoids maintaining one API data hierarchy and another UI data hierarchy with the same fields.
Reactive notification is controlled by the path used to perform a write. A model does not automatically notify every dependency path where it might be stored.
That separation is intentional:
Modelgives data shape and typed access.Dependenciesgives path-based storage and notification.<ValueListener>rebuilds UI for the path it listens to.
Loading And Error Phases
For async work, mark each visible phase once.
Future<void> loadKeys() async {
state.setValue('refreshing', true);
state.setValue('error', null);
state.markChanged();
try {
final keys = await cloudApi.getProjectKeys(projectId);
state.setValue('keys', ProjectKeys(keys.toJson()));
state.setValue('refreshing', false);
state.markChanged();
} catch (error) {
state.setValue('refreshing', false);
state.setValue('error', error.toString());
state.markChanged();
}
}
The UI updates once when loading starts and once when loading finishes or fails.
Controller Owns Behavior
Controllers should own behavior:
- API calls
- navigation
- dialogs
- snackbars
- clipboard actions
- loading and error flow
- business rules
Models should own data shape. If a model method starts opening dialogs, calling services, or controlling page flow, it is no longer just a model.
Debugging A Listener That Does Not Rebuild
When a listener does not rebuild, compare the listener path with the write path.
Ask:
- What path does
<ValueListener>listen to? - What path does the write start from?
- Does the write path pass through the listened notifier?
- Am I replacing an object or mutating inside the same object?
- Should this section listen to a specific field or to a change signal?
Most reactivity bugs reduce to a mismatch between the path being listened to and the path used to write.
Related Concepts
- Dependencies — the data store paths resolve against
- Models — shaped data over the same path mechanics
- Events — the broadcast alternative when paths don't fit