Juice Logo

JUICE

A Reactive Architecture for Flutter Combining Clean Design with BLoC, Stream-Based State Management

pub package License: MIT

Juice provides a complete architectural framework for Flutter applications, combining reactive state management with clean, maintainable code organization. By integrating reactive streams with explicit use cases and strong architectural boundaries, Juice helps teams build robust, scalable applications.

Key Features

State Management

  • Stream-Based Design - Familiar reactive patterns with StreamStatus
  • Smart Widget Rebuilding - Precise control over UI updates
  • State Lifecycle - Built-in handling of loading, error, cancel and success states

Architecture

  • First-Class Use Cases - Isolated, testable business logic
  • Clean Dependencies - Flexible dependency resolution
  • Strong Boundaries - Clear separation between UI, business logic, and state

Bloc Lifecycle Management

  • Permanent Blocs - App-level blocs that live for the entire application lifetime
  • Feature Blocs - Scoped blocs that are disposed when a feature completes
  • Leased Blocs - Widget-level blocs with automatic reference-counted disposal

Enhanced Performance

  • Smart Widget Rebuilding - Powerful group-based system gives you precise control over UI updates
  • Efficient State Updates - Optimized stream-based state management prevents unnecessary rebuilds
  • Resource Management - Automatic cleanup and disposal prevents memory leaks

Developer Experience

  • Type-Safe Navigation - Built-in navigation with full type safety and middleware support
  • Cancellation Support - First-class handling of operation cancellation and timeouts
  • Error Handling - Consistent error handling and recovery across the application

Reactive Patterns

  • Stream-Based Design - Familiar reactive patterns with StreamStatus for managing state transitions
  • State Lifecycle - Built-in handling of loading, error, and success states
  • Event-Driven - Clear, predictable flow of events through the application

Architecture Overview

Juice creates a clean separation between:

UI Layer (Widgets)
    ↕️
Business Logic (Use Cases)
    ↕️
State Management (Blocs)
    ↕️
Lifecycle Management (BlocScope)
  • Widgets focus purely on UI rendering and user interaction
  • Use Cases encapsulate individual business operations
  • Blocs manage state and coordinate use cases
  • BlocScope controls bloc registration, resolution, and lifecycle

This separation provides clear boundaries while maintaining reactive state updates throughout the application.

Why Juice?

Juice solves common architectural challenges:

  • Business Logic Organization: Use cases make complex operations manageable and testable
  • State Management: Built-in handling of loading, error, and success states
  • Lifecycle Management: Semantic control over bloc creation and disposal with permanent, feature, and leased lifecycles
  • UI Performance: Fine-grained control over widget rebuilds
  • Navigation: Type-safe routing with deep linking support
  • Testing: Clear boundaries make unit and integration testing straightforward
  • Scalability: Clean architecture principles support growing codebases

Quick Example

// A complete example showing a counter implementation with:
// - Reactive state management
// - Loading state handling
// - Clean separation of concerns

// Define a use case
class IncrementUseCase extends BlocUseCase<CounterBloc, IncrementEvent> {
  @override
  Future<void> execute(IncrementEvent event) async {
    final newState = bloc.state.copyWith(count: bloc.state.count + 1);
    emitUpdate(groupsToRebuild: {"counter"}, newState: newState);
  }
}

// Create a bloc
class CounterBloc extends JuiceBloc<CounterState> {
  CounterBloc() : super(
    CounterState(count: 0),
    [
      () => UseCaseBuilder(
        typeOfEvent: IncrementEvent,
        useCaseGenerator: () => IncrementUseCase()
      ),
    ],
    [],
  );
}

// Create a reactive widget
class CounterWidget extends StatelessJuiceWidget<CounterBloc> {
  @override
  Widget onBuild(BuildContext context, StreamStatus status) {
    if (status is Waiting) return CircularProgressIndicator();
    
    return Text('Count: ${bloc.state.count}');
  }
}

Installation

Add Juice to your pubspec.yaml:

flutter pub add juice

This will add the latest version of Juice to your pubspec.yaml automatically.

Alternatively, add it manually:

dependencies:
  juice: ^1.1.1

Then, run:

flutter pub get

Quick Start

1. Initialize Juice

void main() {
  // Register your blocs with appropriate lifecycles
  BlocScope.register<CounterBloc>(
    () => CounterBloc(),
    lifecycle: BlocLifecycle.permanent, // Lives for app lifetime
  );

  runApp(MyApp());
}

