Architecture and state management
Architecture and state management
In the previous chapters we built Travel Notes step by step. The app now has:
- Firebase setup
- Authentication
- trips stored in Firestore under
users/{uid}/trips - a realtime trip list
- a trip form with validation
- Firestore security rules
This chapter does not add a big new framework. Instead, we will look at the structure we already have and make the code easier to reason about.
Why architecture matters
Small Flutter examples can keep all logic inside one widget. That works for a while, but a real app quickly gets messy if UI code, Firebase calls, validation and state changes are all mixed together.
Architecture is not about making the app complicated. It is about giving every file a clear job.
The Travel Notes structure
Our app already uses a simple structure:
lib
models
trip.dart
pages
auth_gate.dart
login.dart
trip_detail.dart
trip_list.dart
repositories
trip_repository.dart
services
auth_service.dart
| Folder | Responsibility | Example |
|---|---|---|
models | describe app data | Trip |
pages | show UI and handle screen state | TripListPage, TripDetailPage |
repositories | read and write app data | TripRepository |
services | talk to Firebase services that are not one data collection | AuthService |
This is already a good architecture for this course: every file has a clear reason to exist.
State in Travel Notes
State means data that can change while the app is running.
| State | Where we use it | Good solution |
|---|---|---|
| login/register mode | LoginPage | setState |
| loading while logging in | LoginPage | setState |
| loading while saving a trip | TripDetailPage | setState |
| form field values | TripDetailPage | TextEditingController |
| realtime trips from Firestore | TripListPage | StreamBuilder |
| logged-in user | AuthGate | Firebase auth stream |
setState is not bad. It is the right choice when the state belongs to one screen only.
In Travel Notes, isLoading, isSaving, isRegistering and errorMessage are simple screen state. They do not need a state management package.
Remote data belongs in a repository
The most important architecture choice in Travel Notes is this: pages do not build Firestore paths themselves.
The page asks the repository for trips:
stream: repository.watchTrips(),
The repository knows where trips are stored:
CollectionReference<Map<String, dynamic>> get trips {
final uid = FirebaseAuth.instance.currentUser!.uid;
return FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('trips');
}
This keeps TripListPage focused on UI. If the Firestore path changes later, we change TripRepository, not every page.
Keep StreamBuilder for the list
For the trip list, StreamBuilder is still the simplest solution. Firestore already gives us a stream, and Flutter can rebuild the list when new data arrives.
body: StreamBuilder<List<Trip>>(
stream: repository.watchTrips(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Center(child: Text("Could not load trips"));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final trips = snapshot.data!;
if (trips.isEmpty) {
return const Center(child: Text("No trips yet"));
}
return ListView.builder(
itemCount: trips.length,
itemBuilder: (context, index) {
final trip = trips[index];
return Text(trip.title);
},
);
},
),
This is enough for the current app. A separate ViewModel for the list would only be useful if the screen gets more features, such as search, filters, sorting or selection mode.
Small refactor: a TripCard widget
One simple improvement is to move the visual part of one trip row into a widget. This keeps TripListPage easier to read without changing the app architecture.
Create a widgets folder in lib.
Create trip_card.dart in the widgets folder:
import 'package:flutter/material.dart';
import '../models/trip.dart';
class TripCard extends StatelessWidget {
final Trip trip;
final VoidCallback onTap;
const TripCard({
super.key,
required this.trip,
required this.onTap,
});
Widget build(BuildContext context) {
final ColorScheme colors = Theme.of(context).colorScheme;
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: colors.primary,
foregroundColor: colors.onPrimary,
child: Text(trip.destination.substring(0, 1)),
),
title: Text(trip.title),
subtitle: Text(
"${trip.destination} - ${trip.tags.join(', ')}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: onTap,
),
);
}
}
In trip_list.dart, add the import:
import '../widgets/trip_card.dart';
Then replace the Card and ListTile inside ListView.builder with:
return TripCard(
trip: trip,
onTap: () {
_navigateToDetail(context, trip.id);
},
);
The behavior does not change. The list page still decides what happens when a trip is tapped. The new widget only knows how to display one trip.
When to add more state management
Do not add Riverpod, BLoC or Provider just because an app uses Firebase. Add a state management package when it solves a real problem.
For Travel Notes, the current approach is enough:
AuthServicehandles login and registration.TripRepositoryhandles Firestore trips.AuthGatelistens to the logged-in user.TripListPagelistens to Firestore withStreamBuilder.TripDetailPageusessetStatefor loading and saving.
A ViewModel becomes useful when a page grows too much. For example, if TripListPage later supports search, filters, sorting and multi-select delete, then moving that screen logic to a separate class can make sense.
Optional refactor: TripListViewModel
Let's implement a small ViewModel for the trip list. We will keep Firestore streaming in the page with StreamBuilder, but move the search text and filtering logic to a separate class.
This is a gentle version of state management:
- no extra package
- no dependency injection framework
- no new app structure
- only one small class that extends
ChangeNotifier
Create a viewmodels folder in lib.
Create trip_list_viewmodel.dart in the viewmodels folder:
import 'package:flutter/foundation.dart';
import '../models/trip.dart';
class TripListViewModel extends ChangeNotifier {
String searchText = '';
void updateSearchText(String value) {
searchText = value.trim().toLowerCase();
notifyListeners();
}
List<Trip> filterTrips(List<Trip> trips) {
if (searchText.isEmpty) {
return trips;
}
return trips.where((trip) {
final title = trip.title.toLowerCase();
final destination = trip.destination.toLowerCase();
final tags = trip.tags.join(' ').toLowerCase();
return title.contains(searchText)
|| destination.contains(searchText)
|| tags.contains(searchText);
}).toList();
}
}
This ViewModel does not talk to Firestore. That stays the job of TripRepository. The ViewModel only manages screen logic for the trip list: the current search text and the filtered trips.
Now update trip_list.dart.
Add the import:
import '../viewmodels/trip_list_viewmodel.dart';
Change TripListPage from a StatelessWidget to a StatefulWidget:
class TripListPage extends StatefulWidget {
const TripListPage({super.key});
State<TripListPage> createState() => _TripListPageState();
}
class _TripListPageState extends State<TripListPage> {
final TripListViewModel viewModel = TripListViewModel();
static final TripRepository repository = TripRepository();
static final AuthService authService = AuthService();
Keep the StreamBuilder, but return an AnimatedBuilder after the Firestore data is loaded:
body: StreamBuilder<List<Trip>>(
stream: repository.watchTrips(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Center(child: Text("Could not load trips"));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final allTrips = snapshot.data!;
return AnimatedBuilder(
animation: viewModel,
builder: (context, child) {
final trips = viewModel.filterTrips(allTrips);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: TextField(
decoration: const InputDecoration(
labelText: "Search trips",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: viewModel.updateSearchText,
),
),
Expanded(
child: trips.isEmpty
? const Center(child: Text("No trips yet"))
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: trips.length,
itemBuilder: (context, index) {
final trip = trips[index];
return TripCard(
trip: trip,
onTap: () {
_navigateToDetail(context, trip.id);
},
);
},
),
),
],
);
},
);
},
),
The StreamBuilder still listens to Firestore. The AnimatedBuilder only rebuilds the part that depends on the ViewModel. That means changing the search text does not create a new Firestore query.
Finally, dispose the ViewModel:
void dispose() {
viewModel.dispose();
super.dispose();
}
What changed?
TripRepositorystill loads trips from Firestore.StreamBuilderstill listens to realtime trip changes.TripListViewModelremembers the search text.TripListViewModelfilters the trips before the list is shown.AnimatedBuilderrebuilds the list when the ViewModel callsnotifyListeners().
This is the point of a small ViewModel: keep the page from collecting too much screen logic, while keeping the solution simple enough to understand.
Best practices
- Keep widgets focused on UI.
- Put Firebase Authentication logic in
AuthService. - Put Firestore trip logic in
TripRepository. - Use
setStatefor small state that belongs to one page. - Use
StreamBuilderfor realtime Firestore data. - Add a state management package only when the app becomes hard to manage without it.
Final challenge - waypoints
Your users can already create trips. In this final challenge, you will allow them to add waypoints to a trip. A waypoint represents a location or important stop during the trip.
Store the waypoints as a subcollection inside the corresponding trip document:
Each waypoint document must contain the following fields:
- day: number
- name: string
- type: string
- altitude: number
Example document:
{
"day": 3,
"name": "Mount Fuji",
"type": "hike",
"altitude": 3776
}
Make sure the user can add waypoints to a new or existing trip.
The user must be able to:
- Open a trip.
- Enter the waypoint details.
- Save the waypoint in the correct Firestore subcollection.
- See all waypoints belonging to that trip.
- See a clear confirmation after successfully adding a waypoint.