Type the path and query parameters of your go_router URLs, read them as plain Dart objects, and write them back — without keeping a second copy of that state anywhere else.
The URL already holds part of your application state: the current route, its path parameters, and its query string.
- First problem: this state is often awkward to read in a typed way, because a URL is entirely a string Since dart doesn't support reflection at runtime for performance reasons, it can be hard to parse such a string into a typed object like javascript does.
- Second problem: this state doesn't belong to the state management library that you probably use (riverpod, bloc, provider, etc.), so manipulating the URL as state management requires its own project standards, which are often complex yet implicit.
For these reasons, it is tempting to replicate the url information into your state management and keep the two in sync by hand. That duplication introduces boilerplate, imperativeness (one variable "name" in your state management, one query param "initialName", side effects to sync them both), and unnecessary complexity.
In short, bugs.
go_router_url_watcher fills that gap by reusing the fromMap/toMap
methods that a data class often exposes, and defining a simple, performant
interface to read and write the URL as typed objects. The URL becomes the single
source of truth, and your widgets read it as typed objects. No boilerplate, no
duplicated state.
Use flutter pub add go_router_url_watcher, or add go_router_url_watcher to
the dependencies of your pubspec.yaml manually. You also need
go_router itself.
A class participates by mixing in UrlParamsData and providing toMap() plus a
fromMap factory. It's also a good idea to provide a copyWith method to
update the url easily:
class Person with UrlParamsData {
const Person({this.name, this.age = 0, PersonStatus? status})
: status = status ?? const PersonStatus();
final String? name;
final int age;
final PersonStatus status;
factory Person.fromMap(Map<String, dynamic> map) => Person(
name: map['name'] as String?,
age: int.tryParse(map['age']?.toString() ?? '') ?? 0,
status: map['status'] != null
? PersonStatus.fromMap(map['status'] as Map<String, dynamic>)
: null,
);
@override
Map<String, dynamic> toMap() => {
'name': name,
'age': age,
'status': status.toMap(),
};
Person copyWith({String? name, int? age, PersonStatus? status}) => Person(
name: name ?? this.name,
age: age ?? this.age,
status: status ?? this.status,
);
}
class PersonStatus with UrlParamsData {
const PersonStatus({this.isActive = true, this.labels = const []});
final bool isActive;
final List<String> labels;
factory PersonStatus.fromMap(Map<String, dynamic> map) => PersonStatus(
isActive: bool.tryParse(map['isActive']?.toString() ?? '') ?? true,
labels: (map['labels'] as List?)?.cast<String>() ?? const [],
);
@override
Map<String, dynamic> toMap() => {'isActive': isActive, 'labels': labels};
}If it sounds complex, remember that toMap/fromMap/copyWith are exactly the
methods code generators produce, so you don't have to write them by hand. The
example/lib/model_examples/ folder shows this same model generated with
dart_mappable, freezed, and json_serializable.
dart_mappable is more recommended because it has the exact default behavior
this package needs. freezed and json_serializable need the
UrlParamsData.tryParse, UrlParamsData.readObjectFromString and
UrlParamsData.writeObjectToJson methods, which can be tricky to use. If you
want to use freezed or json_serializable, please look at the
example/lib/model_examples/ folder.
UrlParamsScope subscribes to the router and republishes its parameters.
Register one UrlParamBuilder per type any widget will read:
MaterialApp.router(
routerDelegate: router.routerDelegate,
routeInformationParser: router.routeInformationParser,
routeInformationProvider: router.routeInformationProvider,
builder: (context, child) => UrlParamsScope(
router: router,
builders: [
UrlParamBuilder<Person>(Person.fromMap),
],
child: child!,
),
)// Read. Rebuilds the widget only when the Person slice of the URL changes.
final person = context.watchUrlParams<Person>();
// Write. Updates the URL, which rebuilds the widgets that read it.
context.setUrlParams(person.copyWith(age: person.age + 1));watchUrlParams<T>() returns null when no Person can be parsed from the
current URL, so you should pay attention to that case.
Globally, you can consider that anything can be in the URL (especially in web). Be really careful with nullability and default values.
A widget that uses watchUrlParams<T>() to read a type T rebuilds only when
the params needed to build T change; it does not rebuild when an unrelated
field changes, even though both live in the same URL. Parsing is cached per type
for the lifetime of a URL, so a registered builder runs at most once per URL
change regardless of how many widgets read that type.
UrlParamsData.toQueryParamsString turns a UrlParamsData into a query string
for context.go. Use keysToIgnore/keysToInclude to control which fields are
serialized (for instance, to drop a field that is already a path parameter), and
pass currentUri to merge with the query params already present, if you don't
want the rest of your state to be lost:
context.go(
'/counter/${person.name}'
'${person.toQueryParamsString(keysToIgnore: ["name"], currentUri: context.uri)}',
);context.uri is a shortcut for the router's current Uri.
Path parameters and query parameters share one flat namespace when written. Do not give a path parameter the same name as a query parameter: if both exist, the value is assigned to the path parameter and the query parameter stays empty. If you genuinely need overlapping names, drop down to the lower-level API below.
If two registered types expose colliding field names, or if you want to register
to a sub-slice of a type, you can give a prefixKey in the UrlParamBuilder.
Example:
UrlParamsScope(
router: router,
builders: [
UrlParamBuilder<Person>(Person.fromMap),
UrlParamBuilder<PersonStatus>(PersonStatus.fromMap, prefixKey: "status"),
],
child: child!,
),With prefixKey: "status", the query param corresponding to
PersonStatus.isActive is written as ?status.isActive=true instead of
?isActive=true.
When you do not want to model a whole object, read one parameter at a time.
These also require a UrlParamsScope ancestor and rebuild only when that
specific key changes:
final age = context.watchQueryParamFromKey<int>('age');
final personName = context.watchPathParamFromKey<String>('name');
final favoriteFlavor = context.watchQueryParamFromKey<FlavorEnum>('flavor', parseFromString: (value) => Flavor.values.byName(value));Supported types by default: String, int, double, bool, DateTime, and
(for query params) a List of these. For other types, you can provide a
parseFromString function to parse the value from String to the requested type.
A value that cannot be parsed to the requested type returns null rather than
throwing — reading age as a bool gives null.
go_router exposes repeated query keys (?tag=a&tag=b) as a list. Read them with
a List type to get every value; reading a scalar type keeps the last value:
final tags = context.watchQueryParamFromKey<List<String>>('tag'); // somePath?tag=a&tag=b --> ['a', 'b']
final ids = context.watchQueryParamFromKey<List<int>>('id'); // null if no value is parsable to an intsetUrlParamsFromMap writes raw query and path maps, bypassing the typed object
layer. Pair it with the single-key readers.
context.setUrlParamsFromMap(
queryParams: {'tag': ['a', 'b']},
pathParams: {'name': 'Rico'},
);