2. Define Your State

class CounterState extends BlocState {
  final int count;
  
  CounterState({required this.count});
  
  CounterState copyWith({int? count}) {
    return CounterState(count: count ?? this.count);
  }
}

3. Create Events

class IncrementEvent extends EventBase {}
class DecrementEvent extends EventBase {}

4. Implement Use Cases

class IncrementUseCase extends BlocUseCase<CounterBloc, IncrementEvent> {
  @override
  Future<void> execute(IncrementEvent event) async {
    final newState = bloc.state.copyWith(count: bloc.state.count + 1);
    emitUpdate(
      newState: newState,
      groupsToRebuild: {"counter"}
    );
  }
}

5. Create Your Bloc

class CounterBloc extends JuiceBloc<CounterState> {
  CounterBloc()
      : super(
          CounterState(count: 0),
          [
            () => UseCaseBuilder(
                typeOfEvent: IncrementEvent,
                useCaseGenerator: () => IncrementUseCase()),
            () => UseCaseBuilder(
                typeOfEvent: DecrementEvent,
                useCaseGenerator: () => DecrementUseCase()),
          ],
          [], // Aviators
        );
}

6. Build Your UI

class CounterWidget extends StatelessJuiceWidget<CounterBloc> {
  CounterWidget({super.key, super.groups = const {"counter"}});

  @override
  Widget onBuild(BuildContext context, StreamStatus status) {
    return Text(
      'Count: ${bloc.state.count}',
      style: const TextStyle(fontSize: 32),
    );
  }
}
class CounterButtons extends StatelessJuiceWidget<CounterBloc> {
  CounterButtons({super.key, super.groups = ignoreAllRebuilds});

  @override
  Widget onBuild(BuildContext context, StreamStatus status) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        ElevatedButton(
          onPressed: () => bloc.send(IncrementEvent()),
          child: const Text('+'),
        ),
        const SizedBox(width: 16),
        ElevatedButton(
          onPressed: () => bloc.send(DecrementEvent()),
          child: const Text('-'),
        ),
        const SizedBox(width: 16),
        ElevatedButton(
          onPressed: () => bloc.send(ResetEvent()),
          child: const Text('Reset'),
        ),
      ],
    );
  }
}

Advanced Features

Group-Based Rebuilds

Control which widgets rebuild based on state changes:

class UserProfileWidget extends StatelessJuiceWidget<ProfileBloc> {
  // Only rebuild when "profile" group is triggered
  UserProfileWidget({super.key, super.groups = const {"profile"}});
  
  @override
  Widget onBuild(BuildContext context, StreamStatus status) {
    return Text(bloc.state.username);
  }
}

StreamStatus for State Management

Handle different states elegantly:

@override
Widget onBuild(BuildContext context, StreamStatus status) {
  // Handle status changes while keeping type safety
  if (status.isWaitingFor<DataState>()) {
    return CircularProgressIndicator();
  }
  if (status.isFailureFor<DataState>()) {
    return Text('Error loading data');
  }
  
  // Access state directly through bloc for type safety
  return Text(bloc.state.data);
}

Type-safe navigation management:

// Specific route aviator
() => Aviator(
  name: 'profile',
  navigate: (args) {
    final bloc = BlocScope.get<AppBloc>();
    final userId = args['userId'] as String;
    bloc.navigatorKey.currentState?.pushNamed('/account/$userId/profile');
  },
),

// Area aviator with section routing
() => Aviator(
  name: 'account',
  navigate: (args) {
    final bloc = BlocScope.get<AppBloc>();
    final section = args['section'] as String;
    final userId = args['userId'] as String;

    switch (section) {
      case 'profile':
        bloc.navigatorKey.currentState?.pushNamed('/account/$userId/profile');
        break;
      case 'settings':
        bloc.navigatorKey.currentState?.pushNamed('/account/$userId/settings');
        break;
      case 'orders':
        final orderId = args['orderId'] as String?;
        final path = orderId != null
          ? '/account/$userId/orders/$orderId'
          : '/account/$userId/orders';
        bloc.navigatorKey.currentState?.pushNamed(path);
        break;
    }
  },
)

Stateful Use Cases

Maintain state across multiple events:

class WebSocketUseCase extends StatefulUseCaseBuilder<ChatBloc, ConnectEvent> {
  WebSocket? _socket;

