Skip to content

Models

Model is the base class for structured data in XWidget. A model wraps a data map with typed getters, converts and reshapes raw source data as it loads, and plugs directly into EL — a fragment can read ${content.title} from a Content model the same way it reads any dependency. Use models for shaped data like API responses and form state; use plain Dependencies values for simple glue. For when to reach for which, see State and Reactivity.

A minimal model is a class with typed accessors over the underlying map:

class Topic extends Model {
  Topic(super.data, {super.translation, super.immutable});

  String get key => getValue("key!");
  String get label => getValue("label!");
  String? get rank => getValue("rank");

  set rank(String? rank) => setValue("rank", rank);
}

Null Safety

A trailing ! in a property path asserts the value is present: getValue("key!") throws immediately when key is missing, instead of returning null and failing somewhere downstream. Paths without ! return null for missing values, so nullable getters read naturally.

final topic = Topic({"label": "Sports"});
topic.rank;   // null — no error
topic.key;    // throws — "key" is required but missing

Instance Management

By default, constructing a model twice from the same source data produces two separate objects. The instance factories give you one shared object instead, so every part of the app that loads the same entity sees — and mutates — the same model.

singleInstance

One instance per model type. Subsequent calls return the existing instance, with the new data merged in.

class Session extends Model {
  Session._(super.data, {super.translation, super.immutable});

  factory Session(
    Map<String, dynamic> data, {
    PropertyTranslation? translation,
    bool? immutable,
  }) {
    return Model.singleInstance<Session>(
      factory: Session._,
      data: data,
      translation: translation,
      immutable: immutable,
    );
  }
}

keyedInstance

One instance per key — for entity types where many instances exist, but each entity should exist once. Mark the key property with isKey: true in the model's registration:

Models.register<Topic>(Topic.new, const [
  PropertyTransformer<String>("key", isKey: true),
  PropertyTransformer<String?>("label"),
]);

class Topic extends Model {
  Topic._(super.data, {super.translation, super.immutable});

  factory Topic(
    Map<String, dynamic> data, {
    PropertyTranslation? translation,
    bool? immutable,
  }) {
    return Model.keyedInstance<Topic>(
      factory: Topic._,
      data: data,
      translation: translation,
      immutable: immutable,
    );
  }
}

hasInstance and clearInstances

Model.hasInstance<Topic>("sports");   // true if the keyed instance exists
Model.clearInstances<Topic>();        // drop all Topic instances
Model.clearInstances<Topic>("sports") // drop one keyed instance

hasInstance<T>([key]) reports whether an instance is currently stored. clearInstances<T>([key]) removes stored instances — all of a type, or a single keyed one — so the next factory call builds fresh objects. Clear instances when the underlying data changes wholesale, such as after logout.

Loading Data

Source data rarely matches your model's shape: keys differ, values arrive as strings, nested objects come flattened. Two classes handle the reshaping as data loads: PropertyTransformer declares what your model's properties are; PropertyTranslation maps where the source data comes from.

The examples below share this setup:

Models.register<Content>(Content.new, const [
  PropertyTransformer<String>("title"),
  PropertyTransformer<String?>("summary"),
  PropertyTransformer<List<Image>>("images"),
]);

Models.register<Image>(Image.new, const [
  PropertyTransformer<String>("url"),
  PropertyTransformer<String?>("caption"),
  PropertyTransformer<bool>("active", defaultValue: true),
]);

class Content extends Model {
  Content(super.data, {super.translation, super.immutable});
}

class Image extends Model {
  Image(super.data, {super.translation, super.immutable});
}

PropertyTransformer

Each PropertyTransformer declares one property: its name, type, and optional default. Registering them with Models.register makes them apply automatically every time an instance is created — values are converted to the declared types on the way in:

final image = Image({"url": "https://example.com/a.jpg", "active": "false"});

image.getValue("active");   // false — the String "false" became a bool

Natively supported property types:

  • Any registered Model subclass
  • String, int, double, bool
  • Color, DateTime, Duration
  • List, Set, and Map — prefer a Model subclass over a raw Map when the structure is known
  • Custom types via Type Converters

List<List> is not well supported at the moment.

PropertyTranslation

PropertyTranslation maps source keys to model properties. Unmapped source keys load under their own names, so you only list the differences.

Rename flat keys:

final content = Content({
  "headline": "Hello World",
  "summary": "Basic App",
}, translation: PropertyTranslation({
  "headline": "title",
}));

content.getValue("title");   // Hello World
content.getValue("summary"); // Basic App — unmapped keys load as-is

Build a nested model from flattened source keys by mapping into a dotted path:

final content = Content({
  "title": "Hello World",
  "imageUrl": "https://example.com/a.jpg",
  "imageCaption": "Sunset",
}, translation: PropertyTranslation({
  "imageUrl": "images.url",
  "imageCaption": "images.caption",
}));

content.getValue("images[0].caption");   // Sunset — an Image model was created

Repeated mappings to the same list property append entries in order — this is how several flattened source groups become a list of models:

final content = Content({
  "title": "Hello World",
  "primaryImageUrl": "https://example.com/a.jpg",
  "secondaryImageUrl": "https://example.com/b.jpg",
  "secondaryImageCaption": "Secondary",
}, translation: PropertyTranslation({
  "primaryImageUrl": "images.url",
  "primaryImageCaption": "images.caption",
  "secondaryImageUrl": "images.url",
  "secondaryImageCaption": "images.caption",
}));

content.getValue("images[1].caption");   // Secondary

A source list of maps needs no per-entry mapping — rename the list once and each entry becomes a model:

final content = Content({
  "title": "Hello World",
  "myImages": [
    {"url": "https://example.com/1.jpg", "caption": "#1"},
    {"url": "https://example.com/2.jpg", "caption": "#2"},
  ],
}, translation: PropertyTranslation({
  "myImages": "images",
}));

content.getValue("images[1].caption");   // #2

Type Converters

While loading, values are converted to each property's declared type. Converters for String, int, double, bool, DateTime, Duration, Color, and dynamic are preregistered. Register converters for your own types with TypeConverters.register, typically in main():

main() {
  TypeConverters.register<Money>((value) {
    if (value is Money) {
      return value;
    } else if (value is String) {
      return Money.parse(value, isoCode: 'USD');
    } else if (value is int) {
      return Money.fromInt(value, isoCode: 'USD');
    } else {
      throw Exception("Unable to convert value of type ${value.runtimeType} to 'Money'");
    }
  });
}