Trent는 간단하고 확장 가능하며 반응형 상태 관리를 위한 Flutter 패키지입니다. 내장된 종속성 주입을 통합하고 효율적인 스트림 기반 메커니즘을 사용하여 상태 변경을 처리합니다. 명확한 관심사 분리와 실시간 업데이트가 필요한 앱에 적합하며, 플랫폼 간 성능을 유지하면서 부가 코드를 줄입니다. 반응형 특성으로 인해 상태가 변경될 때 UI가 자동으로 업데이트되어 반응성 있는 사용자 경험을 제공합니다.
사용 사례
실시간 데이터 동기화
크로스 플랫폼 앱 상태 관리
복잡한 UI에서 반복 코드 줄이기
대규모 앱에서의 의존성 주입
스트림 기반 이벤트 처리
주요 기능
내장된 종속성 주입
스트림 기반 상태 업데이트
최소한의 보일러플레이트
반응형 UI 업데이트
확장 가능한 아키텍처
적합한 대상
경량 상태 관리가 필요한 개발자
크로스플랫폼 애플리케이션을 개발하는 팀
효율적인 종속성 해결이 필요한 프로젝트
자주 상태가 변경되는 앱
깨끗하고 반응형 접근을 원하는 사람
고려 사항
스트림에 대한 이해가 필요합니다
반응형 프로그래밍 패턴에 가장 적합
복잡한 종속성에 대해 추가 설정이 필요할 수 있습니다
반응형 개념에 익숙하지 않은 개발자를 위한 학습 곡선
성숙한 대안에 비해 문서화가 제한적
원본 README
연결된 소스 저장소의 README 원문 스냅샷입니다. 최신 내용은 저장소에서 확인하세요.
Simple, Reactive, Scalable, & Opinionated State Management Library
Why would you use Trent over other state management libraries?
Ease of use:
Built-in dependency injection and service locators.
Built-in optimistic update functionality.
Boasts simple Alerter and Digester widgets for managing UI layer reactively.
Fine-grained control:
Includes special shorthand watch<T>, watchMap<T, S>, and get<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 an orElse. 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).
Clean separation of concerns: UI layer & business logic layer.
You can wrap Trent handler functions in cancelableAsyncOps to ensure async calls don't leak across multiple state resets. Further, you can cancel any in-flight async operations that were wrapped in cancelableAsyncOp with a Trent's handler functions by calling reset({bool cancelInFlightAsyncOps = true}) or cancelInFlightAsyncOps.
UI Layer That "Responds" To Your Business Logic States
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.
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.
Business Logic Layer
Define custom state classes, then use them in your Trent state manager:
//// Classes A, B, and C defined here //// A single Trent state manager classclassAuthTrentextendsTrent<AuthTypes> {
AuthTrent() : super(A(1)); // Set initial state// You can add N number of business logic functions to// do logic and alter statevoidbusinessLogicHere() {
//// Business logic here//// Based on the business logic, you can alter state// using build-in methods like:// Emit a new state WITH the UI reactingemit(C());
// Set a new state WITHOUT the UI reactingset(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 manipulationprint(state);
}
// ... More business functions ...
}
Full API 📚
UI Layer: Built-in Widgets
Alerter widget that listens to one-time state alert(...)s from your business logic layer in listenAlerts. This is good if your business logic needs to "quickly send off a state without saving it". An example would be you having Loading, Data, and WarningNotification states. You may be in Data state, but want to send off a quick WarningNotification state without having to throw away your Data state. This is what an alert(WarningNotification(...)) is good for. Alerter can also can listen to regular state updates in listenStates. Both can have their listeners programmatically toggled on/off with listenAlertsIf and listenStatesIf 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) => constText("Rendered if no more specific type is defined"))
..as<A>((state) => Text("State is A"))
..as<B>((state) => constText("State is B"))
..as<C>((state) => constText("State is C"));
},
),
Business Logic Layer: Built-in Functions
emit(state): Emit a new state with the UI reacting.
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Emit a new state to update the UI with a calculation resultvoidshowResult(double result) {
emit(CalculationResult(result));
}
}
set(state): Set a new state without the UI reacting.
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Set the state to prepare for a calculation without triggering a UI updatevoidprepareCalculation() {
set(Division(10, 2));
}
}
alert(state): Alert a temporary state WITHOUT setting/saving it, but being able to listen to it from the Alerter widget (for things like notifications).
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Send an alert to notify of an error without changing the current statevoidalertError(String message) {
alert(InvalidCalculation(message));
}
}
getExStateAs<T>(): This will return the last state of type T. Useful for accessing a state you transitioned away from. For example, if you transitioned from Division to Multiplication, you can still access the last value of the Division state after transitioning away from it.
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Access the previous Division state if it existsvoidreusePreviousDivision() {
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 type T. Useful for specific state operations.
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Log the current result if the state is a CalculationResultvoidlogCurrentResult() {
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.
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Perform different actions depending on the current state typevoidhandleState() {
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.
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Print the raw state for debugging or custom handlingvoidprintRawState() {
print("Raw state: $state");
}
}
clearEx(state): Clears the memory of the last state of a specific type.
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Forget the last Division statevoidforgetPreviousDivision() {
clearEx<Division>();
}
}
clearAllExes(): Clears the memory of all previous states.
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Clear the memory of all previous statesvoidresetMemory() {
clearAllExes();
}
}
reset({bool cancelInFlightAsyncOps = true}): Resets the Trent to its initial state. Optionally, you can cancel any in-flight async operations that were wrapped in cancelableAsyncOp with a Trent's handler functions. This is to prevent async calls from leaking across multiple state resets.
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Reset the Trent to its initial statevoidresetCalculator() {
reset();
}
}
dispose(): Disposes the Trent, closing its state streams.
classCalculatorTrentextendsTrent<CalculatorStates> {
CalculatorTrent() : super(BlankScreen());
// Clean up resources by disposing of the Trentvoidcleanup() {
dispose();
}
}
cancelInFlightAsyncOps: Wraps async operations to ensure they don't leak across multiple state resets. This makes them cancelable if you call either reset({bool cancelInFlightAsyncOps = true}) or cancelInFlightAsyncOps.
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's trents field using register function: Initialize multiple Trents at once. This should be done as high-up in the widget tree as possible, preferably in the main.dart's void main() function. Trents need to be registered, else things won't work. Alternatively, you can call register with your Trents beforerunApp is called, however, you still need to wrap your app in a TrentManager widget. You can mix and match both techniques.
Optimistic updates let you immediately reflect a change in your UI before an async operation (like a network request) completes. This makes your app feel faster and more responsive. If the async operation fails, the optimistic update can be reverted.
Trent provides a built-in API for optimistic updates via the optimisticUpdate<T>(...) method, available on any Trent instance. Optimistic updates are tracked by tag, can be explicitly accepted or rejected, and are automatically cleaned up on reset().
Tags
A tag is a string that identifies the optimistic update. If you run multiple optimistic updates with the same tag, only the latest effect for that tag is present; previous effects are fully reverted before the new one is applied. This ensures repeated or colliding updates don't stack up and cause state confusion. If you don't provide a tag, Trent will generate a universally unique tag for you.
There are three main ways to resolve an optimistic update:
Accept: Call accept() on the attempt to confirm the optimistic update (e.g., after a successful async operation). This makes the update permanent and removes it from the pending list.
Accept As: Call acceptAs(newValue) to accept the update but with a new value (runs revert, then applies the new value as a new optimistic update).
Reject: Call reject() to revert the optimistic update (e.g., if the async operation fails). This will undo the effect and remove it from the pending list.
All of these methods are available on the returned OptimisticAttempt:
final attempt = optimisticUpdate<int>(
tag: "counter",
forward: (state, value) => state.copyWith(value: state.value + value),
reverse: (state, value) => state.copyWith(value: state.value - value),
);
attempt.execute(10); // Apply +10 optimistically// Later, after async completes:
attempt.accept(); // Accepts and finalizes// or
attempt.reject(); // Reverts the update// or
attempt.acceptAs(42); // Reverts +10, then applies +42
Optimistic updates are async-safe: if you run multiple with the same tag before accepting, acceptingAs, or rejecting, only the latest is kept and previous ones are reverted before the new one is applied. This prevents flooding/collision issues.
If you always resolve each optimistic attempt (by calling accept(), acceptAs(...), or reject() on every attempt you create), you will not leak memory or state. The additional cleanup methods (like rejectAllUnresolvedOptimisticUpdates) are provided as extra safety for cases where you might forget to resolve, or for bulk cleanup after network failures, app suspends, or other edge cases.
Cleanup
To prevent memory leaks or stale updates, Trent provides a cleanup method:
rejectAllUnresolvedOptimisticUpdates({Duration? olderThan}): Rejects all unresolved optimistic updates, or only those older than a given duration. This is useful for cleaning up after network failures, app suspends, or just to ensure your state is fresh.
// Reject all unresolved optimistic updates
trent.rejectAllUnresolvedOptimisticUpdates();
// Reject only those older than 30 seconds
trent.rejectAllUnresolvedOptimisticUpdates(olderThan: Duration(seconds: 30));
Optimistic updates are also automatically cleaned up on a Trent's reset(...) being called.
Example:
final attempt = optimisticUpdate<int>(
tag: "saveDraft",
forward: (state, value) => state.copyWith(value: value),
reverse: (state, value) => state.copyWith(value: state.value - value),
);
attempt.execute(123);
// If the save fails after 10 secondsFuture.delayed(Duration(seconds: 10), () {
attempt.reject();
});
// Or, to clean up all stale attempts after a while
trent.rejectAllUnresolvedOptimisticUpdates(olderThan: Duration(seconds: 10));
This system ensures your UI remains responsive, your state stays consistent, and you have full control over optimistic updates and their lifecycle.
How to Use
1. Define Your State Types
Use EquatableCopyable for your state types to enable equality comparison and state copying. Implement the copyWith method to allow partial updates.
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.
Retrieve the Trent instance: Use get<T>() to fetch the Trent instance. We use get instead of watch because get retrieves the Trent instance directly, while watch is used for reactive UI updates.
Call methods: Trigger the desired business logic function, such as updateToSunny, updateToRainy, or resetState.
UI updates: The UI automatically reacts to the state changes if Digester, watch, watchMap, or Alerter are used in the widget tree below where the Trent was registered.
6. Use Alerter, Digester, watch, and watchMap in Your UI
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.
Example UI Implementation with Alerter and Digester