bloc_small

pub package MIT License

A lightweight, streamlined implementation of the BLoC pattern for Flutter state management. Built on flutter_bloc, bloc_small simplifies dependency injection, error handling, and async operations while maintaining full BLoC benefits.

Features

  • Simplified BLoC & Cubit — Easy-to-use state management via MainBloc and MainCubit
  • Dependency Injection — Integrated GetIt setup with automatic CommonBloc registration
  • Error & Loading State Management — Built-in blocCatch/cubitCatch and loading overlay helpers
  • Freezed Integration — Full support for immutable states and events
  • ReactiveSubject API — RxDart-powered stream transformations (map, switchMap, debounceTime, etc.)
  • auto_route Integration — Optional type-safe navigation with deep linking
  • Stateless & Stateful WidgetsBaseBlocPage/BaseCubitPage for both patterns

Installation & Requirements

Requirements:

  • Flutter >=3.38.0
  • Dart >=3.9.2

Add to pubspec.yaml:

dependencies:
  bloc_small:

dev_dependencies:
  build_runner:
  auto_route_generator:
  freezed:
  injectable_generator:

Then run:

flutter pub run build_runner build --delete-conflicting-outputs

Run this command whenever you modify Freezed or Injectable annotations.

Core Concepts

Class Purpose
MainBloc Foundation for event-driven state management
MainCubit Simplified state management without events
MainBlocEvent Base class for all BLoC events
MainBlocState Base class for all states
CommonBloc App-wide loading and common state

BLoC Example:

@lazySingleton
class CountBloc extends MainBloc<CountEvent, CountState> {
  CountBloc() : super(const CountState.initial()) {
    on<Increment>(_onIncrement);
  }

  Future<void> _onIncrement(Increment event, Emitter<CountState> emit) async {
    await blocCatch(actions: () async {
      await Future.delayed(Duration(seconds: 1));
      emit(state.copyWith(count: state.count + 1));
    });
  }
}

Cubit Example:

@lazySingleton
class CountCubit extends MainCubit<CountState> {
  CountCubit() : super(const CountState.initial());

  Future<void> increment() async {
    await cubitCatch(actions: () async {
      emit(state.copyWith(count: state.count + 1));
    });
  }
}

Basic Usage

1. Set Up Dependency Injection

@InjectableInit()
void configureInjectionApp() {
  getIt.registerCore();  // Registers CommonBloc — required
  getIt.init();          // Registers your app dependencies
}

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  configureInjectionApp();
  runApp(MyApp());
}

Important: getIt.registerCore() is required and registers CommonBloc as a lazy singleton. Omitting it will throw on first page load.

2. Define Events & States with Freezed

abstract class CountEvent extends MainBlocEvent {
  const CountEvent._();
}

@freezed
sealed class Increment extends CountEvent with _$Increment {
  const Increment._() : super._();
  const factory Increment() = _Increment;
}

@freezed
sealed class CountState extends MainBlocState with _$CountState {
  const CountState._();
  const factory CountState.initial({@Default(0) int count}) = _Initial;
}

3. Create a Page

