HydratedBloc을 활용한 상태 저장
hydrated_bloc을 사용해 앱을 다시 시작해도 BLoC 상태를 자동으로 직렬화하고 복원합니다.
HydratedBloc을 활용한 상태 저장은(는) CoddyKit의 무료 Flutter Mobile Development 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Flutter Mobile Development 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Flutter Mobile Development 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
Why Persist BLoC State?
By default, a Bloc or Cubit loses everything when the app process is killed. Reopening the app re-runs the initial state, so the user's last selections, cart contents, or theme are gone.
hydrated_bloc solves this without you writing manual save/load code. It transparently:
- Serializes each new state to local storage as it is emitted
- Restores the last state automatically when the BLoC is recreated
This is ideal for UI preferences, onboarding flags, and small session data that should survive an app restart.
Setup and Storage Initialization
Add the packages to pubspec.yaml:
hydrated_blocprovidesHydratedBlocandHydratedCubitpath_providersupplies a writable directory on the device
Before your app runs, you must build a storage backend and assign it to HydratedBloc.storage. Because this touches platform channels, wrap it with WidgetsFlutterBinding.ensureInitialized().
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: kIsWeb
? HydratedStorageDirectory.web
: HydratedStorageDirectory(
(await getApplicationDocumentsDirectory()).path,
),
);
runApp(const MyApp());
}From Cubit to HydratedCubit
The simplest way to persist state is to extend HydratedCubit instead of Cubit. You must override two methods:
toJson(state)converts the state into a JSON-encodableMap(or returnsnullto skip persistence)fromJson(json)rebuilds the state from that map (or returnsnullto fall back to the constructor's initial state)
Here a counter survives restarts with just a few lines.
class CounterCubit extends HydratedCubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
@override
int? fromJson(Map<String, dynamic> json) => json['value'] as int?;
@override
Map<String, dynamic>? toJson(int state) => {'value': state};
}How fromJson and toJson Drive Persistence
The flow is fully automatic once the methods are defined:
- On every
emit, hydrated_bloc callstoJsonand writes the result to storage keyed by the BLoC'sstorageToken. - When the BLoC is constructed, the base class reads stored JSON and calls
fromJson; the returned value becomes the starting state, overriding the value passed tosuper(...).
If fromJson returns null (no data yet, or a decode failure), the constructor's initial state is used instead. This null-fallback is your safety net.
Persisting a Custom State Class
Real apps rarely store a plain int. For a custom state, map every field you care about in toJson and read it back in fromJson. Below, a settings state with a theme flag and font scale is fully serialized.
Keep the JSON shape stable and explicit so future versions can still read old data.
class SettingsState {
final bool darkMode;
final double fontScale;
const SettingsState({required this.darkMode, required this.fontScale});
Map<String, dynamic> toMap() =>
{'darkMode': darkMode, 'fontScale': fontScale};
factory SettingsState.fromMap(Map<String, dynamic> m) => SettingsState(
darkMode: m['darkMode'] as bool? ?? false,
fontScale: (m['fontScale'] as num?)?.toDouble() ?? 1.0,
);
}
class SettingsCubit extends HydratedCubit<SettingsState> {
SettingsCubit()
: super(const SettingsState(darkMode: false, fontScale: 1.0));
void toggleDark() =>
emit(SettingsState(darkMode: !state.darkMode, fontScale: state.fontScale));
@override
SettingsState? fromJson(Map<String, dynamic> json) =>
SettingsState.fromMap(json);
@override
Map<String, dynamic>? toJson(SettingsState state) => state.toMap();
}Using HydratedBloc with Events
For event-driven logic, extend HydratedBloc instead of HydratedCubit. The toJson/fromJson contract is identical; only state production changes (you register event handlers with on<Event>).
This is the right choice when transitions need event semantics, logging, or transformers.
sealed class CartEvent {}
class ItemAdded extends CartEvent {
final String sku;
ItemAdded(this.sku);
}
class CartBloc extends HydratedBloc<CartEvent, List<String>> {
CartBloc() : super(const []) {
on<ItemAdded>((event, emit) => emit([...state, event.sku]));
}
@override
List<String>? fromJson(Map<String, dynamic> json) =>
(json['items'] as List?)?.cast<String>();
@override
Map<String, dynamic>? toJson(List<String> state) => {'items': state};
}Pure Serialization Logic (Testable)
The heart of persistence is plain Dart: turning an object into a Map and back. You can unit-test that round-trip with no Flutter, no storage, and no device. Below is a standalone program proving the encode/decode is lossless.
Treat your toJson/fromJson as ordinary functions and test them in isolation before wiring them into a BLoC.
import 'dart:convert';
Map<String, dynamic> toJson(int value) => {'value': value};
int? fromJson(Map<String, dynamic> json) => json['value'] as int?;
void main() {
final state = 42;
final encoded = jsonEncode(toJson(state));
print('stored: $encoded');
final decoded = fromJson(jsonDecode(encoded) as Map<String, dynamic>);
print('restored: $decoded');
print('lossless: ${decoded == state}');
}Schema Migration with a Version Field
Once your app ships, old serialized data lives on users' devices. If you add or rename fields, naive fromJson can crash or read garbage. The standard defense is a version field written into the JSON.
On read, branch on the version and upgrade old shapes. This demo migrates a v1 payload (single name) into v2 (firstName/lastName).
import 'dart:convert';
Map<String, dynamic> migrate(Map<String, dynamic> json) {
final version = json['v'] as int? ?? 1;
if (version >= 2) return json;
final parts = (json['name'] as String).split(' ');
return {
'v': 2,
'firstName': parts.first,
'lastName': parts.length > 1 ? parts.last : '',
};
}
void main() {
final oldData = jsonDecode('{"v":1,"name":"Ada Lovelace"}');
final upgraded = migrate(oldData as Map<String, dynamic>);
print(upgraded);
}Defensive fromJson: Surviving Corrupt Data
If fromJson throws, hydrated_bloc catches it and falls back to the initial state, but a partially valid map can still produce a bad state. Make decoding total: validate types, supply defaults, and return null when the data is unusable.
Returning null is intentional and safe — it tells hydrated_bloc to use the constructor's initial state instead.
@override
SettingsState? fromJson(Map<String, dynamic> json) {
try {
final scale = (json['fontScale'] as num?)?.toDouble();
if (scale == null || scale <= 0) return null; // reject bad data
return SettingsState(
darkMode: json['darkMode'] as bool? ?? false,
fontScale: scale,
);
} catch (_) {
return null; // fall back to initial state
}
}Selective Persistence and Clearing State
You do not have to persist every state. Returning null from toJson skips writing for that emission — useful for transient loading or error states you would not want restored.
To wipe stored data, call clear() on the instance (removes just this BLoC's entry) or HydratedBloc.storage.clear() (wipes everything, e.g. on logout).
class AuthCubit extends HydratedCubit<AuthState> {
AuthCubit() : super(const Unauthenticated());
void logout() {
clear(); // remove this cubit's persisted entry
emit(const Unauthenticated());
}
@override
Map<String, dynamic>? toJson(AuthState state) =>
state is Authenticated ? {'token': state.token} : null; // skip others
@override
AuthState? fromJson(Map<String, dynamic> json) =>
json['token'] is String ? Authenticated(json['token'] as String) : null;
}Multiple Instances and storageToken
hydrated_bloc keys persisted data by a storageToken, which defaults to runtimeType. So two instances of the same BLoC class share one storage slot and will overwrite each other.
If you need per-entity persistence (for example, one cubit per chat room), override id so each instance gets a unique token like ChatCubit-room42.
class ChatCubit extends HydratedCubit<List<String>> {
final String roomId;
ChatCubit(this.roomId) : super(const []);
@override
String get id => roomId; // token becomes 'ChatCubit-$roomId'
@override
List<String>? fromJson(Map<String, dynamic> json) =>
(json['messages'] as List?)?.cast<String>();
@override
Map<String, dynamic>? toJson(List<String> state) => {'messages': state};
}Quick Check
A teammate reports that a HydratedCubit emits transient Loading states, and after an app restart the UI is sometimes stuck showing a spinner that never resolves. What is the cleanest fix?
Recap
You can now persist BLoC state across restarts with hydrated_bloc:
- Initialize
HydratedBloc.storageinmain()afterensureInitialized(). - Extend
HydratedCubitorHydratedBlocand overridetoJson/fromJson. - Restoration is automatic on construction;
nullfromfromJsonsafely falls back to the initial state. - Return
nullfromtoJsonto skip persisting transient states, and useclear()on logout. - Guard against corrupt data with defensive decoding and version-based migration.
- Override
idwhen you need per-instance storage tokens.
Keep persisted payloads small and your serialization pure and tested — that is what makes restart-safe state reliable at scale.
자주 묻는 질문
“HydratedBloc을 활용한 상태 저장” 강의는 무료인가요?
네 — “HydratedBloc을 활용한 상태 저장” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Flutter Mobile Development 강의 전체를 잠금 해제할 수 있습니다. Flutter Mobile Development 강의에는 총 4개의 강의가 포함되어 있습니다.
“HydratedBloc을 활용한 상태 저장”에서 뭘 배우나요?
hydrated_bloc을 사용해 앱을 다시 시작해도 BLoC 상태를 자동으로 직렬화하고 복원합니다. 브라우저에서 직접 실행하는 실습 코드로 Flutter Mobile Development을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Flutter Mobile Development을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Flutter Mobile Development은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.
“HydratedBloc을 활용한 상태 저장” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Flutter Mobile Development 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Flutter Mobile Development 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 이벤트, 상태 및 Cubit과 Bloc 중 선택하기
- BLoC의 스트림 변환기 및 이벤트 디바운싱
- HydratedBloc을 활용한 상태 저장
- bloc_test 및 Mocktail을 활용한 BLoC 테스트