Skip to content

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:

<Text data="${profile.name}"/>

ValueListener And Notifiers

<ValueListener> listens to one dependency path:

<ValueListener varName="profile">
    <Text data="${profile.name}"/>
</ValueListener>

When the listener is created, XWidget resolves profile and wraps that value in a ModelValueNotifier if it is not already a notifier.

Conceptually:

profile -> ModelValueNotifier(ProfileModel)

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:

dependencies.setValue('profile.name', 'Chris');

The write path is:

Dependencies -> profile notifier -> name

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:

ProfileModel -> name

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:

<ValueListener varName="profile.name">
    <Text data="${profile.name}"/>
</ValueListener>

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.

dependencies.setValue('selectedChannel', channelB);

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:

<ValueListener varName="selectedChannel">
    <Text data="${selectedChannel.name}"/>
</ValueListener>

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.

channel.name = 'Production';

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>
dependencies.setValue('selectedChannel.name', 'Production');

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:

dependencies.setValue('projectKeys.state.refreshing', true);

A direct write to the model starts below projectKeys.state:

state.setValue('refreshing', true);

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:

<ValueListener varName="form.email">
    <Text data="${form.email}"/>
</ValueListener>

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>
dependencies.setValue('projectKeys.state.refreshing', true);

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:

  • Model gives data shape and typed access.
  • Dependencies gives 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:

  1. What path does <ValueListener> listen to?
  2. What path does the write start from?
  3. Does the write path pass through the listened notifier?
  4. Am I replacing an object or mutating inside the same object?
  5. 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.

  • Dependencies — the data store paths resolve against
  • Models — shaped data over the same path mechanics
  • Events — the broadcast alternative when paths don't fit