Learn how we avoided the state management debate when building the eBay Motors app.
When we discuss the eBay Motors app, the question we are most often asked is, “Which state management solution does eBay Motors use?” The simple answer is we mostly manage state in StatefulWidgets, using Provider or InheritedWidgets to expose this state down the widget tree.
However, we think there is a more interesting question we should be asked: “How did eBay Motors design their codebase in a way that the choice of state management tool does not matter?”
We believe that choosing the right tool, or applying a single design pattern, is much less important than establishing clear contracts and boundaries between the unique features and components in the application. Without boundaries, it becomes too easy to write unmaintainable code that doesn’t scale as the application grows in complexity.
“Unmaintainable code” can be defined in several ways:
- changes in one area that creates bugs in seemingly unrelated areas;
- code that is difficult to reason about;
- code that is difficult to write automated tests for; or
- code with unexpected behaviors.
Any of these issues in your code will slow down your velocity and make it more difficult to deliver value to your users.
To better understand the approach we’ve taken to our package structure, let’s look at an example.
One of the key features in our buying flow is the ability to search for vehicles on our Search Screen, and navigate to a Vehicle Details Screen to learn more about the vehicle and purchase it.
If we break these screens down to their simplest requirements, they look something like this:
Search Screen
- Integrates with Search API
- Provides Infinite Scrolling of Listings
- Provides mechanisms to filter and sort through results
- Needs to navigate to another screen upon tapping a listing
Vehicle Detail Screen
- Integrates with a Listing Details API
- Provides rich content about a particular listing
- Needs to navigate to other screens in order to transact on the listing (chat, buy, place bid, etc.)
These two screens feel distinct and have very different reasons to change. They are ideal candidates to be separated by clear boundaries.
Let’s start by modeling the contract for the Search Screen. For this screen to be independently tested, two dependencies should be injected. In this example, we chose to inject these dependencies into an InheritedWidget that sits closer to the root of the widget tree than our Search Screen widget.
class SearchDependencies extends InheritedWidget { const SearchDependencies({ @required this.searchApi, @required this.onSearchResultTapped, @required Widget child, }) : super(child: child); final Iterable<SearchResult> Function(SearchParameters, int offset, int limit) searchApi; final void Function(BuildContext context, String listingId) onSearchResultTapped; static SearchDependencies of(BuildContext context) => context.dependOnInheritedWidgetOfExactType<SearchDependencies>(); @override bool updateShouldNotify(covariant SearchDependencies oldWidget) => oldWidget.searchApi != searchApi || oldWidget.onSearchResultTapped != onSearchResultTapped; }
Note: In this example, we are only injecting a few dependencies. In our app, we inject many dependencies into our domain packages such as APIs for analytics reporting, feature flags, platform APIs, etc.
This enables any widget within the Search package to access these dependencies using a BuildContext: SearchDependencies.of(context). This is conceptually no different than accessing Theme.of(context) or any of the other built-in InheritedWidgets.
class SearchResultCard extends StatelessWidget { const SearchResultCard({@required this.listingId}); final String listingId; @override Widget build(BuildContext context) { return Card( child: InkWell( onTap: () => SearchDependencies.of(context).onSearchResultTapped(context, listingId), child: Column( children: [ // some UI here ], ), ), ); } }
From a testing perspective, we can simply inject whatever fake implementations are needed for a given test case and can fully test the behavior of the Search Screen package.
return SearchDependencies( searchApi: (searchParams, offset, pageSize) => [ /* Stub data here */ ], onSearchResultTapped: (context, listingId) => { /* Stubbed implementation here */}, child: SearchResultsScreen(), );
While we sometimes use this strategy to unit test individual widgets, we often test the top level public widget of the package. This helps ensure that our tests validate the overall behavior and aren’t coupled to implementation details. We even use this strategy to provide mock data to perform full screen screenshot tests. You can read more about that in our previous blog post: Screenshot Testing with Flutter.
This approach to dependency management has other benefits as well. We use the same approach for our Vehicle Detail Screen. While the primary use case is to render a live eBay listing, we have a feature in our selling flow that allows for a seller to preview their listing before publishing. This preview functionality was easily achieved by wrapping the Vehicle Detail widget with different dependencies for that use case.
Now that we have the public API for our Search Screen package, let’s look at how we might integrate it within our application.
class _AppState extends State<App> { ApiClient apiClient = EbayApiClient(); AppNavigator navigator = AppNavigator(); @override Widget build(BuildContext context) { return dependencies( [ (app) => SearchDependencies( searchApi: apiClient.search, onSearchResultTapped: navigator.goToVehicleDetails, child: app, ), (app) => VehicleDetailDependencies( vehicleApi: apiClient.getVehicleDetails, child: app, ), ], app: MaterialApp( localizationsDelegates: [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, SearchResultsLocalizations.delegate, VehicleDetailsLocalizations.delegate, ], home: SearchResultsScreen(), ), ); } }
In this simple example, a stateful widget lives in our Application package and constructs the concrete implementation of our API client. This is one of the root widgets of our application and is responsible for constructing our MaterialApp. Note, we are configuring the dependencies and placing them above the MaterialApp. This is critical because MaterialApp provides our root navigator. This means as we navigate to new routes these same dependencies remain available from context because they are at the trunk of the widget tree.
We would then add a simple integration test in our app package to validate that we’ve wired up our packages correctly.
testWidgets('Should navigate from Search Results to Vehicle Details when I click on a result', harness((given, when, then) async { final app = AppPageObject(); // Pump the App and assert we are on the Search Screen await given.appIsPumped(); // Assert the Search Results is on screen then.findsOneWidget(app.searchResults); // Assert no Vehicle Details is on screen then.findsNothing(app.vehicleDetails); // Tap on the first search result to see its details await when.userTaps(app.searchResults.resultAt(0)); // Assert no Search Results is on screen then.findsNothing(app.searchResults); // Assert the Vehicle Details is on screen then.findsOneWidget(app.vehicleDetails); }));
You may have also noticed that each package exposes its own localization delegate. In order for each package to be independently testable, the package needs to fully own all of its resources: images, fonts and localized strings.
Obviously, this example has been heavily simplified. If taken at face value, this package structure could seem excessive. However, in our codebase, our Search Screen package has grown to 17,000 lines of code and over 500 tests – it is large enough that we are actively working to decompose it into smaller, more manageable pieces. In practice, this package boundary allows developers working on other features to completely ignore all of this complexity. Likewise, when someone does need to work on search, they are able to work exclusively in the Search Screen package and ignore the entire rest of the application.
This approach provides a foundation to scale out our codebase in a manageable way. We can easily have multiple large scale features being simultaneously developed with minimal friction. Working in a smaller package gives focus and improves developer turnaround times. If a developer makes a change, they only need to re-run the tests in the affected package, or occasionally in the app package if their change impacts the public API.
Read full article here:
https://tech.ebayinc.com/engineering/ebay-motors-state-management/
Provides the list of the opensource Flutter apps collection with GitHub repository.