  @override
  Future<void> execute(ConnectEvent event) async {
    _socket = await WebSocket.connect('ws://...');
    // Handle connection
  }

  @override
  Future<void> close() async {
    await _socket?.close();
    super.close();
  }
}

Bloc Lifecycle Management

Juice provides three lifecycle options for blocs, giving you precise control over when blocs are created and disposed:

Permanent Blocs

App-level blocs that live for the entire application lifetime:

// Register at app startup
BlocScope.register<AuthBloc>(
  () => AuthBloc(),
  lifecycle: BlocLifecycle.permanent,
);

// Access anywhere in your app
final authBloc = BlocScope.get<AuthBloc>();

Feature Blocs

Blocs scoped to a feature or user flow that are disposed together:

class CheckoutFlow {
  final scope = FeatureScope('checkout');

  void start() {
    // Register blocs for this feature
    BlocScope.register<CartBloc>(
      () => CartBloc(),
      lifecycle: BlocLifecycle.feature,
      scope: scope,
    );
    BlocScope.register<PaymentBloc>(
      () => PaymentBloc(),
      lifecycle: BlocLifecycle.feature,
      scope: scope,
    );
  }

  Future<void> complete() async {
    // Dispose all blocs in this feature scope
    await BlocScope.endFeature(scope);
  }
}

Leased Blocs

Widget-level blocs with automatic reference-counted disposal:

// Register as leased
BlocScope.register<FormBloc>(
  () => FormBloc(),
  lifecycle: BlocLifecycle.leased,
);

// In your widget, acquire a lease
class MyFormWidget extends StatefulWidget {
  @override
  State<MyFormWidget> createState() => _MyFormWidgetState();
}

class _MyFormWidgetState extends State<MyFormWidget> {
  late final BlocLease<FormBloc> _lease;

  @override
  void initState() {
    super.initState();
    _lease = BlocScope.lease<FormBloc>();
  }

  @override
  void dispose() {
    _lease.dispose(); // Bloc closes when last lease is released
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text(_lease.bloc.state.value);
  }
}

Diagnostics

Monitor bloc lifecycle in development:

// Get diagnostic info for a bloc
final info = BlocScope.diagnostics<MyBloc>();
print('Active: ${info?.isActive}');
print('Lease count: ${info?.leaseCount}');
print('Created at: ${info?.createdAt}');

// Dump all registered blocs (debug only)
BlocScope.debugDump();

Cross-Bloc Communication

EventSubscription

Listen to events from one bloc and transform them for another:

class DestBloc extends JuiceBloc<DestState> {
  DestBloc() : super(
    DestState(),
    [
      () => UseCaseBuilder(
        typeOfEvent: DestEvent,
        useCaseGenerator: () => DestUseCase(),
      ),
      // Subscribe to events from SourceBloc
      () => EventSubscription<SourceBloc, SourceEvent, DestEvent>(
        toEvent: (sourceEvent) => DestEvent(
          message: 'Received: ${sourceEvent.data}',
        ),
        useCaseGenerator: () => DestUseCase(),
        when: (event) => event.shouldForward, // Optional filter
      ),
    ],
    [],
  );
}

StateRelay

React to state changes from one bloc and trigger events in another:

// Simple state relay
final relay = StateRelay<CartBloc, OrderBloc, CartState>(
  toEvent: (state) => UpdateTotalEvent(
    total: state.items.fold(0, (sum, item) => sum + item.price),
  ),
);

// With filtering - only relay when condition is met
final authRelay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
  toEvent: (state) => LoadProfileEvent(userId: state.userId!),
  when: (state) => state.isAuthenticated && state.userId != null,
);

// Clean up when done
await relay.close();

StatusRelay

For when you need access to the full StreamStatus (waiting, error states):

final relay = StatusRelay<AuthBloc, ProfileBloc, AuthState>(
  toEvent: (status) => status.when(
    updating: (state, _, __) => state.isAuthenticated
      ? LoadProfileEvent(userId: state.userId!)
      : ClearProfileEvent(),
    waiting: (_, __, ___) => ProfileLoadingEvent(),
    failure: (_, __, ___) => ClearProfileEvent(),
    canceling: (_, __, ___) => ClearProfileEvent(),
  ),
);

Best Practices

