Navigation and App Architecture
Prerequisites: State management
As an app grows past a few screens, you need a clear way to move between screens and a structure that keeps code testable.
Navigation
The simplest navigation pushes a new screen onto a stack:
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DetailPage(id: 42)),
);
// ...and on the detail page:
Navigator.of(context).pop();
Most apps use the go_router package, which maps URLs to
screens. That supports deep links (opening the app at a specific screen)
and proper browser URLs on the web:
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => const HomePage()),
GoRoute(
path: '/articles/:id',
builder: (context, state) => ArticlePage(id: state.pathParameters['id']!),
),
],
);
// In MaterialApp.router(routerConfig: router), then anywhere:
context.go('/articles/42');
A layered architecture
Flutter's official architecture guide recommends separating an app into layers:
UI layer: Views (widgets) ◀──▶ ViewModels (ChangeNotifier / Riverpod / Bloc)
│
Data layer: Repositories ──▶ Services (HTTP, database)
- Views display state and forward user actions. No business logic.
- ViewModels hold a screen's state and logic.
- Repositories are the single source of truth for a type of data, deciding whether it comes from the network, a cache, or local storage.
- Services wrap external APIs and plugins.
A feature, end to end
class Article {
const Article(this.id, this.title);
final int id;
final String title;
}
abstract interface class ArticleRepository {
Future<List<Article>> latest();
}
class ArticlesViewModel extends ChangeNotifier {
ArticlesViewModel(this._repository);
final ArticleRepository _repository;
List<Article> articles = [];
bool loading = false;
String? error;
Future<void> load() async {
loading = true;
error = null;
notifyListeners();
try {
articles = await _repository.latest();
} catch (e) {
error = "Couldn't load articles.";
} finally {
loading = false;
notifyListeners();
}
}
}
- The ViewModel depends on the
ArticleRepositoryinterface. The real app passes an implementation that calls the API; tests pass a fake. - The widget simply listens to the ViewModel and shows a spinner, the list, or the error.
Passing dependencies in through constructors like this is dependency
injection. Packages such as provider, Riverpod, or get_it help
create and supply these objects.
Keep it proportional
A small app doesn't need every layer on day one. Start with a model and a repository; add structure as screens, features, and team size grow.