StatelessJuiceWidget
Overview
StatelessJuiceWidget
is a specialized widget that connects your UI to Juice’s reactive state management system. While a regular StatelessWidget
is static and only rebuilds when its parameters change, a StatelessJuiceWidget
automatically updates in response to state changes from its associated bloc.
Key Features
- Reactive Updates: Automatically rebuilds when bloc state changes
- Smart Rebuilding: Precise control over which widgets rebuild and when
- Type-Safe State Access: Direct, type-safe access to bloc state
- Built-in Error Handling: Automatic error boundary protection
- Streamlined Lifecycle: Managed connection to bloc streams
How It Works
A StatelessJuiceWidget:
- Connects to its associated bloc’s state stream
- Listens for state changes
- Rebuilds automatically when state changes (based on rebuild groups)
- Handles cleanup automatically
Basic Usage
Here’s a simple example:
class CounterDisplay extends StatelessJuiceWidget<CounterBloc> {
CounterDisplay({super.key, super.groups = const {"counter"}});
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return Text(
'Count: ${bloc.state.count}',
style: Theme.of(context).textTheme.headline4,
);
}
}
Comparison with Regular StatelessWidget
Let’s compare implementing the same counter display with both widgets:
Regular StatelessWidget:
// Regular StatelessWidget
class CounterDisplay extends StatelessWidget {
final CounterBloc bloc; // Must be passed in
final int count; // Must be passed in
CounterDisplay({
required this.bloc,
required this.count,
});
@override
Widget build(BuildContext context) {
// No automatic updates - parent must rebuild
return Text('Count: $count');
}
}
// Usage:
StreamBuilder<CounterState>(
stream: bloc.stream,
builder: (context, snapshot) {
return CounterDisplay(
bloc: bloc,
count: snapshot.data?.count ?? 0,
);
},
)
StatelessJuiceWidget:
// StatelessJuiceWidget
class CounterDisplay extends StatelessJuiceWidget<CounterBloc> {
CounterDisplay({super.key, super.groups = const {"counter"}});
@override
Widget onBuild(BuildContext context, StreamStatus status) {
// Automatic updates when state changes
return Text('Count: ${bloc.state.count}');
}
}
// Usage:
CounterDisplay() // That's it!
Key Differences:
- Automatic State Access: No need to manually pass bloc or state
- Built-in Reactivity: No need for StreamBuilder or other wrappers
- Smart Updates: Control rebuilds through groups
- Cleaner Code: Less boilerplate, more focused on UI
- Error Handling: Automatic error boundaries
Controlling Updates
One of the most powerful features of StatelessJuiceWidget is its ability to control exactly when it rebuilds:
class UserProfile extends StatelessJuiceWidget<ProfileBloc> {
// Only rebuild when "profile" group is updated
UserProfile({super.key, super.groups = const {"profile"}});
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return Column(
children: [
Text(bloc.state.name),
Text(bloc.state.email),
],
);
}
}
class SaveButton extends StatelessJuiceWidget<ProfileBloc> {
// Never rebuild - button state is static
SaveButton({super.key, super.groups = optOutOfRebuilds});
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return ElevatedButton(
onPressed: () => bloc.send(SaveProfileEvent()),
child: Text('Save'),
);
}
}
Handling Different States
StatelessJuiceWidget makes it easy to handle different UI states:
class UserProfile extends StatelessJuiceWidget<ProfileBloc> {
UserProfile({super.key, super.groups = const {"profile"}});
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return status.when(
updating: (state, _, __) => ProfileContent(profile: bloc.state.profile),
waiting: (_, __, ___) => CircularProgressIndicator(),
error: (_, __, ___) => Text('Error loading profile'),
canceling: (_, __, ___) => Text('Operation cancelled'),
);
}
}
Overridable Methods
StatelessJuiceWidget provides several methods you can override to customize its behavior:
onInit()
Called when the widget is first initialized. Use this to perform any setup work.
class AnalyticsWidget extends StatelessJuiceWidget<AnalyticsBloc> {
@override
void onInit() {
// Initialize analytics
bloc.send(InitializeAnalyticsEvent());
}
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return AnalyticsView(data: bloc.state.data);
}
}
onStateChange(StreamStatus status)
Called for each state change to determine if the widget should rebuild. Return false to prevent rebuild for this state change.
class OptimizedList extends StatelessJuiceWidget<ListBloc> {
@override
bool onStateChange(StreamStatus status) {
// Only rebuild if items actually changed
if (status is UpdatingStatus) {
final oldItems = status.oldState.items;
final newItems = status.state.items;
return !ListEquality().equals(oldItems, newItems);
}
return true;
}
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return ListView(
children: bloc.state.items.map(
(item) => ItemTile(item: item)
).toList(),
);
}
}
onBuild(BuildContext context, StreamStatus status)
The main build method. Called whenever the widget needs to rebuild.
class UserProfile extends StatelessJuiceWidget<ProfileBloc> {
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return Column(
children: [
if (status is WaitingStatus)
CircularProgressIndicator()
else
UserInfo(user: bloc.state.user),
ErrorMessage(
visible: status is FailureStatus,
message: 'Failed to load profile'
),
],
);
}
}
close(BuildContext context)
Called when the bloc stream is closed. Use this to show a final UI state or clean up.
class ConnectionWidget extends StatelessJuiceWidget<ConnectionBloc> {
@override
Widget close(BuildContext context) {
// Show disconnected state when bloc closes
return DisconnectedView(
message: 'Connection closed',
onReconnect: () => bloc.send(ReconnectEvent()),
);
}
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return ConnectionView(status: bloc.state.status);
}
}
Method Execution Order
Understanding when each method is called:
- Widget Creation
- Constructor called
- onInit() called once
- State Changes
- onStateChange() called first
- if returns true, onBuild() is called
- if returns false, rebuild is skipped
- Bloc Closure
- close() called when bloc stream closes
Example showing all methods:
class CompleteExample extends StatelessJuiceWidget<ExampleBloc> {
CompleteExample({super.key, super.groups = const {"example"}});
@override
void onInit() {
// Called once when widget initializes
bloc.send(InitializeEvent());
}
@override
bool onStateChange(StreamStatus status) {
// Called for every state change
if (status is UpdatingStatus) {
// Skip rebuild if only timestamp changed
return status.state.data != status.oldState.data;
}
return true;
}
@override
Widget onBuild(BuildContext context, StreamStatus status) {
// Called when widget needs to rebuild
return status.when(
updating: (state, _, __) => DataView(data: bloc.state.data),
waiting: (_, __, ___) => LoadingView(),
error: (_, __, ___) => ErrorView(),
canceling: (_, __, ___) => CanceledView(),
);
}
@override
Widget close(BuildContext context) {
// Called when bloc stream closes
return ClosedView(
onReopen: () => bloc.send(ReopenEvent()),
);
}
}
Best Practices
- Access State Through bloc.state
// ✅ Good: Clear state ownership Text(bloc.state.username) // ❌ Bad: Don't use status.state Text(status.state.username)
- Use Targeted Rebuild Groups
// ✅ Good: Only rebuilds profile section super.groups = const {"profile"} // ❌ Bad: Rebuilds on all changes super.groups = const {"*"}
- Separate UI Logic
// ✅ Good: Separate widgets by update needs class ProfileHeader extends StatelessJuiceWidget<ProfileBloc> { super.groups = const {"profile_header"} } class ProfileContent extends StatelessJuiceWidget<ProfileBloc> { super.groups = const {"profile_content"} }
- Handle All States
// ✅ Good: Handle all possible states status.when( updating: (state, _, __) => Content(), waiting: (_, __, ___) => Loading(), error: (_, __, ___) => ErrorView(), canceling: (_, __, ___) => CancelledView(), )
Common Patterns
Loading States
class DataView extends StatelessJuiceWidget<DataBloc> {
@override
Widget onBuild(BuildContext context, StreamStatus status) {
if (status is WaitingStatus) {
return Center(child: CircularProgressIndicator());
}
return ListView(
children: bloc.state.items.map(
(item) => ItemTile(item: item)
).toList(),
);
}
}
Error Handling
class ErrorAwareWidget extends StatelessJuiceWidget<DataBloc> {
@override
Widget onBuild(BuildContext context, StreamStatus status) {
if (status is FailureStatus) {
return ErrorDisplay(
message: 'Failed to load data',
onRetry: () => bloc.send(RetryEvent()),
);
}
return DataDisplay(data: bloc.state.data);
}
}
Static Components
class ActionButtons extends StatelessJuiceWidget<DataBloc> {
// Opt out of rebuilds for static UI
ActionButtons({super.key, super.groups = optOutOfRebuilds});
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return Row(
children: [
ElevatedButton(
onPressed: () => bloc.send(SaveEvent()),
child: Text('Save'),
),
ElevatedButton(
onPressed: () => bloc.send(CancelEvent()),
child: Text('Cancel'),
),
],
);
}
}
Summary
StatelessJuiceWidget provides a powerful, efficient way to build reactive UIs that:
- Automatically update in response to state changes
- Provide precise control over rebuilds
- Handle errors gracefully
- Reduce boilerplate code
- Maintain type safety
By using StatelessJuiceWidget, you get all the benefits of Juice’s state management system with a clean, declarative API that makes building reactive UIs simpler and more maintainable.