app_state vs go_router for Flutter navigation

Alexey Inkin
Flutter Senior
Published in
10 min readAug 6, 2022

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:

  1. The screen widget (Example).
  2. The optional page bloc or any other stateful object with PageStateMixin to handle navigation events (Example).
  3. The page class (a placeholder to sit in the Navigator’s stack) (Example).
  4. Path class (it parses the URL, possibly with regexp, and generates a URL). (Example)
  5. A line in the URL parser (Example).
  6. 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:

  1. 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.
  2. 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, with
context.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 add PageStateMixin 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!

--

--

Alexey Inkin
Flutter Senior

Google Developer Expert in Flutter. PHP, SQL, TS, Java, C++, professionally since 2003. Open for consulting & dev with my team. Telegram channel: @ainkin_com