skip to content

In flutter_redux, what does StoreConnector's converter do, and why must the view model implement == when distinct is true?

level: middleimportance: must knowfreq 34%

answer

  1. store in, view model out
  2. runs on every onChange
  3. distinct defaults to false
  4. compared with != against the last view model
  5. StoreBuilder hands over the whole Store

basics

~20 s

The converter maps the Store to a small view model and runs on every store change. With distinct: true, StoreConnector rebuilds only when the new view model != the previous one, so the view model needs value equality.

solid answer

~40 s

`StoreConnector<S, VM>` takes a `converter` of type `VM Function(Store<S> store)` and a `builder` of type `Widget Function(BuildContext, VM)`. The converter runs in `initState`, on `didUpdateWidget`, and on every event of `store.onChange`, turning the global state into exactly what this widget shows — say a till's total and item count, plus callbacks that dispatch. `distinct` defaults to `false`, so the connector rebuilds on every dispatch, even one that leaves the state unchanged. With `distinct: true` it compares the new view model with the last one using `!=` and skips the rebuild when they are equal — which only works if the view model class overrides `==` and `hashCode` over its data fields. Otherwise identity comparison makes every new instance different. `StoreBuilder` skips the converter and passes the whole `Store`, so it rebuilds on every change.

code

dart · 14 lines
dart
import 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';

// A dispatch-only button: it needs the store once and never rebuilds.
Widget clearButton() => StoreConnector<PosState, VoidCallback>(
      rebuildOnChange: false,
      converter: (store) => () => store.dispatch(CartCleared()),
      builder: (context, onClear) =>
          TextButton(onPressed: onClear, child: const Text('Clear cart')),
    );

class PosState {}

class CartCleared {}

go deeper

for a junior

Remember that the converter turns the store into a view model and the builder draws from that view model only.

for a middle

Explain when the converter runs, the distinct default, why == and hashCode must cover the data but not the callbacks, and how StoreBuilder differs.

for a senior

Diagnose a screen that rebuilds on every dispatch: missing distinct, a view model without value equality, callbacks in ==, or an expensive converter running for every connector.

for a principal

Weigh the cost model of one store fanning out to many converters against scoped state, and set conventions for view-model equality across a team.

## Why a converter exists A Redux app keeps **all** state in one store, but a widget needs a small slice. flutter_redux's `StoreConnector<S, ViewModel>` separates the two: - **`converter`** — `ViewModel Function(Store<S> store)` — builds a view model from the store. - **`builder`** — `Widget Function(BuildContext context, ViewModel vm)` — builds widgets from that view model only. In a point-of-sale till, the total panel might use a `TotalVm` with `totalCents`, `itemCount` and an `onClear` callback that dispatches `CartCleared()`. The builder never touches the store, which makes it easy to test with a hand-made view model. ## When the converter runs 1. In **`initState`**, after `onInit`, to produce the first view model. 2. On **every event of `store.onChange`**, unless `ignoreChange` returns true for that state. 3. In **`didUpdateWidget`**, when the parent rebuilds the connector, so the builder always sees the latest data. Because, by default, the converter runs for every connector on every dispatch, it should be cheap: read fields and build a small object, not sort a thousand products. ## distinct: rebuild only when the view model changes | `distinct` | What happens on `onChange` | Needs `==` on the view model? | |---|---|---| | `false` (default) | the builder runs for every store change, even if the state is equal | no | | `true` | the new view model is compared with the last one using `!=`; equal ones are dropped | yes | flutter_redux deliberately does its own comparison instead of using `Stream.distinct`, because it has to compare against the view model built in `initState`, which never went through the stream. ## Why == matters Dart's default `==` is **identity**. The converter creates a new `TotalVm` every time, so without an override every comparison says "different" and `distinct: true` saves nothing. The fix: - override `==` and `hashCode` over the **data** fields (`totalCents`, `itemCount`); - leave **callbacks** out of equality — a closure created inside the converter is a new object each time, so including it would make every view model unequal again; - or use a record or a value-equality helper for the data part. `distinct` also gates the change callbacks: with `distinct: true`, `onWillChange` and `onDidChange` fire only when the view model actually changed. ## Other knobs on the connector - **`rebuildOnChange`** (default `true`): set to `false` for a widget that only needs the store once — for example a button that dispatches — so it never rebuilds on changes. - **`ignoreChange`**: a `bool Function(S state)` test; returning `true` skips conversion for that state, for example while an exit animation shows data that has just been removed. ## StoreBuilder versus StoreConnector `StoreBuilder<S>` is a convenience wrapper: its builder receives the **whole `Store<S>`**, because it is a `StoreConnector` whose converter returns the store itself. It has no `distinct` option, and since the store instance never changes, comparing it would be pointless anyway — so a `StoreBuilder` rebuilds on every change. flutter_redux's own docs call a dedicated view model with `StoreConnector` the better practice. ```dart import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:redux/redux.dart'; class TotalVm { TotalVm({required this.totalCents, required this.itemCount, required this.onClear}); final int totalCents; final int itemCount; final VoidCallback onClear; // excluded from equality @override bool operator ==(Object other) => other is TotalVm && other.totalCents == totalCents && other.itemCount == itemCount; @override int get hashCode => Object.hash(totalCents, itemCount); } Widget totalPanel() => StoreConnector<PosState, TotalVm>( distinct: true, converter: (Store<PosState> store) => TotalVm( totalCents: store.state.totalCents, itemCount: store.state.items.length, onClear: () => store.dispatch(CartCleared()), ), builder: (context, vm) => Text('${vm.itemCount} items: ${vm.totalCents}'), ); class PosState { PosState(this.totalCents, this.items); final int totalCents; final List<String> items; } class CartCleared {} ```

  • A view model includes an onClear callback created inside the converter and is compared with distinct: true. What goes wrong?
    If the callback is part of `==`, every conversion creates a new closure, so two view models with the same data still compare unequal and the connector rebuilds on every dispatch. Keep callbacks out of `==` and `hashCode`, and compare only the data fields.
  • Does a StoreConnector with the default distinct: false rebuild when an action leaves the state unchanged?
    Yes, in the pinned flutter_redux 0.6.0 tests a dispatch that produces the same state still rebuilds a default connector, because the store emits on onChange for each dispatch and no comparison is made. `distinct: true` with a value-equal view model suppresses that rebuild.

saying these in an interview costs you the question

  • distinct defaults to true, so StoreConnector only rebuilds on real changes.
  • distinct compares the whole store state, so the view model needs no equality.
  • Including the dispatch callbacks in the view model's == keeps it accurate.
  • StoreBuilder with distinct: true is the cheaper choice for a big screen.
  • The converter runs once, when the StoreConnector is first built.