skip to content

In a Flutter app following the architecture guide's MVVM, how does a tap reach the repository, and how does the new state reach the widgets?

level: middleimportance: should knowfreq 42%

answer

  1. events down, state up
  2. view calls a view model method
  3. repository mutates the data
  4. notifyListeners on ChangeNotifier
  5. ListenableBuilder re-runs builder

basics

~20 s

The widget's callback calls a view model method, which asks the repository to change the data; the repository returns new data, the view model updates its state and calls notifyListeners(), and a ListenableBuilder rebuilds the view.

solid answer

~40 s

The guide calls this **unidirectional data flow**. A gesture in the view invokes a view model method; the view model calls the repository, which is the only place that data changes; the repository returns the new domain data; the view model stores new UI state and, in the guide's sample, calls `notifyListeners()` because it extends `ChangeNotifier`. The view wraps its subtree in `ListenableBuilder(listenable: viewModel, builder: ...)`, which re-runs `builder` on each notification. Data can also start in the repository, such as polled fares, and then only travels the second half. The view never mutates the view model's data; the guide exposes lists as an `UnmodifiableListView`.

code

dart · 44 lines
dart
import 'dart:collection';

import 'package:flutter/material.dart';

abstract class FareWatchRepository {
  Future<void> watch(String offerId);
  Future<Set<String>> watchedIds();
}

class FlightResultsViewModel extends ChangeNotifier {
  FlightResultsViewModel({required FareWatchRepository watches})
      : _watches = watches;
  final FareWatchRepository _watches;

  List<String> _watched = [];
  UnmodifiableListView<String> get watched => UnmodifiableListView(_watched);

  Future<void> watchFare(String offerId) async {
    await _watches.watch(offerId); // the change happens in the repository
    _watched = (await _watches.watchedIds()).toList();
    notifyListeners(); // state flows back up
  }
}

class WatchedFaresView extends StatelessWidget {
  const WatchedFaresView({super.key, required this.viewModel});
  final FlightResultsViewModel viewModel;

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: viewModel,
      builder: (context, _) => Column(
        children: [
          for (final id in viewModel.watched) Text(id),
          TextButton(
            onPressed: () => viewModel.watchFare('LHR-JFK-0915'),
            child: const Text('Watch fare'),
          ),
        ],
      ),
    );
  }
}

go deeper

for a junior

Recite the five steps: tap, view model, repository, view model state, rebuild. Name ChangeNotifier and ListenableBuilder as the sample's tools.

for a middle

Explain why only the repository changes data, what UnmodifiableListView does and does not protect, and what ListenableBuilder's child saves.

for a senior

Diagnose flows that break the direction, such as a view writing to view model state or a repository holding a BuildContext, and the stale-data bugs they cause.

for a principal

Discuss keeping the same one-way flow when a team swaps ChangeNotifier for streams, Riverpod or BLoC across features.

## The loop the guide describes Flutter's architecture guide recommends **unidirectional data flow**: state moves from the data layer up to the widgets, and user events move the other way. Its loop has five steps: 1. **UI layer.** A gesture fires a widget callback, which calls a method on the view model. 2. **View model.** It calls the repository method that knows how to change the data. 3. **Data layer.** The repository changes the data, if needed, and returns the new data. 4. **View model.** It stores its new UI state and notifies the view. 5. **UI layer.** The view rebuilds and shows the new state. The key rule is that **data changes happen only in the repository**, the source of truth. A view model that edits its own copy and hopes to sync later has broken the flow. ## Flutter's pieces in that loop The guide's sample uses only SDK classes: | Step | Flutter mechanism | |---|---| | View calls the view model | an `onPressed` or `onDismissed` callback calling a view model method | | View model announces a change | it `extends ChangeNotifier` and calls `notifyListeners()` | | View reacts | `ListenableBuilder(listenable: viewModel, builder: ...)` | | View cannot mutate state | private fields plus getters; lists exposed as `UnmodifiableListView` | `ChangeNotifier` is a Flutter foundation class that implements `Listenable`. `ListenableBuilder` subscribes to any `Listenable` and calls its `builder` again whenever the listenable notifies. Its optional `child` argument is built once and passed into `builder`, so an expensive subtree that does not depend on the view model is not rebuilt. The guide also names Riverpod, BLoC and streams as valid replacements for `ChangeNotifier`; the direction of flow is the part that does not change. ## A flight-search round trip The user taps a star to watch a fare: - `FlightResultsScreen` calls `viewModel.watchFare(offerId)`. - `FlightResultsViewModel.watchFare` calls `_fareWatchRepository.watch(offerId)`. - `FareWatchRepository` sends the change through its service and updates its cached set of watched ids. - The view model reloads its watched ids from the repository and calls `notifyListeners()`. - The `ListenableBuilder` around the results list re-runs `builder`, and the star fills in. When the repository instead polls the fares API and sees a lower price, only the second half runs: repository to view model, then `notifyListeners()`, then a rebuild. ## Why the view gets an unmodifiable view The guide's sample exposes its list through a getter that returns `UnmodifiableListView(_bookings)`. That wrapper, from `dart:collection`, throws if the view tries to add or remove items, so the view cannot sneak a change past the view model and repository. It is a *view* over the private list, not a copy and not deep immutability: the view model's own changes show through, and the elements themselves are only as immutable as their classes. That is why the guide also recommends **immutable data models**. ## What breaks the direction - A widget writes to `viewModel.offers` directly and then asks for a rebuild. - A repository holds a reference to a widget or a `BuildContext` to push updates. - A view model keeps an edited copy of repository data as the truth. - A view calls a service to save something and the repository never hears about it.

  • What is ListenableBuilder's child parameter for?
    `child` is a widget built once by the parent and handed to `builder` on every notification. Put a subtree there when it does not depend on the view model, so each `notifyListeners()` re-runs only the part that reads its state.
  • How would polled data from the repository reach a view model that is not calling it?
    The repository exposes a `Stream` (or is itself a `Listenable`) and the view model subscribes, updates its state on each event and calls `notifyListeners()`. The guide's example is a user-profile repository whose stream emits on sign-in and sign-out.

saying these in an interview costs you the question

  • The view should edit the view model's list and then call notifyListeners.
  • UnmodifiableListView makes the list's elements immutable too.
  • Repositories push updates by calling setState on the widgets.
  • ListenableBuilder also rebuilds the widget passed as its child.
  • A view model may keep its edited copy as the truth and sync later.