Skip to main content

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:

  1. Value equality. == and hashCode over 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.
  2. 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. Implementing StructurallyShareable lets 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:

lib/data/invoice.dart
/// 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:

lib/data/invoice.dart
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:

lib/data/invoice_queries.dart
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:

test/invoice_sharing_test.dart
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:

lib/data/invoice_page.dart

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:

lib/data/invoice_page.dart

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​

  1. Give every model value equality: freezed, equatable, or == and hashCode written by hand.
  2. Parse JSON in the query function and cache typed models, never Map<String, dynamic>.
  3. Cache lists directly where you can. They are shared element by element with no further work.
  4. 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 with listEquals. Do not compare page.items itself with identical.
  • 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 setQueryData or updateQueryData.
  • 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 shareWith that returns previous when something changed. The cache then holds old data and nothing reports it. Build a new value from the shared parts, as above.

Variations​

  • A sealed result family. A freezed union (Loaded, Empty) where one variant wraps a list implements StructurallyShareable on the base type. The walk asks only when both values have the same runtime type, so shareWith always sees two of the same variant, and a change of variant replaces the value whole.
  • A normalised response. For byId maps, 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.

Live demoSelect and structural sharingWhat a reader rebuilds on, and what it does not.~3 MB, runs in your browser; no server involved.
In React Query

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.