Models with freezed and JSON
The problem. The app's models are generated by freezed and parsed with json_serializable, as in most Flutter codebases. The queries return them, and everything seems to work. But the rows of a list screen rebuild on every refetch, even when nothing in them changed. What does a model need so that the cache can recognise what did not change?
The answer. Two things, both easy to get:
- Value equality.
==andhashCodeover the fields. Structural sharing compares with==, and a class without it is never equal to its previous version, so every fetch counts as a change. freezed generates both. - Taking part in sharing when a class wraps a collection. A class is a
leaf: it is kept whole or replaced whole. A
Page(items: [...])in which one item changed gets new instances for every item. ImplementingStructurallyShareablelets the walk go inside it.
What freezed generates, written out
This is the shape freezed produces, written by hand. It is useful for seeing what the cache relies on:
/// What freezed generates, in essence — and what a model needs either way.
class Invoice {
const Invoice(
{required this.id, required this.customer, required this.cents});
factory Invoice.fromJson(Map<String, Object?> json) => Invoice(
id: json['id']! as String,
customer: json['customer']! as String,
cents: json['cents']! as int,
);
final String id;
final String customer;
final int cents;
bool operator ==(Object other) =>
other is Invoice &&
other.id == id &&
other.customer == customer &&
other.cents == cents;
int get hashCode => Object.hash(id, customer, cents);
}
The freezed version of the same model, for freezed 3 and json_serializable:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'invoice.freezed.dart';
part 'invoice.g.dart';
abstract class Invoice with _$Invoice {
const factory Invoice({
required String id,
required String customer,
required int cents,
}) = _Invoice;
factory Invoice.fromJson(Map<String, Object?> json) =>
_$InvoiceFromJson(json);
}
The query parses at the edge, so the cache only ever holds typed models:
QueryObserverOptions<List<Invoice>> invoicesQuery(Dio dio) =>
QueryObserverOptions(
queryKey: QueryKey(<Object?>['invoices']),
queryFn: (context) async {
final response = await dio.get<List<Object?>>('/invoices');
return <Invoice>[
for (final json in response.data!)
Invoice.fromJson(json! as Map<String, Object?>),
];
},
);
A List<Invoice> in the cache needs nothing more. Lists are shared element
by element, so a refetch where one invoice changed keeps every other
invoice's instance.
A wrapper is a leaf
Wrap that list in a class, as a paged API suggests, and the sharing stops at the class. The measurement:
final before = InvoiceList([invoice('i1', 100), invoice('i2', 200)]);
// A refetch in which only i2 changed:
final after = replaceEqualDeep(
before,
InvoiceList([invoice('i1', 100), invoice('i2', 250)]),
);
// i1 is equal, but it is not the instance the cache held:
expect(identical(after.items[0], before.items[0]), isFalse);
Nothing looks broken. == still holds for i1, and the screen shows the
right data. What is lost is identical, and with it every rebuild skipped
because an item is the same instance as before.
Letting the walk inside
Implement StructurallyShareable and hand the walk the list:
class InvoicePage implements StructurallyShareable<InvoicePage> {
const InvoicePage({required this.items, required this.total});
final List<Invoice> items;
final int total;
InvoicePage shareWith(InvoicePage previous) => InvoicePage(
items: replaceEqualDeep(previous.items, items),
total: total,
);
bool operator ==(Object other) =>
other is InvoicePage &&
other.total == total &&
listEquals(other.items, items);
int get hashCode => Object.hash(total, Object.hashAll(items));
}
With freezed, a private constructor lets the class have members of its own.
copyWith then keeps every other field as it is:
abstract class InvoicePage with _$InvoicePage
implements StructurallyShareable<InvoicePage> {
const InvoicePage._();
const factory InvoicePage({
required List<Invoice> items,
required int total,
}) = _InvoicePage;
factory InvoicePage.fromJson(Map<String, Object?> json) =>
_$InvoicePageFromJson(json);
InvoicePage shareWith(InvoicePage previous) =>
copyWith(items: replaceEqualDeep(previous.items, items));
}
shareWith is only called when the two pages are not equal, so it never
has to check for "nothing changed" itself.
Steps
- Give every model value equality: freezed,
equatable, or==andhashCodewritten by hand. - Parse JSON in the query function and cache typed models, never
Map<String, dynamic>. - Cache lists directly where you can. They are shared element by element with no further work.
- For a class that wraps a collection, implement
StructurallyShareable, and check once, as in the measurement above, that an unchanged item keeps its instance.
Traps
- Comparing freezed collections by identity. freezed returns its lists
wrapped in an unmodifiable view, and the view can be a new object on each
access. Compare the items with
identical, or the lists withlistEquals. Do not comparepage.itemsitself withidentical. - Mutable models. A model with a setter that changes a field in place
changes the cached instance, and nothing notifies anyone. Keep models
immutable, as freezed does by default, and write changes through
setQueryDataorupdateQueryData. - Caching raw JSON. A
Map<String, dynamic>is kept whole or replaced whole, so one changed field replaces everything, and every reader has to parse. Parse once in the query function. - A
shareWiththat returnspreviouswhen something changed. The cache then holds old data and nothing reports it. Build a new value from the shared parts, as above.
Variations
- A
sealedresult family. A freezed union (Loaded,Empty) where one variant wraps a list implementsStructurallyShareableon the base type. The walk asks only when both values have the same runtime type, soshareWithalways sees two of the same variant, and a change of variant replaces the value whole. - A normalised response. For
byIdmaps, see Normalised data or one key per entity. - Turning sharing off. For very large payloads that always change, see Structural sharing.
See it run
In the select and structural sharing demo, five readers show one cache
entry, and each counts its data builds: the builds where its value was not
== to the one before. Press Refetch. Equal data comes back, and no count
moves. Press Rename todo 2, and only the reader that holds the list of texts
moves, with the control reader that has no select.
Then switch Structural sharing off and press Refetch again. The data is
equal, but the list is now a new instance, and the readers that hold a list
count a build for nothing. A wrapper class without StructurallyShareable
has the same effect on every item inside it.
TanStack Query's structural sharing walks plain objects and arrays, which
covers most JSON responses. A class instance is a leaf there too. The
difference is that Dart models are always classes, so the wrapper case that
is rare in JavaScript is common here, and StructurallyShareable is how a
class takes part. See differences from TanStack
Query.