State Management in Flutter: Using Cubit + Freezed with BLoC Pattern

State management is the backbone of any Flutter application, ensuring smooth interactions and responsive interfaces. This guide will dive deep into Cubit + Freezed with Bloc—a powerhouse combination designed to streamline state management and enhance your Flutter projects.

Why Cubit + Freezed with BLoC?

Streamlined State Management

Managing state in Flutter can be complex, especially as your app grows. Cubit + Freezed with Bloc provides a structured approach that separates business logic from UI, making your codebase cleaner and more maintainable. With Cubit, you define discrete logic units for handling state changes, while Freezed helps you generate immutable state classes effortlessly. Bloc ties it all together, ensuring your UI reacts to state changes efficiently.

Enhanced Code Structure

Adopting Cubit + Freezed with Bloc creates a clear architectural pattern that promotes scalability and code reusability. This setup encourages a single source of truth for your app's state, reducing bugs and improving code predictability. Whether you're building a simple counter app or a complex eCommerce platform, this approach scales with your project's needs.

What You'll Gain

Efficient State Updates

With Cubit, state updates are handled efficiently using the emit method, ensuring only necessary parts of your UI are updated when state changes occur. This optimizes performance and provides a seamless user experience.

Code Clarity and Predictability

Freezed generates immutable state classes based on your models, reducing boilerplate code and eliminating runtime errors related to mutable states. This guarantees that your app remains stable and predictable throughout its lifecycle.

Implementing Cubit + Freezed with Bloc

Step-by-Step Setup

Let's set up your project to harness the power of Cubit + Freezed with Bloc.

1. Project Structure

Organize your project with a clear separation of concerns. Here's the recommended structure:

lib/
|-- di/
|   |-- injection.dart
|
|-- home/
|   |-- home_screen.dart
|   |-- cubit/
|   |   |-- home_cubit.dart
|   |   |-- home_state.dart
|
|-- main.dart
2. Dependency Injection

Configure dependencies using get_it and injectable in injection.dart:

import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';

import 'injection.config.dart';

final getIt = GetIt.instance;

@InjectableInit(
  initializerName: 'init',
  preferRelativeImports: true,
  asExtension: true,
)
void configureDependencies() => getIt.init();
3. State Management with Freezed

Define your app's state using Freezed annotations in home_state.dart:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'home_state.freezed.dart';

@freezed
class HomeState with _$HomeState {
  const factory HomeState.initial({
    @Default(0) int counter,
  }) = _Initial;
}
4. Creating Cubit

Implement your Cubit for state management in home_cubit.dart:

import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';

part 'home_state.dart';
part 'home_cubit.freezed.dart';

@injectable
class HomeCubit extends Cubit<HomeState> {
  HomeCubit() : super(const HomeState.initial());

  void incrementCounter() {
    emit(state.copyWith(counter: state.counter + 1));
  }
}
5. Integrating with UI

Integrate your Cubit with BlocProvider and BlocBuilder in home_screen.dart:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

import '../di/injection.dart';
import 'cubit/home_cubit.dart';

class HomeScreen extends StatelessWidget {
  final HomeCubit cubit = getIt<HomeCubit>();

  HomeScreen({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => cubit,
      child: Scaffold(
        appBar: AppBar(
          title: Text('Flutter Demo Home Page'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text(
                'You have pushed the button this many times:',
              ),
              BlocBuilder<HomeCubit, HomeState>(
                builder: (context, state) {
                  return Text(
                    '${state.counter}',
                    style: Theme.of(context).textTheme.headline4,
                  );
                },
              ),
            ],
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            cubit.incrementCounter();
          },
          tooltip: 'Increment',
          child: Icon(Icons.add),
        ),
      ),
    );
  }
}
6. Using Build Runner

To ensure optimal performance and maintain a streamlined development process, we utilize Build Runner—a code generation tool in Dart and Flutter. Build Runner automates the generation of boilerplate code, reducing manual effort and enhancing productivity.

Generating Code

Before diving into the details, it's crucial to generate the necessary code using Build Runner. Open your terminal or command prompt, navigate to your project directory, and execute the following command:

dart run build_runner build

This command triggers Build Runner to generate code based on annotations and configurations in your project. It's a one-time execution unless your project's structure or dependencies change significantly.

Continuous Code Generation with Watch

During development, you'll often make incremental changes to your project. To keep your generated code up-to-date automatically, utilize the watch mode of Build Runner. Simply run:

dart run build_runner watch

With this command, Build Runner monitors your project files for changes. Upon detecting modifications, it automatically regenerates the necessary code, ensuring your app remains in sync with the latest logic and configurations.

Note: Until you run dart run build_runner build, you may encounter errors related to the code that is expected to be generated. This is normal and resolves once the code generation is complete.

Conclusion

By integrating Build Runner into your Flutter development workflow, you streamline code generation and maintain optimal performance. Embrace the efficiency of Build Runner—whether generating code once with build or continuously updating with watch—and elevate your Flutter projects to new heights of efficiency and scalability.

GitHub Repository

For a complete implementation example, check out the GitHub repository here.

1
Subscribe to my newsletter

Read articles from Shankar Kakumani directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shankar Kakumani
Shankar Kakumani