Using Cubit for Managing States in Flutter
State management in Flutter simplified
There are a plethora of state management solutions in Flutter. Provider, Bloc, RxDart, Riverpod, GetX, the list goes on! However, my preference is Cubit or, as I like to call it, mini Bloc.
So, what is a Cubit?
A Cubit is a class that exposes functions that can be invoked to call state changes.
What does this mean?
If you're coming from C++ and its Qt framework, the way Cubits work is highly similar to Qt's signal and slot mechanism. If you're coming from a different background, don't worry! It's straightforward. Any time you wish to change state, you simply call an emit()
function.
A simple application
To showcase the Cubit state management solution, we're going to recreate Flutter's counter app. Create a new Flutter project and add the following packages to your pubspec.yaml
file.
flutter_bloc: ^7.0.1
bloc_test: ^8.0.2
equatable: ^2.0.3
Our sample app would look like this.
So let's get started! The first thing we should do first is to create the cubit and state. In the lib
folder, create a sub-folder called cubits
; this is where we will store our cubits and states. From there, make a file called operator_state.dart
; this will store the current state of our application. Now add the following line to your operator_state.dart
file.
part of 'operator_cubit.dart';
class OperatorState extends Equatable {
double number;
OperatorState({required this.number});
@override
List<Object> get props => [this.number];
}
OperatorState
extends Equatable
in order to avoid the issue of states not changing in Flutter whenever you use Cubits or Blocs. The problem is avoided by adding the following line of code to the OperatorState
class.
@override
List<Object> get props => [this.number];
The number
variable, on the other hand, is what we will keep track of. At the top of the file, there is a part of 'operator_cubit.dart'
, which is the file that will handle state changes; let's create that now!
Let's instantiate our operator_cubit.dart
file.
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
part 'operator_state.dart';
class OperatorCubit extends Cubit<OperatorState> {
OperatorCubit() : super(OperatorState(number: 0.0));
}
Knowing that OperatorCubit
will handle the state changes of OperatorState
, it is only natural that it extends it as well.
Operations
Based on the picture earlier, our sample app will make use of the 4 basic operations, addition, subtraction, multiplication, and division.
To handle these operations, we will simply just create methods that simulates these operations in the OperatorCubit
class.
void add() {
emit(
OperatorState(number: state.number + 1),
);
}
void substract() {
emit(
OperatorState(number: state.number - 1),
);
}
void multiply() {
emit(
OperatorState(number: state.number * 2 ),
);
}
void divide() {
emit(
OperatorState(number: state.number / 2),
);
}
Here we finally get to see the emit()
function I was talking about earlier. emit()
simply calls a state change in the OperatorState
class. For the add()
and subtract()
methods, we simply add or subtract 1
to the current state number. The multiply()
and divide()
methods, on the other hand, will multiply or divide the current state number by 2
.
The operator_cubit.dart
file now looks like this.
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
part 'operator_state.dart';
class OperatorCubit extends Cubit<OperatorState> {
OperatorCubit() : super(OperatorState(number: 1.0));
void add() {
emit(
OperatorState(number: state.number + 1),
);
}
void substract() {
emit(
OperatorState(number: state.number - 1),
);
}
void multiply() {
emit(
OperatorState(number: state.number * 2 ),
);
}
void divide() {
emit(
OperatorState(number: state.number / 2),
);
}
}
Writing Tests
Now that we have the basics of cubits down, we can start discussing how do we test these cubits. Testing Cubits is a simple process. In the test
folder, create a sub-folder called cubit_test
, there create a file called operator_cubit_test.dart
. In the operator_cubit_test.dart
file, we import the following.
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:cubit_testing_basics/cubits/operator_cubit.dart';
We then create a main()
function which will contain all of our tests for the OperatorCubit
class. Since our test file will contain tests only for the OperatorCubit
class, it's best to group
the tests together.
void main() {
group(OperatorCubit, () {
});
}
From there we can now start setting up our tests.
late OperatorCubit operatorCubit;
//initialize the testing setup
setUp(() {
operatorCubit = OperatorCubit();
});
//dictates what will happen after the test finishes
tearDown(() {
operatorCubit.close();
});
We assign the late
keyword to the operatorCubit
variable because we only instantiate it during the setup of our application. This is simulated by the following code.
setUp(() {
operatorCubit = OperatorCubit();
});
tearDown()
on the other hand disposes off the cubit after the tests finishes.
We then test the initial state of our operatorCubit
using the test()
function. We first add in a description for our test.
test("Initial state of OperatorCubit is OperatorState(number: 1.0)", () {
expect(operatorCubit.state, OperatorState(number: 1.0));
});
expect()
takes in two parameters, actual
, and matcher
. actual
corresponds to the actual state being emitted, while matcher
is what we're expecting to be emitted. For our case, our actual
is the operatorCubit.state
, while our matcher
is the initial state of our application which is OperatorState(number: 1.0))
.
For testing our state changes, we make use of the blocTest()
function. A blocTest()
function will usually make use of 4 parameters.
description: ' ', //not a named parameter
build: () => nameOfCubit,
act: (cubit) => nameOfCubit.method(),
expect: () => [nameOfCubitState(expectedValue)],
With that, lets test our 4 operations!
//test the cubits
blocTest(
"The cubit should emit a state of OperatorState(number: 2.0) when add() is called",
build: () => operatorCubit,
act: (cubit) => operatorCubit.add(),
expect: () => [OperatorState(number: 2.0)],
);
blocTest(
"The cubit should emit a state of OperatorState(number: 0.0) when substract() is called",
build: () => operatorCubit,
act: (cubit) => operatorCubit.substract(),
expect: () => [OperatorState(number: 0.0)],
);
blocTest(
"The cubit should emit a state of OperatorState(number: 2.0) when multiply() is called",
build: () => operatorCubit,
act: (cubit) => operatorCubit.multiply(),
expect: () => [OperatorState(number: 2.0)],
);
blocTest(
"The cubit should emit a state of OperatorState(number: 0.5) when divide() is called",
build: () => operatorCubit,
act: (cubit) => operatorCubit.divide(),
expect: () => [OperatorState(number: 0.5)],
);
Our operator_cubit_test.dart
file is now complete!
import 'package:flutter_test/flutter_test.dart';
import 'package:bloc_test/bloc_test.dart';
import 'package:cubit_testing_basics/cubits/operator_cubit.dart';
void main() {
group(OperatorCubit, () {
late OperatorCubit operatorCubit;
//initialize the testing setup
setUp(() {
operatorCubit = OperatorCubit();
});
//dictates what will happen after the test finishes
tearDown(() {
operatorCubit.close();
});
//test the initial state, number: 1
test("Initial state of OperatorCubit is OperatorState(number: 1.0)", () {
expect(operatorCubit.state, OperatorState(number: 1.0));
});
//test the cubits
blocTest(
"The cubit should emit a state of OperatorState(number: 2.0) when add() is called",
build: () => operatorCubit,
act: (cubit) => operatorCubit.add(),
expect: () => [OperatorState(number: 2.0)],
);
blocTest(
"The cubit should emit a state of OperatorState(number: 0.0) when substract() is called",
build: () => operatorCubit,
act: (cubit) => operatorCubit.substract(),
expect: () => [OperatorState(number: 0.0)],
);
blocTest(
"The cubit should emit a state of OperatorState(number: 2.0) when multiply() is called",
build: () => operatorCubit,
act: (cubit) => operatorCubit.multiply(),
expect: () => [OperatorState(number: 2.0)],
);
blocTest(
"The cubit should emit a state of OperatorState(number: 0.5) when divide() is called",
build: () => operatorCubit,
act: (cubit) => operatorCubit.divide(),
expect: () => [OperatorState(number: 0.5)],
);
});
}
Connecting Cubits to the UI
In our main.dart
file, import the following.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:cubit_testing_basics/cubits/operator_cubit.dart';
In our main()
function, we have a MyApp
class which extends StatelessWidget
.
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Cubit, and Testing Basics'),
);
}
}
MyHomePage
extends StatefulWidget
.
class MyHomePage extends StatefulWidget {
MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'The Number is Currently:',
style: TextStyle(
fontSize: 20,
),
),
SizedBox(
height: 30,
),
Text(
'1.0',
style: Theme.of(context).textTheme.headline3,
),
SizedBox(
height: 30,
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton(
onPressed: null,
child: Text('+'),
),
FloatingActionButton(
onPressed: null
child: Text('-'),
),
],
),
SizedBox(
height: 30,
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton(
onPressed: null,
child: Text('*'),
),
FloatingActionButton(
onPressed: null,
child: Text('/'),
),
],
),
],
),
),
);
}
}
Lets add in our Cubit!
First we wrap our MaterialApp
with the BlocProvider
widget. BlocProvider
will take in the parameter create
, there we will pass an instance of OperatorCubit()
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => OperatorCubit(),
child: MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Cubit, and Testing Basics'),
),
);
}
}
In our _MyHomePageState
class, we replace the following lines of code...
Text(
'1.0',
style: Theme.of(context).textTheme.headline3,
),
With...
BlocBuilder<OperatorCubit, OperatorState>(
builder: (context, state) {
return Text(
state.number.toString(),
style: Theme.of(context).textTheme.headline3,
);
},
),
BlocBuilder
rebuilds the widget tree with the corresponding state change, in our case, with the changes in the number
variable.
The last thing we need to add is the onPressed
functionality of our FloatingActionButton
widgets.
Here, we passed on to the onPressed
parameter the methods in our OperatorCubit
by accessing it through BlocProvider
. For example, in your FloatingActionButton
with the +
operator...
onPressed: () => BlocProvider.of<OperatorCubit>(context).add()
We now do the same thing to the other functions, but this time, using the other methods.
Our _MyHomePageState
class will now look like this.
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'The Number is Currently:',
style: TextStyle(
fontSize: 20,
),
),
SizedBox(
height: 30,
),
BlocBuilder<OperatorCubit, OperatorState>(
builder: (context, state) {
return Text(
state.number.toString(),
style: Theme.of(context).textTheme.headline3,
);
},
),
SizedBox(
height: 30,
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton(
onPressed: () => BlocProvider.of<OperatorCubit>(context).add(),
child: Text('+'),
),
FloatingActionButton(
onPressed: () => BlocProvider.of<OperatorCubit>(context).substract(),
child: Text('-'),
),
],
),
SizedBox(
height: 30,
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton(
onPressed: () => BlocProvider.of<OperatorCubit>(context).multiply(),
child: Text('*'),
),
FloatingActionButton(
onPressed: () => BlocProvider.of<OperatorCubit>(context).divide(),
child: Text('/'),
),
],
),
],
),
),
);
}
}
Our main.dart
file is now complete!
import 'package:cubit_testing_basics/cubits/operator_cubit.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => OperatorCubit(),
child: MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Cubit, and Testing Basics'),
),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'The Number is Currently:',
style: TextStyle(
fontSize: 20,
),
),
SizedBox(
height: 30,
),
BlocBuilder<OperatorCubit, OperatorState>(
builder: (context, state) {
return Text(
state.number.toString(),
style: Theme.of(context).textTheme.headline3,
);
},
),
SizedBox(
height: 30,
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton(
onPressed: () => BlocProvider.of<OperatorCubit>(context).add(),
child: Text('+'),
),
FloatingActionButton(
onPressed: () => BlocProvider.of<OperatorCubit>(context).substract(),
child: Text('-'),
),
],
),
SizedBox(
height: 30,
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
FloatingActionButton(
onPressed: () => BlocProvider.of<OperatorCubit>(context).multiply(),
child: Text('*'),
),
FloatingActionButton(
onPressed: () => BlocProvider.of<OperatorCubit>(context).divide(),
child: Text('/'),
),
],
),
],
),
),
);
}
}
The finished code can be found here.
Wrapping Up
You have now learned the basics of the Cubit state management solution, and how to test a Cubit in Flutter! If you want to learn more, simply go to the documentation .
If you've found this useful, do consider following me on Twitter, and on LinkedIn.