Implementing BLoC/Cubit for Complex State in Flutter

Flutter, Google’s UI toolkit, empowers developers to build natively compiled applications for mobile, web, and desktop from a single codebase. Efficient state management is pivotal for crafting maintainable, scalable, and testable applications, particularly as complexity escalates. The BLoC (Business Logic Component) and Cubit patterns stand out as leading solutions for handling intricate state management in Flutter.

Understanding BLoC and Cubit Patterns

BLoC and Cubit are state management patterns recommended by Google and the Flutter community. They separate business logic from the presentation layer, improving code reusability, maintainability, and testability.

  • BLoC (Business Logic Component): Utilizes streams and sinks to manage the state, making it highly reactive and suitable for complex asynchronous logic.
  • Cubit: A simplified version of BLoC built on top of StreamBloc. It uses functions rather than streams, offering a more straightforward approach for simpler state management needs.

Why Use BLoC/Cubit for Complex State?

Choosing BLoC or Cubit is especially beneficial when dealing with:

  • Complex State Transitions: When the state of your application changes based on multiple factors and user inputs.
  • Asynchronous Operations: Handling data fetching, processing, and real-time updates.
  • Shared State: Managing state across multiple widgets and screens.

Implementing BLoC/Cubit in Flutter: A Practical Guide

To demonstrate how to implement BLoC/Cubit for complex state management, let’s consider a scenario where we manage a list of tasks with functionalities to add, delete, and mark tasks as completed.

Step 1: Setting Up the Project

Begin by creating a new Flutter project:

flutter create complex_state_app
cd complex_state_app

Step 2: Adding Dependencies

Add the necessary dependencies to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.3  # Use the latest version
  equatable: ^2.0.5     # For state comparison

dev_dependencies:
  flutter_test:
    sdk: flutter

Run flutter pub get to install the dependencies.

Step 3: Defining the Data Model

Create a task.dart file to define the task data model:

import 'package:equatable/equatable.dart';

class Task extends Equatable {
  final String id;
  final String title;
  final String description;
  final bool isCompleted;

  const Task({
    required this.id,
    required this.title,
    required this.description,
    this.isCompleted = false,
  });

  Task copyWith({
    String? id,
    String? title,
    String? description,
    bool? isCompleted,
  }) {
    return Task(
      id: id ?? this.id,
      title: title ?? this.title,
      description: description ?? this.description,
      isCompleted: isCompleted ?? this.isCompleted,
    );
  }

  @override
  List get props => [id, title, description, isCompleted];
}

Step 4: Implementing Cubit for Task Management

Create a task_cubit.dart file to manage the tasks using Cubit:

import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:uuid/uuid.dart';

import 'task.dart';

part 'task_state.dart';

class TaskCubit extends Cubit {
  TaskCubit() : super(const TaskState());

  void addTask(String title, String description) {
    final newTask = Task(
      id: const Uuid().v4(),
      title: title,
      description: description,
    );
    emit(TaskState(tasks: [...state.tasks, newTask]));
  }

  void updateTask(String taskId, {String? title, String? description, bool? isCompleted}) {
    final updatedTasks = state.tasks.map((task) {
      if (task.id == taskId) {
        return task.copyWith(
          title: title ?? task.title,
          description: description ?? task.description,
          isCompleted: isCompleted ?? task.isCompleted,
        );
      }
      return task;
    }).toList();
    emit(TaskState(tasks: updatedTasks));
  }

  void deleteTask(String taskId) {
    final updatedTasks = state.tasks.where((task) => task.id != taskId).toList();
    emit(TaskState(tasks: updatedTasks));
  }
}

And define the task_state.dart file:

part of 'task_cubit.dart';

class TaskState extends Equatable {
  final List tasks;

  const TaskState({this.tasks = const []});

  @override
  List get props => [tasks];
}

Step 5: Integrating Cubit with the UI

Update your main.dart file to use the TaskCubit in the UI:

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

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Complex State App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: BlocProvider(
        create: (context) => TaskCubit(),
        child: const TaskListScreen(),
      ),
    );
  }
}

