πŸ‘Ύ Check out the results of my summer hackathon, Covehack! πŸ‘Ύ

Building a Simple, Opinionated, Reactive State Management Library for Scalable Flutter Apps

Posted February 7, 2025 β€’ 14 min read
banner image

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. Trent is designed to minimize the code you need to write to manage even intricate states. You define your core logic in lean classes, and Trent handles the reactivity and plumbing.
  • Ephemeral State Management Without Clutter: In complex apps, notifications, alerts, and temporary UI states are abundant. Trent's alert() 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 like watch<T>(), watchMap<T, S>(), Digester, and Alerter provide 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. Trent is 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});

  @override
  LayoutState copyWith({int? homeNavIdx}) {
    return LayoutState(homeNavIdx: homeNavIdx ?? this.homeNavIdx);
  }

  @override
  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> {
  @override
  List<Object?> get props => [];
}

class NoDataState extends WeatherState {
  @override
  NoDataState copyWith() => this;
}

class SunnyState extends WeatherState {
  final double temperature;
  SunnyState(this.temperature);

  @override
  List<Object?> get props => [temperature];

  @override
  SunnyState copyWith({double? temperature}) {
    return SunnyState(temperature ?? this.temperature);
  }
}

class RainyState extends WeatherState {
  final double rainfall;
  RainyState(this.rainfall);

  @override
  List<Object?> get props => [rainfall];

  @override
  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 straightforward Trent<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, using Trent<WeatherState> with an abstract base state and concrete subclasses, handles this complexity without becoming convoluted. The Digester and watchMap widgets 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 uses BehaviorSubject for 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 extends ChangeNotifier to efficiently notify Flutter widgets about state changes. Here, S represents the type of your Trent's State.

  • Core Methods: emit(newState), set(newState), and alert(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 a Trent instance. Use this when you need to call methods on your Trent but don't need the widget to rebuild when the state changes (e.g., button presses triggering state updates). Here, T is your specific Trent class and S is its State type.
  • watch<T extends Trent<S>>(context): Provides reactive access. Widgets using watch will automatically rebuild whenever the Trent's state changes, ensuring your UI dynamically reflects the latest state. Again, T is your Trent class and S is 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 of DesiredStateType. Useful for scenarios like navigating back to a previous state.
  • getCurrStateAs<DesiredStateType>(): Checks if the current state is of DesiredStateType and 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 the Trent'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 your Trent is no longer needed.
  • stateStream and alertStream: For advanced use cases, these provide direct access to the underlying BehaviorSubject streams, 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>: The Digester widget is your primary tool for building UIs that react to main state changes. It listens to the stateStream of your Trent and rebuilds its UI based on the current state. You use a WidgetSubtypeMapper within Digester to define how different state subtypes should be rendered as widgets.

    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>: The Alerter widget is dedicated to handling ephemeral "alert" states. It listens to the alertStream of your Trent. When you call alert(...), Alerter can display transient UI elements like SnackBars, toasts, or pop-up dialogs, based on the alert state, without interfering with the main UI driven by the main state. But, Alerter is 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: listenAlertsIf and listenStates. Let's explore these with an example:

    class WeatherScreen extends StatelessWidget {
      @override
      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 way Alerter reacts to alert states. Just like in our previous examples, you use a WidgetSubtypeMapper here to define how to handle different alert state types. In the example, we show a SnackBar when a WeatherAlertState is emitted via alert(...).

  • listenAlertsIf: (oldState, newState) => ...: This optional function provides conditional listening to alert states. The function takes the oldState and newState as arguments and should return a boolean. Alerter will only execute the listenAlerts mapper if listenAlertsIf returns true. In our example, listenAlertsIf: (oldState, newState) => newState is WeatherAlertState ensures that the SnackBar is only shown when the new alert state is indeed a WeatherAlertState. This level of control is crucial for fine-tuning when alert reactions should occur, preventing unnecessary UI updates or side effects.

  • listenStates: While Alerter is primarily designed for alerts, listenStates allows 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 an Alerter. 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 with Digester remains the primary way to visually represent the main WeatherState; listenStates in Alerter provides 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>(), and get<T>(). Mapped outputs (.as<SomeType>(...)....orElse(...)) simplify complex state-based UI logic. Ephemeral states via alert(...) provide a clean way to handle transient UI elements. Access to state history with getExStateAs<T>() adds powerful state management capabilities.
  • Performance & Robustness: Stream-based updates ensure efficiency. Built on Equatable for reliable state equality checks and Option.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 via listenAlertsIf and listenStates. You can also listen to ephemeral alert states with listenAlerts and listenAlertsIf.
  • 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 a Trent instance in your widgets, causing rebuilds upon state changes.
  • get<T extends Trent<S>>(BuildContext context): Non-reactive Trent instance 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 type StateType.
  • getCurrStateAs<StateType>(): Retrieves the current state if it is of type StateType.
  • 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 type StateType from memory.
  • clearAllExes(): Clears all cached "last known states" from memory.
  • reset(): Resets the Trent's state back to its initial state.
  • dispose(): Releases resources and closes the underlying streams when the Trent is no longer needed.
  • stateStream: Provides direct access to the main state BehaviorSubject stream.
  • alertStream: Provides direct access to the alert state BehaviorSubject stream.

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 {
  @override
  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!