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
Modelsubclass String,int,double,boolColor,DateTime,DurationList,Set, andMap— prefer aModelsubclass over a rawMapwhen 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'");
}
});
}