class TaskListScreen extends StatelessWidget {
  const TaskListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task List'),
      ),
      body: BlocBuilder(
        builder: (context, state) {
          if (state.tasks.isEmpty) {
            return const Center(
              child: Text('No tasks yet!'),
            );
          }
          return ListView.builder(
            itemCount: state.tasks.length,
            itemBuilder: (context, index) {
              final task = state.tasks[index];
              return ListTile(
                title: Text(task.title),
                subtitle: Text(task.description),
                trailing: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    IconButton(
                      icon: const Icon(Icons.check),
                      onPressed: () {
                        context.read().updateTask(
                          task.id,
                          isCompleted: !task.isCompleted,
                        );
                      },
                    ),
                    IconButton(
                      icon: const Icon(Icons.delete),
                      onPressed: () {
                        context.read().deleteTask(task.id);
                      },
                    ),
                  ],
                ),
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _showAddTaskDialog(context);
        },
        child: const Icon(Icons.add),
      ),
    );
  }

  Future _showAddTaskDialog(BuildContext context) async {
    String title = '';
    String description = '';

    return showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: const Text('Add New Task'),
          content: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              TextFormField(
                decoration: const InputDecoration(labelText: 'Title'),
                onChanged: (value) => title = value,
              ),
              TextFormField(
                decoration: const InputDecoration(labelText: 'Description'),
                onChanged: (value) => description = value,
              ),
            ],
          ),
          actions: [
            TextButton(
              child: const Text('Cancel'),
              onPressed: () {
                Navigator.of(context).pop();
              },
            ),
            TextButton(
              child: const Text('Add'),
              onPressed: () {
                context.read().addTask(title, description);
                Navigator.of(context).pop();
              },
            ),
          ],
        );
      },
    );
  }
}

Step 6: Testing the Implementation

Create a test file task_cubit_test.dart to test the functionalities of the TaskCubit:

import 'package:bloc_test/bloc_test.dart';
import 'package:complex_state_app/task.dart';
import 'package:complex_state_app/task_cubit.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  group('TaskCubit', () {
    late TaskCubit taskCubit;

    setUp(() {
      taskCubit = TaskCubit();
    });

    tearDown(() {
      taskCubit.close();
    });

    test('initial state is TaskState with empty tasks', () {
      expect(taskCubit.state, const TaskState(tasks: []));
    });

    blocTest(
      'emits TaskState with a new task when addTask is called',
      build: () => taskCubit,
      act: () => taskCubit.addTask('Test Task', 'Test Description'),
      expect: () => [
        TaskState(
          tasks: [
            const Task(
              id: '1',  // You might want to mock Uuid for testing
              title: 'Test Task',
              description: 'Test Description',
            ),
          ],
        ),
      ],
    );

    blocTest(
      'emits TaskState with updated tasks when updateTask is called',
      build: () => taskCubit,
      seed: () => TaskState(tasks: [
        const Task(
          id: '1',
          title: 'Test Task',
          description: 'Test Description',
          isCompleted: false,
        ),
      ]),
      act: () => taskCubit.updateTask('1', isCompleted: true),
      expect: () => [
        TaskState(
          tasks: [
            const Task(
              id: '1',
              title: 'Test Task',
              description: 'Test Description',
              isCompleted: true,
            ),
          ],
        ),
      ],
    );

    blocTest(
      'emits TaskState with tasks excluding the deleted task when deleteTask is called',
      build: () => taskCubit,
      seed: () => TaskState(tasks: [
        const Task(
          id: '1',
          title: 'Test Task',
          description: 'Test Description',
        ),
      ]),
      act: () => taskCubit.deleteTask('1'),
      expect: () => [const TaskState(tasks: [])],
    );
  });
}

Benefits of Using BLoC/Cubit

  • Separation of Concerns: Isolates the business logic from the UI, enhancing maintainability.
  • Testability: Simplifies unit testing by focusing on the business logic in isolation.
  • Reusability: Enables the reuse of business logic across different parts of the application.
  • State Management: Offers a structured approach to managing application state.

Conclusion

Implementing BLoC/Cubit for complex state in Flutter offers a robust and scalable architecture for your applications. By following these steps and adopting BLoC/Cubit patterns, you can effectively manage intricate state transitions, asynchronous operations, and shared states, leading to more maintainable and testable codebases.