dry_bloc 1.0.0
dry_bloc: ^1.0.0 copied to clipboard
States and functions for bloc package that reduces boilerplate
DryBloc(Don't Repeat Yourself Bloc) #
A Dart package that provides structured state management with the BLoC pattern, reducing boilerplate code and standardizing error handling. Under the hood, it's a standard Bloc
with its full API, but dry_bloc
adds syntactic sugar, handles exceptions, and manages state transitions, making working with BLoC a breeze!
Features: #
- Standardized state types for various use cases
- Built-in error handling with typed exceptions
- Pattern matching for state handling
- Reduced boilerplate for common BLoC operations
Example: #
class LoadProfileEvent {
const LoadProfileEvent();
}
typedef LoadProfileState = DrySuccessDataState<User, UserLoadError>;
class LoadProfileBloc
extends DrySuccessDataBloc<LoadProfileEvent, User, UserLoadError> {
LoadProfileBloc({required this.profileService}) {
handle<LoadProfileEvent>((event) => profileService.loadUser());
}
@protected
final ProfileService profileService;
}
That's all it takes! With minimal code, dry_bloc
handles the underlying complexities.
Let's break down what's happening here.
We'll start with the state. We've defined a typedef
for convenience, making it easier to reference our Bloc
's state. But what is DrySuccessDataState
, and what are the types (<User, UserLoadError>
) passed as generics?
State Types #
There are four primary states when implementing an asynchronous operation:
- Initial
- Loading
- Success
- Failure
Our example above demonstrates this. We send a request to the server and wait for a response.
You've likely written many similar states. Even using freezed to reduce boilerplate, it's still quite verbose. For example, these states implemented with freezed
would look like this:
@freezed
sealed class LoadProfileState with _$LoadProfileState {
const factory LoadProfileState.initial() = _LoadProfileInitialPage;
const factory LoadProfileState.loading() = _LoadProfileLoadingState;
const factory LoadProfileState.success(User user) = _LoadProfileSuccessState;
const factory LoadProfileState.failure(UserLoadError error) = _LoadProfileFailureState;
}
And you're not done! Code generation is still required. Recent versions of freezed
have also removed pattern-matching methods, adding complexity.
Now for the good news! DrySuccessDataState<User, UserLoadError>
provides the same functionality as the freezed
example, but with pattern-matching methods and without code generation!
DryBloc
offers three main state types:DryEmptyState
,DryDataState
, andDrySuccessDataState
.
1. DrySuccessDataState
You've already seen DrySuccessDataState
in action. Like the others, it represents four states (Initial, Loading, Success, and Failure). In the Success
state, you receive the data resulting from the successful operation (in our example, the user data from the server). This state (and the corresponding DrySuccessDataBloc
) is ideal for loading data that should be available upon successful completion.
2. DryEmptyState
This also represents four states, but all are empty (no data). It's useful for asynchronous operations where you don't need the result. It's equivalent to:
@freezed
sealed class LoadProfileState with _$LoadProfileState {
const factory LoadProfileState.initial() = _LoadProfileInitialPage;
const factory LoadProfileState.loading() = _LoadProfileLoadingState;
const factory LoadProfileState.success() = _LoadProfileSuccessState;
const factory LoadProfileState.failure(UserLoadError error) = _LoadProfileFailureState;
}
3. DryDataState
This represents four states, each containing data. It's useful when you need data in every state. It's equivalent to:
@freezed
sealed class LoadProfileState with _$LoadProfileState {
const factory LoadProfileState.initial(User user) = _LoadProfileInitialPage;
const factory LoadProfileState.loading(User user) = _LoadProfileLoadingState;
const factory LoadProfileState.success(User user) = _LoadProfileSuccessState;
const factory LoadProfileState.failure(User user, UserLoadError error) = _LoadProfileFailureState;
}
Pattern Matching #
All state types support pattern matching for cleaner UI code:
state.when(
initial: () => InitialView(),
loading: () => LoadingView(),
success: (data) => SuccessView(data: data),
failure: (exc) => ErrorView(exception: exc),
);
// Or with nullable return
final message = state.whenOrNull(
success: (data) => 'Success: ${data.length} items',
failure: (exc) => 'Error: ${exc}',
);
// Or with default fallback
final widget = state.maybeWhen(
orElse: (_) => DefaultView(),
success: (data) => SuccessView(data: data),
);
You can use these states independently or with their corresponding
DryBloc
classes::
DrySuccessDataState
withDrySuccessDataBloc
DryEmptyState
withDryEmptyBloc
DryDataState
withDryDataBloc
DryBloc #
Now, let's look at the DryBloc
itself. What does it do under the hood? How does it manage state changes?
A common way to handle events might look like this:
Future<void> _handle(event, emit) async {
emit(const LoadProfileState.loading());
try {
final result = await profileService.loadUser();
emit(
result.when(
data: (user) => LoadProfileState.success(user),
error: (e) => LoadProfileState.failure(e),
),
);
} on Object catch (e) {
emit(const LoadProfileState.failure());
rethrow;
}
}
With dry_bloc
, all you need to write is profileService.loadUser()
. DryBloc
's handle
method takes care of the rest:
LoadProfileBloc({required this.profileService}) {
handle<LoadProfileEvent>((event) => profileService.loadUser());
}
Error Handling #
The previous example uses a monad with two states: Data
and Error
:
result.when(
data: (user) => LoadProfileState.success(user),
error: (e) => LoadProfileState.failure(e),
),
Result.error
is returned when an error occurs during normal application operation (a business logic error). This distinguishes between:
- Fatal/critical exceptions (errors that shouldn't happen, like data parsing errors)
- Business logic errors (e.g., attempting a Bluetooth operation when Bluetooth is off)
Exceptions caught in a catch
block are considered fatal, while errors from the monad are business logic errors.
DryBloc
handles these exceptions for you, eliminating the need for monads and reducing code.
dry_bloc
provides these exception types:
DryFatalException
DryBusinessException
, which is further divided into:DryBusinessTypedException
DryBusinessUntypedException
How do you work with these?
The exception is delivered to your UI via Dry*State.failure()
, allowing you to display the appropriate widget or error message based on the exception type:
BlocListener<VerifyForgotCodeBloc, VerifyForgotCodeState>(
listener: (context, state) {
state.whenOrNull(
failure: (exc) {
exc.when(
fatal: (error) => /* Handle fatal error */,
businessTyped: (error) => /* Handle typed business error */,
businessUntyped: (error) => /* Handle untyped business error */,
);
},
);
},
),
How does
DryBloc
classify exceptions?
It uses the type parameter passed to the DryBloc
:
class LoadProfileBloc
extends DrySuccessDataBloc<LoadProfileEvent, User, UserLoadError> {
/// ...
}
Here, UserLoadError
represents a typed business logic error, which is non-fatal. If your service throws a UserLoadError
(or a subclass), DryBloc
handles it and emits a failure state. This replaces the need for monads. Instead of returning Result.data()
or Result.error()
, you return the result directly when successful and throw an error of the specified type when unsuccessful.
What about
DryFatalException
?
Any other exception caught by DryBloc
is considered fatal. This allows you to log these errors (e.g., using runZonedGuarded
's onError
callback) and report them to crash reporting services.
Ok, and what's with
DryBusinessUntypedException
? How can we get those?
By default, you won't get those. This type of exception was introduced in case you don't want some exception to be threated as fatal, but, at the same time, for some reason, you don't want/can't mark it as business-logic typed(by passing its type to DryBloc
).
DryBusinessUntypedException
occurs when an exception is marked as non-fatal but doesn't match the specified business logic error type. To mark an exception as non-fatal:
- Globally: Use
DryBloc.globalIsFatalException
. - Bloc-scoped: Override
isFatalException()
. - Event-scoped: Use the
isFatalExceptionOverride
parameter of thehandle
method.
You can also override DryBloc.handle()
and DryBloc.handleException()
.
All exceptions, after emitting the corresponding failure state, are rethrown, so you can catch them using the Zones API. For example:
runZonedGuarded(
() async {
runApp(...);
},
(error, stack) {
// This package also provides you the maybeWhenDryException() method
// so you can easily pattern-match the exception
error.maybeWhenDryException(
// If you want to log to the Crashlytics only fatal exceptions
businessTyped: (error) {},
businessUntyped: (error) {},
// In this case the fatal handler is redundant, it's just for demonstration
fatal: (error) {
FirebaseCrashlytics.instance.recordError(error, stack);
},
orElse: (error) {
FirebaseCrashlytics.instance.recordError(error, stack);
}
);
},
);
As you can see above, you are free to decide which exceptions to log and where to log them. The exception types are finely categorized.
License #
This project is licensed under the MIT License - see the LICENSE file for details.