
Juice
Juice is a lifecycle-aware Flutter application framework built around use-case driven blocs, explicit ownership, and targeted rebuilds.
What It Solves
Juice is aimed at apps that need stronger structure than simple local state tools provide:
- long-running async work with waiting, failure, and canceling states
- app-wide blocs vs feature-owned blocs vs widget-owned blocs
- targeted rebuilds for performance-sensitive screens
- cross-bloc coordination without pushing orchestration into widgets
- a coherent ecosystem for storage, networking, auth, and routing
The framework is most compelling when you lean into ownership and lifecycle, not when you use it as a generic counter-style state wrapper.
Core Capabilities
- Use-case driven business logic: blocs route events into dedicated use cases
- Per-event concurrency:
EventConcurrency.sequential/droppable/restartable/concurrenton every builder controls how same-type events interleave (1.5.0;restartable— latest input wins, the superseded run fenced — 2.0.0). Required since 2.0.0: a mode is a decision, and the compiler asks for it - Lifecycle ownership:
permanent,feature, andleasedlifecycles viaBlocScope - Targeted rebuilds: rebuild groups let widgets subscribe narrowly. Measured against bloc and Riverpod (benchmarks): 1 widget rebuilt per update with 0 selector calls, the same counts as a Riverpod provider-per-cell family (selector-based targeting runs one selector per consumer per update); per frame, groups tie Riverpod's select and family forms on a Mac and a clock-pinned iPhone and lead bloc's BlocSelector (§16–§17), and untargeted defaults cost about 2.5×
- Status-aware streams:
StreamStatusseparates transient workflow state from persistent app state - Cross-bloc orchestration: event subscriptions, state relays, and status relays
- Per-item async state:
EntityStatuses<K>+BlocUseCase.guardEntitytrack which rows of a collection are in flight or failed — the item-grained companion toStreamStatus, with guaranteed cleanup (no stuck spinners) (1.6.0). See the EntityStatus guide and spec. - Skip duplicate emits:
emitUpdate(..., skipIfSame: true)drops an emission whennewState == state(loggingstate_emission_skipped) — a per-call opt-in for no-op refreshes; requires value equality on the state (1.7.0) - Typed telemetry: the framework reports through
JuiceLogger.record(JuiceTelemetry)— one typed object per entry, a closed vocabulary (JuiceTelemetryKind), values live, nothing stringified until a logger prints; the reference is doc/TELEMETRY.md. Every use-case execution is a span: a start and an end entry sharing a process-uniqueexecutionId, withelapsedMicros— honest durations even when same-type events overlap, live in DevTools viajuice_observability'sDevtoolsJuiceLogger - Telemetry cost follows the consumer: a logger declares the level it keeps (
LevelAwareJuiceLogger) and the framework builds nothing below it — the default logger keeps nothing in release, so an unconfigured app pays for no chatter there;JuiceLoggerConfig.minLevelis the global floor. A send is 0.66 µs with the chatter built and 0.44 µs without it, on the benchmark Mac (RESULTS §15); errors and failures stay loud - Against bloc and Riverpod (RESULTS §16–§17, 2.0.0, Mac and a clock-pinned iPhone 17 Pro Max): sequential dispatch per update, release default — Juice 0.73 / 0.63 µs, bloc 1.04 / 1.03, Riverpod 0.42 / 0.41; with every telemetry record built Juice ties bloc; in burst bloc leads. Riverpod's notifier is a synchronous call with no event queue, the floor for that shape. Per frame the tuned Juice and Riverpod forms tie (groups vs family 0.5% / 0.0%) and bloc's BlocSelector trails them (18% on the Mac, 10% on the phone); where the scenario hurts groups (50 of 1,000 updated) Juice leads BlocSelector by 7% and Riverpod select by 17% on the phone. Drift toolchain (3.44.4 against the 3.47.5 pin); the harness declares ties when ranges overlap
Quick Example
class CounterState extends BlocState {
final int count;
const CounterState({this.count = 0});
CounterState copyWith({int? count}) {
return CounterState(count: count ?? this.count);
}
}
class IncrementEvent extends EventBase {}
class IncrementUseCase extends BlocUseCase<CounterBloc, IncrementEvent> {
@override
Future<void> execute(IncrementEvent event) async {
emitUpdate(
newState: bloc.state.copyWith(count: bloc.state.count + 1),
groupsToRebuild: {'counter'},
);
}
}
class CounterBloc extends JuiceBloc<CounterState> {
CounterBloc()
: super(
const CounterState(),
[
() => UseCaseBuilder(
typeOfEvent: IncrementEvent,
useCaseGenerator: () => IncrementUseCase(),
concurrency: EventConcurrency.sequential,
),
],
);
}
Installation
dependencies:
juice: ^2.0.0
Recommended Evaluation Path
Start with the examples that show Juice at its strongest:
notes_appinpackages/juice_examples: leased editor, autosave, rebuild groups, storagesocial_feed: fetch/caching/coalescing throughjuice_networkdashboard: auth lifecycle + routing guardsecommerce: ecosystem composition across packages
The repository root example/ app is a framework showcase and API sampler. It is useful for browsing concepts quickly, but it is not the primary architecture reference.
Ecosystem Packages
juice_storage: local persistence, TTL cache, secure storagejuice_network: fetch, retry, coalescing, interceptor pipelinejuice_routing: declarative routing and guard pipelinejuice_auth: provider-agnostic session lifecycle
Release Notes
This release tightens package hygiene, treats packages/juice as the single source of truth for the core framework, and shifts the public story toward lifecycle-aware app architecture rather than generic state management alone.