Skip to main content

Architecture and state management

About 5 min

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
FolderResponsibilityExample
modelsdescribe app dataTrip
pagesshow UI and handle screen stateTripListPage, TripDetailPage
repositoriesread and write app dataTripRepository
servicestalk to Firebase services that are not one data collectionAuthService

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.

StateWhere we use itGood solution
login/register modeLoginPagesetState
loading while logging inLoginPagesetState
loading while saving a tripTripDetailPagesetState
form field valuesTripDetailPageTextEditingController
realtime trips from FirestoreTripListPageStreamBuilder
logged-in userAuthGateFirebase 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:

  • AuthService handles login and registration.
  • TripRepository handles Firestore trips.
  • AuthGate listens to the logged-in user.
  • TripListPage listens to Firestore with StreamBuilder.
  • TripDetailPage uses setState for 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?

  • TripRepository still loads trips from Firestore.
  • StreamBuilder still listens to realtime trip changes.
  • TripListViewModel remembers the search text.
  • TripListViewModel filters the trips before the list is shown.
  • AnimatedBuilder rebuilds the list when the ViewModel calls notifyListeners().

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 setState for small state that belongs to one page.
  • Use StreamBuilder for 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.