Building Open Source PackagesBuilding a Simple, Opinionated, Reactive State Management Library for Scalable Flutter Apps
Welcome to my latest project! I'm excited to introduce Trent — a state management library I built from the ground up while tackling the challenges of large-scale app development with Flutter. If you're new to Flutter, it's a powerful UI toolkit for building natively compiled applications across mobile, web, and desktop. As I was working on ambitious projects like Dormside, I found that existing state management solutions were often too verbose or complex for real-world needs.
Project links:
Why Trent? Lessons Learned from Building Complex Apps
My experience building applications like Dormside and Confesi highlighted key needs that Trent directly addresses:
- Minimalism in Complex Projects: Large projects amplify boilerplate issues.
Trentis designed to minimize the code you need to write to manage even intricate states. You define your core logic in lean classes, andTrenthandles the reactivity and plumbing. - Ephemeral State Management Without Clutter: In complex apps, notifications, alerts, and temporary UI states are abundant.
Trent'salert()method became essential for managing these without complicating the primary state flow in Dormside. - A Simple, Consistent API Across Scales: Whether managing a single setting or orchestrating complex data flows in Dormside, a consistent API is crucial.
Trent's core API is minimal:emit(),set(),alert(), and supporting utilities likewatch<T>(),watchMap<T, S>(),Digester, andAlerterprovide all you need without overwhelming you. - Flexibility for Both Simple and Complex States: Some parts of Dormside required simple data models, while others demanded complex state hierarchies.
Trentis built to handle both – whether you have a single configuration object or a multi-faceted state with loading, data, and error variations.
Simple vs. Complex State Examples: Trent Adapts to Your Needs
Trent's adaptability is best seen by comparing how it handles both simple and more complex state scenarios. Let's look at two contrasting examples:
1. Simple State: Managing Layout
For basic UI elements, you often need to manage simple configurations. Imagine managing a layout with a navigation index. Here's how Trent keeps it straightforward:
import 'package:trent/trent.dart';
// Define a simple state class
class LayoutState extends EquatableCopyable<LayoutState> {
final int homeNavIdx;
LayoutState({required this.homeNavIdx});
LayoutState copyWith({int? homeNavIdx}) {
return LayoutState(homeNavIdx: homeNavIdx ?? this.homeNavIdx);
}
List<Object> get props => [homeNavIdx];
}
// Create your Trent class
class LayoutTrent extends Trent<LayoutState> {
LayoutTrent() : super(LayoutState(homeNavIdx: 0));
void updateHomeNavIdx(int idx) {
emit(state.copyWith(homeNavIdx: idx));
}
}Here, LayoutTrent extends Trent<LayoutState>. Updating the homeNavIdx is as simple as calling emit() with a new state. Trent handles the UI updates automatically – clean and direct.
2. Complex State: Managing Weather Data (Multi-Class State)
Now consider a more complex scenario: managing weather data which can be in various states – No Data, Sunny, or Rainy. This is where Trent's ability to handle multi-class states shines. Such scenarios are common in real-world applications and Trent's way of managing this is reminiscent of a "state machine"-like approach, keeping your code organized and maintainable.
import 'package:trent/trent.dart';
// Define your state types using a base abstract class
abstract class WeatherState extends EquatableCopyable<WeatherState> {
List<Object?> get props => [];
}
class NoDataState extends WeatherState {
NoDataState copyWith() => this;
}
class SunnyState extends WeatherState {
final double temperature;
SunnyState(this.temperature);
List<Object?> get props => [temperature];
SunnyState copyWith({double? temperature}) {
return SunnyState(temperature ?? this.temperature);
}
}
class RainyState extends WeatherState {
final double rainfall;
RainyState(this.rainfall);
List<Object?> get props => [rainfall];
RainyState copyWith({double? rainfall}) {
return RainyState(rainfall ?? this.rainfall);
}
}
// Create your Trent class for the complex state
class WeatherTrent extends Trent<WeatherState> {
WeatherTrent() : super(NoDataState()); // Initial state is NoData
void updateToSunny(double temperature) {
emit(SunnyState(temperature));
}
void updateToRainy(double rainfall) {
emit(RainyState(rainfall));
}
void resetWeather() {
reset(); // Resets back to NoDataState
}
}In this multi-class example, WeatherTrent manages a WeatherState which can be NoDataState, SunnyState, or RainyState. Despite the increased complexity of state variations, the Trent class remains clean. The UI logic, as we'll see, also benefits from Trent's Digester and watchMap components, which are designed to handle these multi-state scenarios elegantly.
Direct Comparison:
Single State (Layout example): Ideal for simple UI configurations or isolated data points.
Trent's straightforwardTrent<LayoutState>setup is perfect for these cases, minimizing code and maximizing clarity.Multi-Class State (Weather example): Necessary for scenarios with distinct state variations like loading states, error states, or different data representations.
Trent, usingTrent<WeatherState>with an abstract base state and concrete subclasses, handles this complexity without becoming convoluted. TheDigesterandwatchMapwidgets then make consuming these complex states in your UI just as easy as consuming simple states.
Trent's architecture is specifically designed to smoothly handle both ends of this spectrum, making it a versatile choice regardless of your state complexity.
Trent's Core Architecture: Designed for Scalability
Trent's internal structure is what allows it to be so adaptable and efficient, especially in larger applications:
The
Trent<S extends EquatableCopyable<S>>Class: This abstract class is the foundation. It usesBehaviorSubjectfor managing both the main state stream (stateStream) and a separate stream for "alert" states (alertStream). It also intelligently caches previous states, useful for state history management, and extendsChangeNotifierto efficiently notify Flutter widgets about state changes. Here,Srepresents the type of yourTrent's State.Core Methods:
emit(newState),set(newState), andalert(alertState):emit(newState): Updates the current state and triggers necessary UI rebuilds, ensuring your UI is always in sync with your state.set(newState): Updates the state without triggering UI rebuilds. This is valuable for state changes that don't directly impact the visible UI, optimizing performance.alert(alertState): Sends an ephemeral state for alerts, notifications, or transient UI elements like toast messages. These alerts are managed separately from your main application state, preventing interference.
Widget Integration: watch<T extends Trent<S>>(context) and get<T extends Trent<S>>(context):
get<T extends Trent<S>>(context): Provides non-reactive access to aTrentinstance. Use this when you need to call methods on yourTrentbut don't need the widget to rebuild when the state changes (e.g., button presses triggering state updates). Here,Tis your specificTrentclass andSis its State type.watch<T extends Trent<S>>(context): Provides reactive access. Widgets usingwatchwill automatically rebuild whenever theTrent's state changes, ensuring your UI dynamically reflects the latest state. Again,Tis yourTrentclass andSis its State type.
Utility and State Management Methods: Trent provides a rich set of utility methods for advanced state manipulation:
getExStateAs<DesiredStateType>(): Retrieves the last known state that was ofDesiredStateType. Useful for scenarios like navigating back to a previous state.getCurrStateAs<DesiredStateType>(): Checks if the current state is ofDesiredStateTypeand returns it if it is.stateMap: A powerful method for mapping the current state to different values or actions based on its type, using.as<StateType>(...)for type-specific handling and.orElse(...)for a default case.- State Clearing Methods (
clearEx<StateType>(),clearAllExes()): For managing state history and memory, allowing you to clear specific past states or all of them. reset(): Returns theTrent's state to its initial value, effectively resetting a part of your application state.dispose(): Important for resource management; releases resources and closes the underlying streams when yourTrentis no longer needed.stateStreamandalertStream: For advanced use cases, these provide direct access to the underlyingBehaviorSubjectstreams, allowing for custom stream handling if needed.
Under the hood, Trent smartly utilizes the Provider package for efficient dependency injection, making Trent instances readily accessible throughout your widget tree.
Conditional UI Building with watchMap<T extends Trent<S>, S>(context, ...): Especially valuable for complex states, watchMap lets you define different UI outputs based on different state subtypes (like Data, Loading, Error states in the Weather example). It eliminates nested if-else chains in your UI, leading to cleaner and more maintainable widget code. For example:
watchMap<WeatherTrent, WeatherState>(
context,
(mapper) {
mapper
..as<SunnyState>((s) => Text("It's Sunny: ${s.temperature}°C"))
..as<RainyState>((r) => Text("It's Rainy: ${r.rainfall}mm"))
..orElse((_) => const Text("No Weather Data Available"));
},
);Widgets That Simplify Reactive UIs
Trent provides widget building blocks specifically designed to make reactive UI development easier:
Digester<T extends Trent<S>, S>: TheDigesterwidget is your primary tool for building UIs that react to main state changes. It listens to thestateStreamof yourTrentand rebuilds its UI based on the current state. You use aWidgetSubtypeMapperwithinDigesterto define how different state subtypes should be rendered as widgets..dart Digester<WeatherTrent, WeatherState>( child: (mapper) { mapper ..as<SunnyState>((s) => Text("It's Sunny: ${s.temperature}°C")) ..as<RainyState>((r) => Text("It's Rainy: ${r.rainfall}mm")) ..orElse((_) => const Text("No Weather Data Available")); }, );Alerter<T extends Trent<S>, S>: TheAlerterwidget is dedicated to handling ephemeral "alert" states. It listens to thealertStreamof yourTrent. When you callalert(...),Alertercan display transient UI elements likeSnackBars, toasts, or pop-up dialogs, based on the alert state, without interfering with the main UI driven by the main state. But,Alerteris more versatile than just reacting to alerts! It also provides fine-grained control over responding to both alert state changes and even main state changes, if needed, through its fields:listenAlertsIfandlistenStates. Let's explore these with an example:.dart class WeatherScreen extends StatelessWidget { Widget build(BuildContext context) { return Alerter<WeatherTrent, WeatherState>( listenAlerts: (mapper) { mapper ..as<WeatherAlertState>((alert) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Alert: ${alert.message}")), ); }); }, listenAlertsIf: (oldState, newState) => newState is WeatherAlertState, listenStates: (mapper) { mapper ..as<SunnyState>((state) => print("Weather is Sunny: ${state.temperature}°C")) ..as<RainyState>((state) => print("Weather is Rainy: ${state.rainfall}mm")) ..orElse((_) => print("Weather state: No Data")); }, child: Digester<WeatherTrent, WeatherState>( child: (mapper) { mapper ..as<SunnyState>((state) => Text("Sunny: ${state.temperature}°C")) ..as<RainyState>((state) => Text("Rainy: ${state.rainfall}mm")) ..orElse((_) => const Text("No Data")); }, ), ); } }listenAlerts: This field is the primary wayAlerterreacts to alert states. Just like in our previous examples, you use aWidgetSubtypeMapperhere to define how to handle different alert state types. In the example, we show aSnackBarwhen aWeatherAlertStateis emitted viaalert(...).listenAlertsIf:(oldState, newState) => ...: This optional function provides conditional listening to alert states. The function takes theoldStateandnewStateas arguments and should return a boolean.Alerterwill only execute thelistenAlertsmapper iflistenAlertsIfreturns true. In our example,listenAlertsIf: (oldState, newState) => newState is WeatherAlertStateensures that theSnackBaris only shown when the new alert state is indeed aWeatherAlertState. This level of control is crucial for fine-tuning when alert reactions should occur, preventing unnecessary UI updates or side effects.listenStates:WhileAlerteris primarily designed for alerts,listenStatesallows it to also react to changes in the main application state if you need to perform actions in response to main state changes within the scope of anAlerter. This is less common but can be useful for specific scenarios. In our example, we are simply printing messages to the console when the weather state changes to Sunny, Rainy, or No Data. Importantly, the UI building withDigesterremains the primary way to visually represent the mainWeatherState;listenStatesinAlerterprovides a mechanism for side-effects or secondary reactions to these main state changes if needed.
Both Digester and Alerter offer optional conditional functions (listenStatesIf, listenAlertsIf) for advanced scenarios where you need even finer-grained control over when they react to state or alert changes.
Putting It All Together in Your Flutter Application
Register Your Trent Instances: In your main.dart file, use TrentManager at the top of your widget tree to register all your Trent instances. TrentManager leverages MultiProvider for efficient dependency injection.
void main() {
runApp(
TrentManager(
trents: [
register(LayoutTrent()),
register(WeatherTrent()),
// ... register other Trents
],
child: const MyApp(),
),
);
}Accessing Trent Instances in Your UI: Within your widgets, use watch<T extends Trent<S>>(context) for reactive access to your Trent instances:
final weatherTrent = watch<WeatherTrent>(context);Interacting with Your Trent: Call methods on the Trent instance to manipulate state. Because you're using watch, UI updates are automatic when you emit a new state:
ElevatedButton(
onPressed: () {
weatherTrent.updateToSunny(28.0); // UI will automatically rebuild
},
child: const Text('Make it Sunny'),
);Building Reactive UIs with Digester and watchMap: Use Digester for declarative UI building based on state, and watchMap for conditionally rendering different widgets based on different state subtypes, as shown in the Weather example earlier.
Trent API: A Quick Reference
Key Advantages of Trent:
- Simple and Intuitive: Designed for ease of use, especially in complex projects. Built-in dependency injection and streamlined reactive widgets significantly reduce boilerplate.
- Fine-Grained Control & Flexibility: Offers granular control through methods like
watch<T>(),watchMap<T, S>(), andget<T>(). Mapped outputs (.as<SomeType>(...)....orElse(...)) simplify complex state-based UI logic. Ephemeral states viaalert(...)provide a clean way to handle transient UI elements. Access to state history withgetExStateAs<T>()adds powerful state management capabilities. - Performance & Robustness: Stream-based updates ensure efficiency. Built on
Equatablefor reliable state equality checks andOption.Some(...)/Option.None()for safe handling of optional states. Encourages clear separation of UI and business logic.
Core UI Layer Components:
Alerter<T extends Trent<S>, S>: Widget for handling alert and optionally main states, ideal for transient messages and for fine-grained control over reaction to state changes vialistenAlertsIfandlistenStates. You can also listen to ephemeral alert states withlistenAlertsandlistenAlertsIf.Digester<T extends Trent<S>, S>: Widget for dynamically building UI based on current state subtypes, ensuring type-safe state rendering.watch<T extends Trent<S>>(BuildContext context): Reactively retrieves aTrentinstance in your widgets, causing rebuilds upon state changes.get<T extends Trent<S>>(BuildContext context): Non-reactiveTrentinstance retrieval, for actions not requiring UI updates.watchMap<T extends Trent<S>, S>(BuildContext context, void Function(WidgetSubtypeMapper<S>) configure): Maps state subtypes to specific widgets for conditional UI rendering.
Business Logic Layer Functions (within Trent<S> class):
emit(state): Updates the state and triggers UI rebuilds for reactive updates.set(state): Updates the state without triggering UI rebuilds, for non-visual state changes.alert(state): Sends an ephemeral state, used for alerts and transient notifications.getExStateAs<StateType>(): Retrieves the last known state of typeStateType.getCurrStateAs<StateType>(): Retrieves the current state if it is of typeStateType.stateMap: Provides a way to map the current state to different outputs or actions using.as<StateType>(...)and.orElse(...).clearEx<StateType>(): Clears the cached "last known state" of typeStateTypefrom memory.clearAllExes(): Clears all cached "last known states" from memory.reset(): Resets theTrent's state back to its initial state.dispose(): Releases resources and closes the underlying streams when theTrentis no longer needed.stateStream: Provides direct access to the main stateBehaviorSubjectstream.alertStream: Provides direct access to the alert stateBehaviorSubjectstream.
Getting Started: Implementing Trent in Your Project
Define Your State Classes:
Start by defining your state classes, inheriting from EquatableCopyable for efficient state management and equality comparisons.
// Example Weather State Classes (as defined earlier)
abstract class WeatherState extends EquatableCopyable<WeatherState> { ... }
class NoDataState extends WeatherState { ... }
class SunnyState extends WeatherState { ... }
class RainyState extends WeatherState { ... }Create Your Trent Classes:
Extend Trent<YourStateType> for each distinct piece of state you need to manage. Implement methods to update the state using emit(), set(), and alert().
// Example WeatherTrent Class (as defined earlier)
class WeatherTrent extends Trent<WeatherState> { ... }
// Example LayoutTrent Class (as defined earlier)
class LayoutTrent extends Trent<LayoutState> { ... }Organize Your Files (Optional):
A good practice is to create a trents directory in your lib folder and place each Trent class in its own file (e.g., weather_trent.dart, layout_trent.dart).
Initialize TrentManager in main.dart:
Register all your Trent instances within the TrentManager widget at the root of your runApp hierarchy.
void main() {
runApp(
TrentManager(
trents: [
register(WeatherTrent()),
register(LayoutTrent()),
],
child: const MyApp(),
),
);
}Connect UI to Business Logic:
In your widgets, access your Trent instances using watch<T>() or get<T>(). Call methods on the Trent to update the state in response to user interactions or application events.
class WeatherScreen extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
ElevatedButton(
onPressed: () => get<WeatherTrent>(context).updateToSunny(25.0),
child: const Text("Make it Sunny"),
),
// ... more buttons and UI elements interacting with WeatherTrent and LayoutTrent
],
);
}
}Build Reactive UIs with Alerter, Digester, and watchMap:
Use Trent's specialized widgets within your widget tree to build reactive UI components that automatically update based on state changes. Refer to the examples provided earlier for Alerter, Digester, watch, and watchMap usage, and especially the Alerter example showcasing listenAlertsIf and listenStates for advanced control.
Conclusion: Trent – State Management Scaled for Your Flutter Ambitions
Trent is more than just another state management library; it's a response to the complexities of building real-world, large-scale Flutter applications like Dormside. It provides a reactive, efficient, and exceptionally intuitive approach to managing state, whether you're dealing with simple UI configurations or orchestrating complex data flows across your application.
Trent simplifies development with its focus on ease of use, fine-grained control, and performance. If you're building ambitious Flutter projects and are looking for a state management solution that scales with your application's complexity without adding unnecessary boilerplate, Trent is designed for you.
Explore the Trent repository on GitHub and the package on Pub.dev. I'm happy to accept feedback, suggestions, and contributions!