Bloc Lifecycle

  • Use BlocLifecycle.permanent for app-level blocs (auth, settings, theme)
  • Use BlocLifecycle.feature for multi-screen flows (checkout, onboarding)
  • Use BlocLifecycle.leased for widget-specific blocs (forms, modals)
  • Always call BlocScope.endFeature() when a feature completes
  • Release leases in widget dispose() methods

Use Cases

  • One use case per business operation
  • Keep use cases focused and single-purpose
  • Handle errors consistently using emitFailure
  • Follow event-handler pattern for clear input/output
  • Clean up resources in close() method

State Design

  • Make state classes immutable as first choice
  • Implement copyWith for state updates
  • Keep bloc states laser focused on feature needs only
  • Don't duplicate state across blocs

Widget Optimization

  • Use targeted group-based rebuilds
  • Define rebuild groups by UI update needs
  • Keep widgets focused on UI logic
  • Separate stateless and stateful juice widgets
  • Handle loading/error states consistently

Cross-Bloc Communication

  • Use EventSubscription for event-to-event forwarding between blocs
  • Use StateRelay for simple state-to-event transformation
  • Use StatusRelay when you need to handle waiting/error states
  • Always close relays when no longer needed
  • Use when predicates to filter unnecessary events
  • Keep aviators simple and single-purpose
  • Use consistent navigation patterns
  • Handle deep linking properly
  • Clean up navigation resources
  • Test navigation flows independently

Testing

  • Test use cases in isolation
  • Verify state transitions through StreamStatus
  • Test error handling and cancellation paths
  • Validate group-based rebuild logic
  • Test aviator navigation flows
  • Test bloc lifecycle (creation, disposal, lease counting)

Resource Management

  • Choose appropriate lifecycle for each bloc type
  • Use BlocScope.diagnostics() to monitor bloc state in development
  • Call BlocScope.endAll() on app shutdown
  • Release all leases before widget disposal
  • Handle cancellation appropriately
  • Use BlocScope.debugDump() to detect leaks during development

Project Status

Juice is currently at version 1.0.4 and is under active development. While the core features are stable and production-ready, work effort is focused next on:

  • Comprehensive documentation and guides
  • Additional examples and use cases
  • Developer tools and utilities
  • Extended testing utilities

Upcoming Companion Packages

I'm excited to plan for additional packages that will extend Juice's architecture. These companion packages are designed to address common application needs while keeping the core framework lightweight and focused:

Core App Services

  • juice_network: Streamlined HTTP client bloc with Dio integration
  • juice_auth: Simplified authentication and authorization workflows
  • juice_storage: Efficient local storage and caching solutions
  • juice_connectivity: Comprehensive network and Bluetooth management
  • juice_config: Flexible environment configuration and feature flagging

UI & Interaction

  • juice_form: Intuitive form handling and validation utilities
  • juice_theme: Robust theme management and dynamic styling options
  • juice_animation: Predefined reusable animation patterns for a polished

UI Features & Integration

  • juice_messaging: Real-time, general purpose messaging via WebSocket integration
  • juice_location: Advanced location services and geofencing capabilities
  • juice_analytics: Powerful tools for analytics and event tracking

By introducing these companion packages as separate modules, the aim is to keep Juice lean and maintainable while offering reliable, ready-to-use solutions for specific needs.

Documentation

Documentation is being actively developed.

Currently available:

  • This README with a quick start guide
  • Example projects in the examples directory

Coming soon:

  • API documentation
  • Full documentation site

Getting Help

  • Issues: For bugs and feature requests, please open an issue
  • Questions & Discussion: For questions, ideas, and general discussion, use GitHub Discussions. This helps create a searchable knowledge base for all users.
  • Examples: Check our example projects for common use cases

Contributing

Contributions are welcome! Please read our contributing guidelines before submitting pull requests.

Acknowledgments

We deeply appreciate everyone who supports the Juice framework!

  • Contributors: Developers who have helped improve Juice by submitting code, reporting bugs, or enhancing documentation.
  • Sponsors: Individuals and organizations providing financial support to drive Juice's ongoing development.
  • View the Contributors' Hall of Fame to see who has made an impact.
  • View the Sponsors' Hall of Fame to see our valued sponsors.
  • Learn more about becoming a sponsor in our Sponsorship Tiers.

License

Juice is available under the MIT license. See the LICENSE file for more information.

Author

Juice was created and is maintained by Kevin Ehmka. For inquiries, please feel free to reach out via email at kehmka@gmail.com.

Libraries

juice
Juice - A modular reactive state management library for Flutter.