Simple Routes
Simple, type-safe route and navigation management for go_router.
Features
By defining your routes and route structure using Dart classes, you gain powerful tools to help you build and manage your app's navigation.
- Eliminate "magic strings"
- Enforce route parameter requirements
- Inject and extract route and query parameters
- Navigate without building custom strings
- Determine the current route and its ancestors
The primary focus of this package is to provide a simple interface for triggering navigation within the app.
It boils down to a simple go
method:
const MySimpleRoute().go(context);
Or, for more complicated routes:
const MyNestedRouteWithParams().go(context, data: MyRouteData('some-value'));
Push
The go
method also supports the "push" navigation type.
To use this, just set the push
named argument to true
.
const MySimpleRoute().go(context, push: true);
Getting started
This package is intended to be used with the GoRouter package.
dependencies:
go_router: ^12.0.0
simple_routes: ^0.0.11
Usage
Defining simple routes
This first section will describe how to use the SimpleRoute
class and ChildRoute
interface to define routes that do not require any dynamic variables, i.e. "static" routes like /home
or /settings/notifications
.
For routing that requires path parameters, see the Defining dynamic routes section below.
Static routes should extend the SimpleRoute
base class, such as the example HomeRoute
below.
class HomeRoute extends SimpleRoute {
const HomeRoute();
@override
String get path => '/home';
}
When extending the SimpleRoute
base class, you must override the path
property.
Note: Root level routes should have a path that is prefixed with a forward slash (/
).
This value is used to define the path
of a GoRoute
.
GoRoute(
path: const HomeRoute().path,
pageBuilder: (context, state) => const HomePage(),
),
By extending SimpleRoute
, your route will inherit a go
method that makes navigation, well, simple.
ElevatedButton(
onPressed: () => const HomeRoute().go(context),
child: const Text('Go to Home'),
),
Nested (child) routes
Any nested (AKA "Child") routes should implement the ChildRoute
interface.
class SettingsRoute extends SimpleRoute implements ChildRoute<HomeRoute> {
const SettingsRoute();
@override
String get path => 'settings';
@override
HomeRoute get parent => const HomeRoute();
}
Note: Child routes should have a path that is not prefixed with a slash.
Along with providing the path
for this route, when implementing the ChildRoute
interface, you must also implement the parent
property.
The go
method will automatically build the full path for your route, based on its path
and the path
s of its parents.
ElevatedButton(
onPressed: () => const SettingsRoute().go(context),
child: const Text('Go to Settings'),
),
Simple! Now let's look at how to handle routes that require dynamic data.
Defining dynamic routes
Before we define our dynamic route, we need to do a little bit of setup:
Parameters
The first rule is that all parameters must be defined as an enum.
enum RouteParams {
userId,
}
This helps eliminate "magic strings" and, therefore, reduce the opportunity for errors and hard-to-track-down bugs.
Data classes
Secondly, we need to define a class that represents the data needed by our new route. This new class should extend the SimpleRouteData
class.
class UserRouteData extends SimpleRouteData {
const UserRouteData({required this.userId});
final String userId;
@override
String inject(String path) {
return path.setParam(
RouteParams.userId,
userId,
);
}
}
By extending the SimpleRouteData
base class, we must implement the inject
method. This is how the templated parameters in the path are replaced with the actual values. Note the String.setParam
extension - it is recommended to utilize this function anytime you inject a value.
This extension takes an enum value as its first argument and a String value as its second argument.
String setParam<E extends Enum>(E enum, String value);
Dynamic routes
Finally, your route class should extend the DataRoute
class, typed for the data class we just created.
class UserRoute extends DataRoute<UserRouteData> {
const UserRoute();
@override
String get path => join(['/user', withPrefix(RouteParams.userId)]);
}
Note: Just like the SimpleRoute
s above, any root-level paths should be prefixed with a forward slash; any child routes should not be prefixed.
Utility functions
We used a couple utility functions in this example, so let's take a moment to break it all down.
First, we can see that the route class itself extends DataRoute
with the appropriate SimpleRouteData
as the generic type.
Next, we create the path
value in a more interesting way. We use the join
utility method to build a path from the path segments - This is the recommend way of defining paths with multiple segments (again, to reduce the chance of error).
Inside the join
we use the withPrefix
extension method to convert the enum value to the path template String. This is the recommended way of converting your enum values to their path template values.
In this example, the path
would become /user/:userId
.
Using dynamic routes
Now that we have our route defined, let's see how to use it!
ElevatedButton(
onPressed: () => const UserRoute().go(
context,
data: UserRouteData(userId: '123'),
),
child: const Text('Go to User'),
),
As you can see, the go
method now requires an instance of your route's data class. When invoked, the data will be injected into the full path (via your inject
method).
Children of DataRoutes
One caveat with this structure is that any children of a DataRoute
must also be a DataRoute
to supply the data to its parents.
For example, if we have a route - /users/:userId/settings/mfa
- the settings
route and the mfa
route will both need the userId
value to generate their full route; therefore, they will need to accept a data object containing that value.
For cases like this example, where the same piece of data is needed, these child classes can simply re-use the parent's data class.
class UserSettingsRoute extends DataRoute<UserRouteData> implements ChildRoute<UserRoute> {
const UserSettingsRoute();
@override
String get path => 'settings';
@override
UserRoute get parent => const UserRoute();
}
class MfaSettingsRoute extends DataRoute<UserRouteData> implements ChildRoute<UserSettingsRoute> {
const MfaSettingsRoute();
@override
String get path => 'mfa';
@override
UserSettingsRoute get parent => const UserSettingsRoute();
}
Then, when invoking navigation to either of these routes, you can pass the same data object.
ElevatedButton(
onPressed: () => const UserSettingsRoute().go(
context,
data: UserRouteData(userId: '123'),
),
child: const Text('Go to User Settings'),
),
ElevatedButton(
onPressed: () => const MfaSettingsRoute().go(
context,
data: UserRouteData(userId: '123'),
),
child: const Text('Go to MFA Settings'),
),
If a child route requires its own data in addition to its parent's data, you have two options:
- Create a new data class that extends the parent's data class and adds the value(s) you need
- Create a totally new data class
While option 1 is certainly easy, it is not recommended. By extending the parent's data class, you will lose some of the compile-time type-checking that the DataRoute
class would otherwise provide.
If you do use Option 1, make sure to implement the inject
method (the compiler will not yell at you if you do not). You can call super.inject(path)
to inject the parent's data into the path, then inject your new value(s).
class MyRouteData extends UserRouteData {
const MyRouteData({
super.userId,
required this.someValue;
});
final String someValue;
@override
String inject(String path) {
return super.inject(path).setParam(
RouteParams.someValue,
someValue,
);
}
}
Data Factories
Another useful utility is the SimpleRouteDataFactory
class. By extending this class, you can define a factory that can safely extract the route data from GoRouterState
.
class UserRouteDataFactory extends SimpleRouteDataFactory<UserRouteData> {
const UserRouteDataFactory();
@override
UserRouteData fromState(GoRouterState state) {
return UserRouteData(
userId: state.params[RouteParams.userId]!,
);
}
@override
bool containsData(GoRouterState state) {
return containsKey(state, RouteParams.userId);
}
}
The fromState
method is useful within your route configuration for extracting the route data from the GoRouterState
.
The containsData
method must also be implemented; it gives you a way to validate whether all necessary parameters are present in the GoRouterState
.
Also note the containsKey
helper method. This utility checks whether the GoRouterState
contains a particular parameter key (i.e. enum value name).
Using a DataFactory
GoRoute(
path: const UserRoute().path,
redirect: (context, state) {
if (!const UserRouteDataFactory().containsData(state)) {
return const HomeRoute().path;
}
return null;
},
builder: (context, state) {
final routeData = const UserRouteDataFactory().fromState(state);
return UserScreen(
userId: routeData.userId,
);
},
),
A useful pattern is to check the validity of the state in a redirect
, thus ensuring that the state is valid before attempting to extract the route data object and build the screen in the builder
.
Query parameters
As of v0.0.3, this package supports injecting and extracting query parameters.
Injecting query parameters
Injecting query parameters into your route is easy. When calling the go
method, just add a Map<String, String>
to the query
argument.
ElevatedButton(
onPressed: () => const MyRoute().go(context, query: {'key': 'value'}),
),
Extracting query parameters
The query parameters live on a Uri
instance on the GoRouterState
. You can access this map yourself using GoRouterState.uri.queryParameters
.
Or, you can use the getQueryParams
convenience function. This method is a wrapper around the queryParameters
property and just serves to make it easier to access.
GoRoute(
path: const MyRoute().path,
builder: (context, state) => MyScreen(data: getQueryParams(state)['someKey']),
),
Route Checking
Current Route
As of 0.0.7, you can use the isCurrentRoute
method, available on all SimpleRoutes, to check whether the current route is a match for the given route.
This works for DataRoutes, too!
For example, if we have a simple route structure like:
class BaseRoute extends SimpleRoute {
const BaseRoute();
@override
String get path => '/base';
}
class SubRoute extends SimpleRoute implements ChildRoute<BaseRoute> {
const SubRoute();
@override
String get path => 'sub';
@override
BaseRoute get parent => const BaseRoute();
}
And we are at the screen for the SubRoute
with a location of /base/sub
. We can easily check whether the current route is the SubRoute
by calling isCurrentRoute
:
// current location: '/base/sub'
if (const SubRoute().isCurrentRoute(context) /* true */) {
debugPrint('We are at SubRoute!');
}
Ancestor Route
As of 0.0.7, you can use the isAncestorRoute
method, available on all SimpleRoutes, to check whether the current route is an ancestor of the given route. This is similar to isCurrentRoute
(see section above), except that the current route must be a descendant of the route in question.
For example, in our simple route structure from the previous section, we can check whether the current route is a descendant of BaseRoute
by calling isAncestorRoute
:
// current location: '/base/sub'
if (const BaseRoute().isAncestorRoute(context) /* true */) {
debugPrint('We are at a descendant of BaseRoute!');
}
However, this method will return false
if the current route is an exact match for the route in question.
For example, if we are at the screen for the SubRoute
and use isAncestor
, it will return false
;
// current location: '/base/sub'
if (const SubRoute().isAncestor(context) /* false */) {
...
}
Useful Tips
Static instances
If you don't liking creating instances of your routes all over the place, you can create a static instance of each route and use that instead.
class HomeRoute extends SimpleRoute {
const HomeRoute();
@override
String get path => '/home';
static const instance = HomeRoute();
}
Then:
GoRoute(
path: HomeRoute.instance.path,
...
),
ElevatedButton(
onPressed: () => HomeRoute.instance.go(context),
child: const Text('Go to Home'),
),
Route getters
Another useful pattern is to create a getter for each child route on its parent route.
class HomeRoute extends SimpleRoute {
const HomeRoute();
@override
String get path => '/home';
// make it static
static DashboardRoute get dashboard => const DashboardRoute();
// or make it an instance method
SettingsRoute get settings => const SettingsRoute();
}
Then:
GoRoute(
path: const HomeRoute().path,
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
// static getter
path: HomeRoute.dashboard.path,
builder: (context, state) => const DashboardScreen(),
),
GoRoute(
// instance method
path: const HomeRoute().settings.path,
builder: (context, state) => const SettingsScreen(),
),
],
),
ElevatedButton(
onPressed: () => HomeRoute.dashboard.go(context),
),
ElevatedButton(
onPressed: () => const HomeRoute().settings.go(context),
),