app_state vs go_router for Flutter navigation
app_state and go_router are two packages that use Router for parsing URLs, converting them to the state of your app, managing screens, updating the address bar when the state changes, handling deep linking and other things.
Both packages cover basic navigation cases but differ in support of complex scenarios.
TL;DR
go_router is simpler, but is very limited, app_state covers more complex cases, drives a better decomposition and comes with state management out of the box.
Boilerplate
go_router: You declare a list of routes that map path to widgets, then pass this router to your MaterialApp
or any other app widget:
final _router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const Page1Screen(),
),
GoRoute(
path: '/page2',
builder: (context, state) => const Page2Screen(),
),
],
);
class App extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp.router(
routeInformationProvider: _router.routeInformationProvider,
routeInformationParser: _router.routeInformationParser,
routerDelegate: _router.routerDelegate,
title: 'GoRouter Example',
);
}
app_state: you need 5–6 units for each route:
- The screen widget (Example).
- The optional page bloc or any other stateful object with
PageStateMixin
to handle navigation events (Example). - The page class (a placeholder to sit in the Navigator’s stack) (Example).
- Path class (it parses the URL, possibly with regexp, and generates a URL). (Example)
- A line in the URL parser (Example).
- A line in the page factory (Example).
You create Path classes with parsing methods, then chain them like this:
return
AboutPath.tryParse(routeInformation) ??
BookDetailsPath.tryParse(routeInformation) ??
const BookListPath(); // The default page if nothing worked.
The min app is 30 lines. The 2-screens example is here.
Summary: go_router is simpler.
Stack Recovery from URL
Use case: When the app is started at /books/123
, both BookListPage
and BookDetailsPage
must be in the stack, so when the foreground details are closed, the background book list is left.
go_router: You use nested routes:
final _router = GoRouter(
routes: [
GoRoute(
path: '/books',
builder: (context, state) => const BookListScreen(),
routes: [
GoRoute(
path: ':id',
builder: (context, state) => BookDetailsScreen(
id: state.params['id']!,
),
),
],
),
],
);
The nesting of routes dictates the stack you get. Each back button press will pop one page from this stack.
app_state: In BookDetailsPath
, override the getter:
@override
List<PagePath> get defaultStackPaths => [
const BookListPath(),
this,
];
When the URL is navigated and parsed, this will pre-populate the stack with any given pages.
See this example for the above GIF.
The stack you get is not limited to what you get by splitting path in segments. You can return any list of pages like/books
→ /books/fiction
→ /books/123
as long as the book 123’s path class can do a quick lookup to determine that it is fiction. Then the back button will take you back on that list.
Summary: app_state is more flexible.
Navigation
go_router: There are two options to change the page stack:
context.go('/books/123')
replaces the stack entirely. With the above setup, it will drop the older stack and put two pages on it:/books
and/books/123
.context.push('/books/123')
adds a single page on top of the stack. With the above setup, it will add the/books/123
page on top of anything that currently is there. The back button then pops that page and reveals whatever was there before.
Both ways involve merging your data to a string path which is then parsed into parameters. It means that the compiler cannot check that you navigate to an existing location and that its parameters are passed correctly. For instance, you can go to /books/abc
which will fail parsing an integer book ID.
To overcome this, there is the code generator for go_router. It allows you to navigate to a route in a type-safe manner:
BookRoute(id: 123).go(context);
To use it, you opt for class annotations for the code generator. Their boilerplate is comparable to the boilerplate of defining app_state’s Page
and PagePath
classes.
app_state: To navigate, you push pages to the PageStack
and pop them. It is declarative when filling the stack from a URL and imperative afterwards.
Pages are always pushed in a type-safe manner. This call pushes the page to the top of any currently existing stack:
pageStack.push(BookDetailsPage(id: 123));
Declarative navigation is also supported. This call replaces the entire current stack with the default stack of the book path which is likely/books
→ /books/123
:
pageStack.replacePath(BookDetailsPath(id: 123));
Summary: all the same if you add boilerplate to go_router.
Returning Results from a Dialog
go_router: If you want a result from a dialog, you have three options:
Use the Old Navigator
final result = await Navigator.push<int>(context, DialogRoute());
When you do this, the dialog will not show its own URL in the address bar because the old Flutter navigation is not aware of URLs.
Process in the Dialog
If you want a dialog with a URL, you implement it as a GoRoute
, but then you cannot await a result from it. No navigation method in go_router provides any awaiting — it goes too far in being declarative. It can only transition you between pre-declared states with a minor exception of a void push.
The documentation on this suggests that the dialog should itself handle the input and not return it. This couples the input and the processing. Such dialogs cannot be reused if you need their result for different purposes.
Pass a Callback
Another option is to pass an extra object with the push:
context.push('/dialog', extra: listenable);
Any object can be passed as the extra
argument to both push
and go
. This is not type-safe however. In your route, you must check if that argument is of the class you expect.
Also this only works with imperative navigation. If instead the URL of the dialog is typed in, the stack is re-constructed from the declaration, and the page factory has no way to pass this callback, so it makes the dialog URL meaningless.
app_state: You can await a dialog’s result, and its URL will also show:
final result = await pageStack.push(DialogPage());
As a bonus, it is type-safe at compile time. Pages have a return type parameter, so the type of result
is inferred. It is a compile-time error to await another type. And for a page state it is a compile-time error to pop with another type.
Additionally, PageStateMixin
has didPopNext
method that is called when the above page pops, and it is passed the same data. This means that it can even receive the result when the app was closed and reopened from a URL. Futures cannot do that, they do not survive the app restart.
For this reason, we do not normally await routes that have their own URLs, instead we override didPopNext
.
Run the example app from this GIF. It prints the data received both ways.
Summary: app_state is more powerful and safe.
Tabs with Independent Navigation Stacks
Use case: The app must have tabs, each one should support its own navigation stack. When the user switches from the tab and back to it, its stack should still be there, and the back button should pop from that stack.
go_router: No support. Each URL must be translated to a pre-determined widget tree of the entire app, no partial or nested routers are allowed, and this forbids background states in other tabs. A workaround is complex and defeats the point of using this package.
app_state: You create PageStacks
as your main state object. It contains multiple stacks and keeps the notion of the current one. Then you make the ordinary IndexedStack
, or tabs, or anything you want, and build PageStackNavigator
in each of them to render its own stack.
This way you get a full-featured stack of arbitrary pages in each tab. You can push any page anywhere. See this runnable example that can push a pop-up page into any of the two stacks.
Well, how are URLs respected if the page can be pushed into any stack? Each page has its own URL that it shows in the address bar when active, but it has nothing to do with what tab is currently selected (they just happen to match in simple cases).
Say you have Friends and Messages tab. You can tap a friend in Friends tab and open their profile there. Or you can tap a friend in Messages tab and open the same profile there. The address bar will show the friend’s URL in both cases. This is convenient because you may have two friend profiles in the two tabs. And the Android back button will return you to the bottom of each stack (friends or messages) which is what you expect from a social app.
If you copy the URL of a friend’s page and paste the URL, the app will load that page in its preferred tab which you likely hardcode as Friends tab. For this, you override PagePath
’s defaultStackKey
(see the example at the above link).
Summary: app_state wins.
State Management
go_router: This package deals with widgets. When the routing is done, at the end of the day you get your stack of screens. This is where go_router ends. To manage the state, the widgets must be stateful. This allows you to choose any state management technique you want.
go_router is mostly declarative (with push and back being secondary). In go_router, declarative navigation will replace the stack of pages. If you navigate from /books
to /books/123
, withcontext.go('/books/123');
… this will replaces everything in the old stack.
The two stacks partially match, so the matching bottom parts can be preserved by the means of keys in stateful widgets. This requires careful key management. One bug can easily drop all changes a user made in /books
like filter, sorting, scrolling position, search text etc.
Another option is to use global state containers like with riverpod. But then you can’t have two independent states in two routes of the same class.
app_state: You do not depend on widget keys or global objects for state preservation. If you navigate from /books
to /books/123
with
pageStack.replacePath(BookDetailsPath(id: 123));
… the pages are preserved because they have keys hardcoded in Page
classes. If a page is preserved, then so is its state. The page can later produce any screen widget than can have no keys at all because it is passed a PageStateMixin
for its state. So no ways to accidentally lose the stack state.
Unlike with go_router, you can also control how the old stack is matched for state preservation. This call will replace the old stack by re-creating the bottom pages resetting their state:
pageStackBloc.replaceWithPath(
BookDetailsPath(id: 123),
mode: PageStackMatchMode.none,
);
You can even write your custom code to match the two stacks to selectively preserve some page states. You don’t have this control with go_router.
Still the declarative navigation is needed here less often than with go_router, because app_state has much more powerful imperative pushes.
You probably also want to back your pages with some stateful objects to not fiddle with widget’s setState
. In this case, you benefit greatly from app_state because:
- You can back a screen with any stateful object: a bloc, a
ChangeNotifier
, or anything you addPageStateMixin
to. - You no longer need to manage the creation and destruction of such objects, the package does this for you.
- You no longer need stateful screen widgets in most cases.
- You state objects no longer need to subscribe to navigation events, they get them out of the box.
- State objects can link to each other easily and securely because they are the primary units in the app. No need to worry that some widget will change its key, delete its state and take its bloc with it.
Summary: app_state wins.
Android Back Button Support
go_router: Back button is supported out of the box. If you want to override what it does to change anything on the page instead of popping, you do it the standard Flutter way with WillPopScope
widget.
If you use anything other than stateful widgets for your states, this adds a complexity of looking up your bloc, and then two locations in your code are aware of back button handling.
app_state: Back button is supported out of the box. If you want to change what it does, you override PageStateMixin.onBackPressed()
.
Since it is the same object as the page’s state, you can do actions in one short line without looking up the bloc as you would do from widgets in the standard Flutter way.
Summary: app_state is simpler.
Forward Button Recovery
Use case: In a multi-screen app, the user enters some text, then closes the screen without saving by ‘Back’ button. They then click ‘Forward’ and expect their input is still there.
go_router: No support. Forward navigation is an ordinary URL navigation. Anything that was not stored in the URL and recovered from it is lost.
app_state: Custom data can be saved to browser’s history alongside the URL and then recovered. See this tutorial (the GIF is from there).
Summary: app_state wins.
Maintainability
go_router: Initially it was developed as an independent package. Flutter team later adopted the project and maintains it now. It makes go_router the most recognized router package out there.
This however comes with a long Flutter process for bug fixes. Critical bugs can take months to fix during which an issue can hang in low priority compared to the Flutter core.
app_state: It is an independent package that new programmers in your team most likely have to learn from scratch. Fixes are provided as the best effort, and PRs are welcome.
Conclusion
go_router is simpler to start with and requires less code for supported use cases, app_state covers more complex cases, drives a better decomposition and comes with state management out of the box.
Like this reading? Please also like the package:
https://pub.dev/packages/app_state
We need it to take off.
Missed Anything?
How do these packages compare to you? What navigation cases you are struggling with? Let me know in the comments, so I can expand!