class CounterPage extends StatefulWidget {
  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends BaseBlocPageState<CounterPage, CountBloc> {
  @override
  Widget buildPage(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter')),
      body: BlocBuilder<CountBloc, CountState>(
        builder: (context, state) => Text('${state.count}'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => bloc.add(Increment()),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Using Cubit

Cubit simplifies state management by using direct method calls instead of events:

@lazySingleton
class CountCubit extends MainCubit<CountState> {
  CountCubit() : super(const CountState.initial());

  void increment() => emit(state.copyWith(count: state.count + 1));
}

Use BaseCubitPageState and BaseCubitPage for page widgets. Call methods directly:

floatingActionButton: FloatingActionButton(
  onPressed: () => cubit.increment(),
  child: const Icon(Icons.add),
)
Aspect BLoC Cubit
Events
Complexity Higher Lower
Use Case Complex logic Simple updates

Bloc Ownership Rules

The DI container owns every bloc and cubit. Widgets consume, never close them.

  1. Register page state managers as singleton/lazy singletonregisterFactory is not supported and will throw in debug builds. Factories create new instances per resolution, and pages never dispose what they didn't create.

    getIt.registerLazySingleton<CountBloc>(CountBloc.new);  // ✓
    // getIt.registerFactory<CountBloc>(CountBloc.new);     // ✗
    
  2. CommonBloc is app-wide — Registered via registerCore(). Call resetCore() in tests/hot-restart.

  3. Do not annotate your router for codegenregisterAppRouter registers the instance you provide. Annotating it with @LazySingleton would register it twice, causing getIt.init() to throw.

    @AutoRouterConfig()
    class AppRouter extends BaseAppRouter { }  // No @LazySingleton
    

Using StatelessWidget

BaseBlocPage and BaseCubitPage receive the state manager in buildPage:

class CounterPage extends BaseBlocPage<CountBloc> {
  const CounterPage({super.key});

  @override
  Widget buildPage(BuildContext context, CountBloc bloc) {
    return Scaffold(
      body: BlocBuilder<CountBloc, CountState>(
        builder: (context, state) => Text('${state.count}'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => bloc.add(Increment()),
        child: const Icon(Icons.add),
      ),
    );
  }
}

auto_route Integration

  1. Create Router:
@AutoRouterConfig()
class AppRouter extends BaseAppRouter {
  @override
  List<AutoRoute> get routes => [
    AutoRoute(page: HomeRoute.page, initial: true),
    AutoRoute(page: SettingsRoute.page),
  ];
}
  1. Register & Configure:
void configureInjectionApp() {
  getIt.registerAppRouter<AppRouter>(AppRouter(), enableNavigationLogs: true);
  getIt.registerCore();
  getIt.init();
}

class MyApp extends StatelessWidget {
  final _router = getIt<AppRouter>();

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: _router.config(),
    );
  }
}
  1. Navigate:
final navigator = getIt.getNavigator();
navigator?.push(const HomeRoute());

Loading Overlay

Wrap your page content with buildLoadingOverlay to display a loading indicator:

Widget buildPage(BuildContext context) {
  return buildLoadingOverlay(
    child: Scaffold(...),
    loadingKey: 'myKey',  // Optional: for multiple loading states
  );
}

Control it from your bloc/cubit:

await blocCatch(actions: () async {
  showLoading();
  try {
    // Your async operation
  } finally {
    hideLoading();
  }
});

Error Handling

Use blocCatch or cubitCatch to wrap async operations and handle errors automatically:

await blocCatch(
  actions: () async {
    // Your code here
  },
  onError: (error) {
    print('Error: $error');
  },
);

For standardized error handling, use BaseErrorHandlerMixin:

@lazySingleton
class CountBloc extends MainBloc<CountEvent, CountState> with BaseErrorHandlerMixin {
  // ...
  
  Future<void> _onIncrement(Increment event, Emitter<CountState> emit) async {
    await blocCatch(
      actions: () async { /* ... */ },
      onError: handleError,  // Uses mixin's error handler
    );
  }
}

The mixin provides automatic logging, error message generation, and loading state cleanup.

ReactiveSubject

ReactiveSubject<T> wraps RxDart's BehaviorSubject/PublishSubject with a simplified API.

API Reference

Constructors & Core:

Method Description
ReactiveSubject({T? initialValue}) Creates with BehaviorSubject
ReactiveSubject.broadcast() Creates with PublishSubject
add(T value) Add new value
dispose() Release resources

Transformation:

Method Example
map<R>() subject.map((i) => i * 2)
where() subject.where((i) => i > 0)
switchMap() subject.switchMap((i) => api.fetch(i))
debounceTime() subject.debounceTime(300.ms)
distinct() subject.distinct()

Example:

final subject = ReactiveSubject<int>(initialValue: 0);
final stream = subject
    .map((i) => i * 2)
    .debounceTime(Duration(milliseconds: 300))
    .listen(print);
await subject.dispose();

Contributing

Contributions welcome! Please open an issue or pull request on GitHub.

License

MIT License. See LICENSE file for details.