JuiceSelector - Optimized State Selection
JuiceSelector is a widget that rebuilds only when a specific portion of state changes. This provides more granular control than rebuild groups and eliminates unnecessary widget rebuilds.
Overview
Traditional approaches rebuild widgets on any state change:
// Rebuilds whenever ANY state property changes
class CounterDisplay extends StatelessJuiceWidget<CounterBloc> {
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return Text('Count: ${bloc.state.count}');
}
}
With JuiceSelector, you specify exactly what to watch:
// Only rebuilds when count changes
JuiceSelector<CounterBloc, CounterState, int>(
selector: (state) => state.count,
builder: (context, count) => Text('Count: $count'),
)
Basic Usage
JuiceSelector<CounterBloc, CounterState, int>(
selector: (state) => state.count,
builder: (context, count) {
return Text(
'Count: $count',
style: Theme.of(context).textTheme.headline4,
);
},
)
The widget:
- Extracts
countfrom state using the selector - Only rebuilds when
countchanges (using==equality) - Passes the selected value directly to the builder
Type Parameters
JuiceSelector<TBloc, TState, TSelected>
TBloc- The bloc type (e.g.,CounterBloc)TState- The state type (e.g.,CounterState)TSelected- The type of the selected value (e.g.,int,String,User)
Selecting Different Types
Primitive Values
// Select int
JuiceSelector<CounterBloc, CounterState, int>(
selector: (state) => state.count,
builder: (context, count) => Text('$count'),
)
// Select String
JuiceSelector<ProfileBloc, ProfileState, String>(
selector: (state) => state.user.name,
builder: (context, name) => Text(name),
)
// Select bool
JuiceSelector<SettingsBloc, SettingsState, bool>(
selector: (state) => state.isDarkMode,
builder: (context, isDark) => Icon(
isDark ? Icons.dark_mode : Icons.light_mode,
),
)
Complex Objects
// Select object
JuiceSelector<ProfileBloc, ProfileState, User?>(
selector: (state) => state.currentUser,
builder: (context, user) {
if (user == null) return Text('Not logged in');
return UserAvatar(user: user);
},
)
Computed Values
// Select computed value
JuiceSelector<TodoBloc, TodoState, int>(
selector: (state) => state.todos.where((t) => t.isCompleted).length,
builder: (context, completedCount) => Text('Done: $completedCount'),
)
Custom Equality with JuiceSelectorWith
For complex types where == isn’t sufficient, use JuiceSelectorWith:
JuiceSelectorWith<TodoBloc, TodoState, List<Todo>>(
selector: (state) => state.todos,
equals: (previous, current) => listEquals(previous, current),
builder: (context, todos) => TodoList(todos: todos),
)
This is useful for:
- Lists and collections
- Objects without proper
==implementation - Deep equality comparisons
Providing a Bloc Instance
By default, JuiceSelector looks up the bloc from BlocScope. You can provide a specific instance:
JuiceSelector<CounterBloc, CounterState, int>(
bloc: mySpecificBloc, // Optional: provide bloc instance
selector: (state) => state.count,
builder: (context, count) => Text('$count'),
)
Using the Stream Extension
For non-widget use cases, use the select extension on blocs:
// Create a stream of selected values
final countStream = bloc.select((state) => state.count);
countStream.listen((count) {
print('Count changed to: $count');
});
// With custom equality
final todosStream = bloc.selectWith(
(state) => state.todos,
equals: (a, b) => listEquals(a, b),
);
Practical Examples
Counter with Multiple Displays
class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
// Only rebuilds when count changes
JuiceSelector<CounterBloc, CounterState, int>(
selector: (state) => state.count,
builder: (context, count) => Text('Count: $count'),
),
// Only rebuilds when message changes
JuiceSelector<CounterBloc, CounterState, String>(
selector: (state) => state.message,
builder: (context, message) => Text(message),
),
// Never rebuilds (static button)
ElevatedButton(
onPressed: () => BlocScope.get<CounterBloc>().send(IncrementEvent()),
child: Text('Increment'),
),
],
);
}
}
User Profile
class ProfileHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
children: [
// Only rebuilds when avatar URL changes
JuiceSelector<ProfileBloc, ProfileState, String?>(
selector: (state) => state.user?.avatarUrl,
builder: (context, avatarUrl) => CircleAvatar(
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
),
),
// Only rebuilds when name changes
JuiceSelector<ProfileBloc, ProfileState, String>(
selector: (state) => state.user?.name ?? 'Guest',
builder: (context, name) => Text(name),
),
],
);
}
}
Shopping Cart
class CartSummary extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
children: [
// Item count badge
JuiceSelector<CartBloc, CartState, int>(
selector: (state) => state.items.length,
builder: (context, count) => Badge(
label: Text('$count'),
child: Icon(Icons.shopping_cart),
),
),
// Total price
JuiceSelector<CartBloc, CartState, double>(
selector: (state) => state.totalPrice,
builder: (context, total) => Text('\$${total.toStringAsFixed(2)}'),
),
],
);
}
}
Loading State Indicator
JuiceSelector<DataBloc, DataState, bool>(
selector: (state) => state.isLoading,
builder: (context, isLoading) {
if (isLoading) {
return CircularProgressIndicator();
}
return SizedBox.shrink();
},
)
Comparison with Alternatives
vs Rebuild Groups
| Feature | JuiceSelector | Rebuild Groups |
|---|---|---|
| Granularity | Per-property | Per-group |
| Setup | Inline | Define groups + emit |
| Equality | Automatic | N/A |
| Use case | Fine-grained | Coarse-grained |
Use JuiceSelector when:
- You need per-property rebuild control
- You want automatic equality checking
- You’re selecting computed values
Use rebuild groups when:
- Multiple widgets should update together
- You have well-defined UI sections
- You need to coordinate updates
vs StatelessJuiceWidget with Groups
// Rebuild groups approach
class CounterDisplay extends StatelessJuiceWidget<CounterBloc> {
CounterDisplay({super.groups = const {"counter"}});
@override
Widget onBuild(BuildContext context, StreamStatus status) {
return Text('${bloc.state.count}');
}
}
// JuiceSelector approach
JuiceSelector<CounterBloc, CounterState, int>(
selector: (state) => state.count,
builder: (context, count) => Text('$count'),
)
Best Practices
1. Keep Selectors Simple
// Good - simple property access
selector: (state) => state.count
// Good - simple computation
selector: (state) => state.items.length
// Avoid - complex computation in selector
selector: (state) => expensiveComputation(state.data)
2. Use Memoization for Expensive Computations
If you need expensive computations, do them in the state:
// In state class
class TodoState extends BlocState {
final List<Todo> todos;
// Compute once, cache in state
late final int completedCount = todos.where((t) => t.completed).length;
}
// In widget - just select the precomputed value
selector: (state) => state.completedCount
3. Prefer Multiple Selectors Over One Complex One
// Good - separate selectors
Column(
children: [
JuiceSelector<...>(selector: (s) => s.name, ...),
JuiceSelector<...>(selector: (s) => s.email, ...),
],
)
// Avoid - tuple/record selector
JuiceSelector<..., (String, String)>(
selector: (s) => (s.name, s.email),
builder: (context, values) => ...,
)
4. Use JuiceSelectorWith for Collections
// For lists, use custom equality
JuiceSelectorWith<TodoBloc, TodoState, List<Todo>>(
selector: (state) => state.todos,
equals: listEquals,
builder: ...,
)
API Reference
JuiceSelector
| Property | Type | Description |
|---|---|---|
selector | T Function(TState) | Extracts value from state |
builder | Widget Function(BuildContext, T) | Builds widget with selected value |
bloc | TBloc? | Optional bloc instance |
JuiceSelectorWith
| Property | Type | Description |
|---|---|---|
selector | T Function(TState) | Extracts value from state |
equals | bool Function(T, T) | Custom equality function |
builder | Widget Function(BuildContext, T) | Builds widget with selected value |
bloc | TBloc? | Optional bloc instance |
Stream Extensions
// On JuiceBloc
Stream<T> select<T>(T Function(TState) selector)
Stream<T> selectWith<T>(
T Function(TState) selector, {
required bool Function(T, T) equals,
})