Simple, Reactive, Scalable, & Opinionated State Management Library
How to Use Full Example β’ API Docs β’ Simple Weather App Using Trent Source Code
- Ease of use:
- Built-in dependency injection and service locators.
- Boasts simple
Alerter
andDigester
widgets for managing UI layer reactively.
- Fine-grained control:
- Includes special shorthand
watch<T>
,watchMap<T, S>
, andget<T>
functions for reduced-boilerplate managing of UI layer. - State output for widgets and functions is "mapped", meaning instead of requiring exhaustive checks like
if (state is A) {...} else if (state is B) {...} else {...}
you just chain..as<A>((state) {...}..as<B>((state) {...})
etc. invocations non-exhaustively. If you want a catch-all route, chain anorElse
. If a state isn't included, it's ignored. - Allows listening to one-off "ephemeral" states with
alert
that you don't want saved (ie: sending off a quick notification state without replacing your current state). - Access the last state of a specific type with
getExStateAs<T>
, ensuring you don't lose the value of the state you transitioned away from (ie: Data state -> Loading state -> Reloading Data state with its past data saved). - Uses
Equatable
for customizable equality checks.
- Includes special shorthand
- Performance & safety:
- Efficient stream-based state management.
- Utilizes custom
Option.Some(...)
/Option.None()
types. - Clean separation of concerns: UI layer & business logic layer.
-
Alerter
widget:- Listens for alert states emitted by your business logic using the
alert(...)
method. - Can also listen for normal state changes reactively from the business logic layer.
- Provides a declarative way to handle temporary or one-time notifications (e.g., error messages or toast notifications) without changing the current state.
- Listens for alert states emitted by your business logic using the
-
Digester
widget:- Dynamically builds your UI based on the current state of your business logic.
- Provides an intuitive, type-safe way to map each state to a corresponding UI representation.
-
(Main) utility functions:
watch<T>
: Reactively listens to state changes and rebuilds widgets dynamically.get<T>
: Retrieves a Trent instance without listening for state changes. The method used for invoking business logic functions.watchMap<T, S>
: Reactively maps state to specific widgets dynamically based on type.
Define custom state classes, then use them in your Trent state manager:
//
// Classes A, B, and C defined here
//
// A single Trent state manager class
class AuthTrent extends Trent<AuthTypes> {
AuthTrent() : super(A(1)); // Set initial state
// You can add N number of business logic functions to
// do logic and alter state
void businessLogicHere() {
//
// Business logic here
//
// Based on the business logic, you can alter state
// using build-in methods like:
// Emit a new state WITH the UI reacting
emit(C());
// Set a new state WITHOUT the UI reacting
set(A(2));
// Alert a temporary state WITHOUT setting it, but
// being able to listen to it (for things like notifications)
alert(B(3));
// Switch from one state to the other and back WITHOUT losing
// the value of the state you transitioned away from
getExStateAs<A>().match(some: (val) {
// Do something
}, none: () {
// Do something
});
// Get the current state as a specific typeg
getCurrStateAs<A>().match(some: (val) {
// Do something
}, none: () {
// Do something
});
// Map over the current state and do things based on the type
// (not all routes need to be defined)
stateMap
..orElse((state) {
// Do something (doElse run if nothing else more specific hit)
})
..as<A>((state) {
// Do something
})
..as<B>((state) {
// Do something
})
..as<C>((state) {
// Do something
});
// Simply access the raw state for custom manipulation
print(state);
}
// ... More business functions ...
}
-
Alerter
widget that listens to one-time statealert(...)
s from your business logic layer inlistenAlerts
. This is good if your business logic needs to "quickly send off a state without saving it". An example would be you havingLoading
,Data
, andWarningNotification
states. You may be inData
state, but want to send off a quickWarningNotification
state without having to throw away yourData
state. This is what analert(WarningNotification(...))
is good for.Alerter
can also can listen to regular state updates inlistenStates
. Both can have their listeners programmatically toggled on/off withlistenAlertsIf
andlistenStatesIf
respectively.// AuthTrent is where your business logic is defined, AuthTrentTypes is // the type all your business logic types extend from (in this example `A`, `B`, and `C` states) Alerter<AuthTrent, AuthTrentTypes>( // Not all handlers need to be defined // // This only listens to alerts listenAlerts: (mapper) => mapper ..orElse((state) { // Triggered if nothing more specific is defined }) ..as<A>((state) { // Called if `A` is alerted }) ..as<B>((state) { // Called if `B` is alerted }) ..as<C>((_) { // Called if `C` is alerted }), // Not all handlers need to be defined // // This only listens to states emitted listenStates: (mapper) => mapper ..orElse((state) { // Triggered if nothing more specific is defined }) ..as<A>((state) { // Called if `A` is alerted }) ..as<B>((state) { // Called if `B` is alerted }) ..as<C>((_) { // Called if `C` is alerted }), // Only trigger listens if... listenAlertsIf: (oldAlert, newAlert) => true, // oldAlert is wrapped in an Option type because it may not exist listenStatesIf: (oldState, newState) => true, // Both of these are pure types since there will always be an old and new state child: Container(), );
-
Digester
widget that builds your UI based on your current business logic state.// AuthTrent is where your business logic is defined, AuthTrentTypes is // the type all your business logic types extend from (in this example `A`, `B`, and `C` states) Digester<AuthTrent, AuthTrentTypes>( // Not all handlers need to be defined child: (mapper) { mapper ..orElse((state) => const Text("Rendered if no more specific type is defined")) ..as<A>((state) => Text("State is A")) ..as<B>((state) => const Text("State is B")) ..as<C>((state) => const Text("State is C")); }, ),
-
emit(state)
: Emit a new state with the UI reacting.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Emit a new state to update the UI with a calculation result void showResult(double result) { emit(CalculationResult(result)); } }
-
set(state)
: Set a new state without the UI reacting.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Set the state to prepare for a calculation without triggering a UI update void prepareCalculation() { set(Division(10, 2)); } }
-
alert(state)
: Alert a temporary state WITHOUT setting/saving it, but being able to listen to it from theAlerter
widget (for things like notifications).class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Send an alert to notify of an error without changing the current state void alertError(String message) { alert(InvalidCalculation(message)); } }
-
getExStateAs<T>()
: This will return the last state of typeT
. Useful for accessing a state you transitioned away from. For example, if you transitioned fromDivision
toMultiplication
, you can still access the last value of theDivision
state after transitioning away from it.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Access the previous Division state if it exists void reusePreviousDivision() { getExStateAs<Division>().match( some: (state) { print("Resuming division: ${state.numerator} / ${state.denominator}"); }, none: () { print("No previous division found."); }, ); } }
-
getCurrStateAs<T>()
: Returns the current state as typeT
. Useful for specific state operations.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Log the current result if the state is a CalculationResult void logCurrentResult() { getCurrStateAs<CalculationResult>().match( some: (state) { print("Current result: ${state.result}"); }, none: () { print("Not in result state."); }, ); } }
-
stateMap
: Maps over the current state and performs actions based on its type.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Perform different actions depending on the current state type void handleState() { stateMap ..orElse((state) { print("Generic state handler. Called if nothing more specific defined."); }) ..as<BlankScreen>((_) { print("Calculator is blank."); }) ..as<InvalidCalculation>((state) { print("Error: ${state.message}"); }) ..as<CalculationResult>((state) { print("Result: ${state.result}"); }); } }
-
state
: Access the raw state for custom manipulation.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Print the raw state for debugging or custom handling void printRawState() { print("Raw state: $state"); } }
-
clearEx(state)
: Clears the memory of the last state of a specific type.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Forget the last Division state void forgetPreviousDivision() { clearEx<Division>(); } }
-
clearAllExes()
: Clears the memory of all previous states.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Clear the memory of all previous states void resetMemory() { clearAllExes(); } }
-
reset()
: Resets the Trent to its initial state.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Reset the Trent to its initial state void resetCalculator() { reset(); } }
-
dispose()
: Disposes the Trent, closing its state streams.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Clean up resources by disposing of the Trent void cleanup() { dispose(); } }
-
Access
stateStream
andalertStream
for custom handling of streams.class CalculatorTrent extends Trent<CalculatorStates> { CalculatorTrent() : super(BlankScreen()); // Listen to state and alert streams for real-time updates void listenToStates() { stateStream.listen((state) { print("State updated: $state"); }); alertStream.listen((state) { print("Alert received: $state"); }); } }
-
get<YOUR_TYPE_OF_TRENT>()
: Get a Trent instance from the service locator. This is how you access your business logic functions from the UI layer.// Get the CalculatorTrent instance get<CalculatorTrent>().divide(10, 2);
-
watchMap<T, S>()
: Map state to specific UI widgets dynamically and reactively.watchMap<WeatherTrent, WeatherTypes>(context, (mapper) { mapper ..as<Sunny>((state) => Text("Sunny: ${state.temperature}Β°C")) ..as<Rainy>((state) => Text("Rainy: ${state.rainfall}mm")) ..orElse((_) => const Text("No Data")); });```
-
watch<T>()
: Reactive Trent retrieval. Use this when the UI needs to rebuild reactively based on state changes.Copy code final weatherTrent = watch<WeatherTrent>(context); print(weatherTrent.state);
-
TrentManager
widget'strents
field usingregister
function: Initialize multiple Trents at once. This should be done as high-up in the widget tree as possible, preferably in themain.dart
'svoid main()
function. If you don't register your Trents, nothing will work.// Initialize multiple Trents at once void main() { runApp( TrentManager( trents: [ register(WeatherTrent()), register(OtherTrent()), register(AnotherTrent()), // ... ], child: const MyApp(), ), ); }
Use EquatableCopyable
for your state types to enable equality comparison and state copying. Implement the copyWith
method to allow partial updates.
abstract class WeatherTypes extends EquatableCopyable<WeatherTypes> {
@override
List<Object?> get props => [];
}
class NoData extends WeatherTypes {
@override
WeatherTypes copyWith() {
return this;
}
}
class Sunny extends WeatherTypes {
final double temperature;
Sunny(this.temperature);
@override
List<Object?> get props => [temperature];
@override
WeatherTypes copyWith({double? temperature}) {
return Sunny(temperature ?? this.temperature);
}
}
class Rainy extends WeatherTypes {
final double rainfall;
Rainy(this.rainfall);
@override
List<Object?> get props => [rainfall];
@override
WeatherTypes copyWith({double? rainfall}) {
return Rainy(rainfall ?? this.rainfall);
}
}
Extend Trent
to define your state manager and initialize it with an initial state.
class WeatherTrent extends Trent<WeatherTypes> {
WeatherTrent() : super(NoData());
void updateToSunny(double temperature) {
emit(Sunny(temperature));
}
void updateToRainy(double rainfall) {
emit(Rainy(rainfall));
}
void resetState() {
reset();
}
}
For better organization, consider creating a trents
directory to store Trent files for each feature.
lib/
βββ main.dart
βββ trents/
β βββ weather_trent.dart
β βββ auth_trent.dart
β βββ etc.
Initialize your Trents at the top of your widget tree using TrentManager
and register
.
void main() {
runApp(TrentManager(
trents: [register(WeatherTrent())],
child: const MyApp(),
));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
appBar: AppBar(title: const Text('Weather App')),
body: WeatherScreen(),
),
);
}
}
To call business logic functions from the UI, use the get<T>()
utility to retrieve your Trent instance. This allows you to trigger state changes or logic directly from the UI layer. This, of course, is assuming you have a Trent
instance registered in the TrentManager
.
Suppose we have the following Trent class:
class WeatherTrent extends Trent<WeatherTypes> {
WeatherTrent() : super(NoData());
void updateToSunny(double temperature) {
emit(Sunny(temperature));
}
void updateToRainy(double rainfall) {
emit(Rainy(rainfall));
}
void resetState() {
reset();
}
}
Hereβs how you can invoke its methods from the UI:
class WeatherScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
final weatherTrent = get<WeatherTrent>(context);
weatherTrent.updateToSunny(30.0);
},
child: const Text("Set to Sunny"),
),
ElevatedButton(
onPressed: () {
final weatherTrent = get<WeatherTrent>(context);
weatherTrent.updateToRainy(100.0);
},
child: const Text("Set to Rainy"),
),
ElevatedButton(
onPressed: () {
final weatherTrent = get<WeatherTrent>(context);
weatherTrent.resetState();
},
child: const Text("Reset Weather State"),
),
],
);
}
}
- Retrieve the Trent instance: Use
get<T>()
to fetch the Trent instance. We useget
instead ofwatch
becauseget
retrieves the Trent instance directly, whilewatch
is used for reactive UI updates. - Call methods: Trigger the desired business logic function, such as
updateToSunny
,updateToRainy
, orresetState
. - UI updates: The UI automatically reacts to the state changes if
Digester
,watch
,watchMap
, orAlerter
are used in the widget tree below where the Trent was registered.
These widgets and functions provide a declarative and flexible way to respond to state changes and alerts. Alerter
is for listening to alert states, Digester
for building UI based on the current state, while watch
and watchMap
give you more granular control for reactive or dynamic updates.
class WeatherScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Alerter<WeatherTrent, WeatherTypes>(
listenAlerts: (mapper) {
mapper
..as<WeatherAlert>((alert) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Alert: ${alert.message}")),
);
});
},
listenAlertsIf: (oldState, newState) => true,
listenStates: (mapper) {
mapper
..as<Sunny>((state) => print(state))
..as<Rainy>((state) => print(state))
..orElse((_) => const Text("No Data"));
},
child: Digester<WeatherTrent, WeatherTypes>(
child: (mapper) {
mapper
..as<Sunny>((state) => Text("Sunny: ${state.temperature}Β°C"))
..as<Rainy>((state) => Text("Rainy: ${state.rainfall}mm"))
..orElse((_) => const Text("No Data"));
},
),
);
}
}
class WeatherScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final weatherTrent = watch<WeatherTrent>(context);
final state = weatherTrent.state;
if (state is Sunny) {
return Text("Sunny: ${state.temperature}Β°C");
} else if (state is Rainy) {
return Text("Rainy: ${state.rainfall}mm");
} else {
return const Text("No Data");
}
}
}
class WeatherScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return watchMap<WeatherTrent, WeatherTypes>(context, (mapper) {
mapper
..as<Sunny>((state) => Text("Sunny: ${state.temperature}Β°C"))
..as<Rainy>((state) => Text("Rainy: ${state.rainfall}mm"))
..orElse((_) => const Text("No Data"));
});
}
}
Add tests to ensure your Trent works as expected. Existing tests can be found here.
void main() {
test('WeatherTrent state transitions', () {
final trent = WeatherTrent();
// Initial state
expect(trent.state, isA<NoData>());
// Update to Sunny
trent.updateToSunny(25.0);
expect(trent.state, isA<Sunny>());
expect((trent.state as Sunny).temperature, 25.0);
// Update to Rainy
trent.updateToRainy(50.0);
expect(trent.state, isA<Rainy>());
expect((trent.state as Rainy).rainfall, 50.0);
// Reset state
trent.resetState();
expect(trent.state, isA<NoData>());
});
}
-
The package is always open to improvements, suggestions, and additions! Feel free to open issues or pull requests on GitHub.
-
I'll look through PRs and issues as soon as I can!