Persisting State in Flutter Using Hydrated BLoC with Freezed
In this tutorial, we’ll build a Flutter app that uses Hydrated BLoC with the Freezed package to persist the state of a counter and theme brightness settings.
Setting Up Your Project
Add the following dependencies to your pubspec.yaml
:
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.0.0
hydrated_bloc: ^9.0.0
path_provider: ^2.0.0
freezed_annotation: ^3.1.0
dev_dependencies:
build_runner: ^2.4.0
freezed: ^3.1.0
Run flutter pub get
to install these dependencies.
Generating Freezed Classes
The Freezed
package will help define immutable states and events for our BrightnessBloc
and CounterBloc
.
1. Define Events and States for BrightnessBloc
Create a file brightness_bloc.dart
:
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
part 'brightness_bloc.freezed.dart';
part 'brightness_bloc.g.dart';
@freezed
class BrightnessEvent with _$BrightnessEvent {
const factory BrightnessEvent.toggle() = BrightnessToggle;
}
@freezed
class BrightnessState with _$BrightnessState {
const factory BrightnessState({required bool isDarkMode}) = _BrightnessState;
factory BrightnessState.fromJson(Map<String, dynamic> json) =>
_$BrightnessStateFromJson(json);
}
class BrightnessBloc extends HydratedBloc<BrightnessEvent, BrightnessState> {
BrightnessBloc() : super(const BrightnessState(isDarkMode: false)) {
on<BrightnessToggle>((event, emit) {
emit(state.copyWith(isDarkMode: !state.isDarkMode));
});
}
@override
BrightnessState? fromJson(Map<String, dynamic> json) =>
BrightnessState.fromJson(json);
@override
Map<String, dynamic>? toJson(BrightnessState state) => state.toJson();
}
2. Define Events and States for CounterBloc
Create a file counter_bloc.dart
:
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
part 'counter_bloc.freezed.dart';
part 'counter_bloc.g.dart';
@freezed
class CounterEvent with _$CounterEvent {
const factory CounterEvent.increment() = CounterIncrement;
const factory CounterEvent.decrement() = CounterDecrement;
}
@freezed
class CounterState with _$CounterState {
const factory CounterState({required int value}) = _CounterState;
factory CounterState.fromJson(Map<String, dynamic> json) =>
_$CounterStateFromJson(json);
}
class CounterBloc extends HydratedBloc<CounterEvent, CounterState> {
CounterBloc() : super(const CounterState(value: 0)) {
on<CounterIncrement>((event, emit) {
emit(state.copyWith(value: state.value + 1));
});
on<CounterDecrement>((event, emit) {
emit(state.copyWith(value: state.value - 1));
});
}
@override
CounterState? fromJson(Map<String, dynamic> json) =>
CounterState.fromJson(json);
@override
Map<String, dynamic>? toJson(CounterState state) => state.toJson();
}
Run flutter pub run build_runner build
to generate the *.freezed.dart
and *.g.dart
files.
3. App Initialization
Update your main.dart
:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (_) => BrightnessBloc()),
BlocProvider(create: (_) => CounterBloc()),
],
child: const AppView(),
);
}
}
4. AppView
The AppView
adapts its theme based on the BrightnessBloc
state:
class AppView extends StatelessWidget {
const AppView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<BrightnessBloc, BrightnessState>(
builder: (context, state) {
return MaterialApp(
theme: ThemeData(
brightness: state.isDarkMode ? Brightness.dark : Brightness.light,
),
home: const CounterPage(),
);
},
);
}
}
5. CounterView
The CounterView
is updated to use the new CounterBloc
:
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return const CounterView();
}
}
class CounterView extends StatelessWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
return Text('${state.value}', style: textTheme.displayMedium);
},
),
),
floatingActionButton: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(
onPressed: () =>
context.read<BrightnessBloc>().add(const BrightnessToggle()),
child: const Icon(Icons.brightness_6),
),
const SizedBox(height: 4),
FloatingActionButton(
onPressed: () =>
context.read<CounterBloc>().add(const CounterIncrement()),
child: const Icon(Icons.add),
),
const SizedBox(height: 4),
FloatingActionButton(
onPressed: () =>
context.read<CounterBloc>().add(const CounterDecrement()),
child: const Icon(Icons.remove),
),
const SizedBox(height: 4),
FloatingActionButton(
onPressed: () => HydratedBloc.storage.clear(),
child: const Icon(Icons.delete_forever),
),
],
),
);
}
}
Cubit-Full Code
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(const App());
}
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => BrightnessCubit(),
child: const AppView(),
);
}
}
class AppView extends StatelessWidget {
const AppView({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<BrightnessCubit, Brightness>(
builder: (context, brightness) {
return MaterialApp(
theme: ThemeData(brightness: brightness),
home: const CounterPage(),
);
},
);
}
}
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<CounterBloc>(
create: (_) => CounterBloc(),
child: const CounterView(),
);
}
}
class CounterView extends StatelessWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: BlocBuilder<CounterBloc, int>(
builder: (context, state) {
return Text('$state', style: textTheme.displayMedium);
},
),
),
floatingActionButton: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
FloatingActionButton(
child: const Icon(Icons.brightness_6),
onPressed: () => context.read<BrightnessCubit>().toggleBrightness(),
),
const SizedBox(height: 4),
FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
context.read<CounterBloc>().add(CounterIncrementPressed());
},
),
const SizedBox(height: 4),
FloatingActionButton(
child: const Icon(Icons.remove),
onPressed: () {
context.read<CounterBloc>().add(CounterDecrementPressed());
},
),
const SizedBox(height: 4),
FloatingActionButton(
child: const Icon(Icons.delete_forever),
onPressed: () => HydratedBloc.storage.clear(),
),
],
),
);
}
}
sealed class CounterEvent {}
final class CounterIncrementPressed extends CounterEvent {}
final class CounterDecrementPressed extends CounterEvent {}
class CounterBloc extends HydratedBloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncrementPressed>((event, emit) => emit(state + 1));
on<CounterDecrementPressed>((event, emit) => emit(state - 1));
}
@override
int fromJson(Map<String, dynamic> json) => json['value'] as int;
@override
Map<String, int> toJson(int state) => {'value': state};
}
class BrightnessCubit extends HydratedCubit<Brightness> {
BrightnessCubit() : super(Brightness.light);
void toggleBrightness() {
emit(state == Brightness.light ? Brightness.dark : Brightness.light);
}
@override
Brightness fromJson(Map<String, dynamic> json) {
return Brightness.values[json['brightness'] as int];
}
@override
Map<String, dynamic> toJson(Brightness state) {
return <String, int>{'brightness': state.index};
}
}
Final Thoughts
With Freezed
, defining states and events becomes concise, and using Hydrated BLoC
ensures persistence with minimal boilerplate. Implement these techniques in your projects to enhance user experience.