flutter
/

BLoC Local Storage: Persisting Data with Hive, SharedPreferences & SQLite

Last Sync: Today

On this page

12
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Local Storage: Persisting Data with Hive, SharedPreferences & SQLite

Introduction

Local storage is essential for building responsive, offline‑capable Flutter apps. When combined with BLoC, it provides a clean separation between data sources and UI logic. This guide covers how to integrate popular local storage solutions—Hive, SharedPreferences, and SQLite—with BLoC using the repository pattern. You'll learn caching strategies, offline support, and best practices to keep your app fast and reliable.

Why Use Local Storage with BLoC?

  • Offline Access – Serve cached data when the network is unavailable.
  • Performance – Reduce network calls by storing frequently used data locally.
  • User Preferences – Save settings, themes, and user sessions.
  • Data Persistence – Keep state across app restarts.
  • Separation of Concerns – Keep storage logic in repositories, not inside BLoCs.

Choosing the Right Storage

StorageUse CaseKey Features
SharedPreferencesSimple key‑value, small data (settings, flags)Easy API, synchronous, no complex types
HiveFast key‑value, complex objects, large datasetsType‑safe, no SQL, supports encryption
SQLiteRelational data, complex queries, large collectionsFull SQL, migrations, ACID compliance

Repository Pattern with BLoC

The repository pattern acts as a single source of truth, abstracting data sources (local/remote). Your BLoC interacts only with the repository, which decides whether to fetch from cache or network.

DARTRead-only
1
class UserRepository {
  final LocalDataSource localDataSource;
  final RemoteDataSource remoteDataSource;

  UserRepository(this.localDataSource, this.remoteDataSource);

  Future<User> getUser(String id) async {
    // Try local first
    final localUser = await localDataSource.getUser(id);
    if (localUser != null) return localUser;

    // Fallback to network
    final remoteUser = await remoteDataSource.getUser(id);
    await localDataSource.saveUser(remoteUser);
    return remoteUser;
  }
}

// BLoC uses the repository
class UserBloc extends Bloc<UserEvent, UserState> {
  final UserRepository repository;
  UserBloc(this.repository) : super(UserInitial());

  on<LoadUser>((event, emit) async {
    emit(UserLoading());
    try {
      final user = await repository.getUser(event.id);
      emit(UserLoaded(user));
    } catch (e) {
      emit(UserError(e.toString()));
    }
  });
}

Integrating SharedPreferences with BLoC

SharedPreferences is ideal for storing simple settings. Create a service that wraps SharedPreferences and inject it into your BLoC or repository.

DARTRead-only
1
class PreferencesService {
  static const String _themeKey = 'theme_mode';
  final SharedPreferences _prefs;

  PreferencesService(this._prefs);

  ThemeMode getThemeMode() {
    final index = _prefs.getInt(_themeKey) ?? 0;
    return ThemeMode.values[index];
  }

  Future<void> setThemeMode(ThemeMode mode) async {
    await _prefs.setInt(_themeKey, mode.index);
  }
}

class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
  final PreferencesService prefs;
  SettingsBloc(this.prefs) : super(SettingsInitial()) {
    on<LoadSettings>((event, emit) {
      emit(SettingsLoaded(prefs.getThemeMode()));
    });
    on<ThemeChanged>((event, emit) async {
      await prefs.setThemeMode(event.mode);
      emit(SettingsLoaded(event.mode));
    });
  }
}

Integrating Hive with BLoC

Hive is a fast, NoSQL database perfect for storing complex objects. Define adapters and open boxes before using them.

DARTRead-only
1
// Define a Hive adapter
@HiveType(typeId: 0)
class Todo extends HiveObject {
  @HiveField(0)
  String id;
  
  @HiveField(1)
  String title;
  
  @HiveField(2)
  bool completed;

  Todo({required this.id, required this.title, this.completed = false});
}

// Initialize Hive in main
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(TodoAdapter());
  await Hive.openBox<Todo>('todos');
  runApp(MyApp());
}

// Repository using Hive
class TodoRepository {
  final Box<Todo> todoBox;

  TodoRepository(this.todoBox);

  List<Todo> getTodos() => todoBox.values.toList();

  Future<void> addTodo(Todo todo) async {
    await todoBox.add(todo);
  }

  Future<void> deleteTodo(Todo todo) async {
    await todo.delete();
  }
}

// BLoC
class TodoBloc extends Bloc<TodoEvent, TodoState> {
  final TodoRepository repository;
  TodoBloc(this.repository) : super(TodoInitial()) {
    on<LoadTodos>((event, emit) {
      emit(TodoLoaded(repository.getTodos()));
    });
    on<AddTodo>((event, emit) async {
      await repository.addTodo(event.todo);
      emit(TodoLoaded(repository.getTodos()));
    });
  }
}

Integrating SQLite with BLoC

For relational data, SQLite is a solid choice. Use the sqflite package and create a database helper.

DARTRead-only
1
class DatabaseHelper {
  static Database? _database;
  static const String _dbName = 'app.db';
  static const int _version = 1;

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDatabase();
    return _database!;
  }

  Future<Database> _initDatabase() async {
    final path = await getDatabasesPath();
    return openDatabase(
      join(path, _dbName),
      onCreate: (db, version) {
        return db.execute(
          'CREATE TABLE todos(id INTEGER PRIMARY KEY, title TEXT, completed INTEGER)',
        );
      },
      version: _version,
    );
  }
}

class TodoRepository {
  final DatabaseHelper dbHelper;

  TodoRepository(this.dbHelper);

  Future<List<Todo>> getTodos() async {
    final db = await dbHelper.database;
    final List<Map<String, dynamic>> maps = await db.query('todos');
    return maps.map((map) => Todo.fromMap(map)).toList();
  }

  Future<void> addTodo(Todo todo) async {
    final db = await dbHelper.database;
    await db.insert('todos', todo.toMap());
  }
}

Caching Strategies

  • Cache‑First – Always serve from cache; update cache from network in background.
  • Network‑First – Try network; fallback to cache if offline or error.
  • Cache‑Then‑Network – Show cached data immediately, then refresh from network.
  • Time‑Based Expiry – Invalidate cache after a certain duration.
DARTRead-only
1
class CacheService {
  final Box<dynamic> cacheBox;
  final Duration ttl;

  CacheService(this.cacheBox, this.ttl);

  Future<T?> get<T>(String key) async {
    final entry = cacheBox.get(key);
    if (entry == null) return null;
    final timestamp = cacheBox.get('${key}_timestamp');
    if (timestamp != null && DateTime.now().difference(DateTime.parse(timestamp)) > ttl) {
      await cacheBox.delete(key);
      return null;
    }
    return entry as T;
  }

  Future<void> set(String key, dynamic value) async {
    await cacheBox.put(key, value);
    await cacheBox.put('${key}_timestamp', DateTime.now().toIso8601String());
  }
}

Handling Offline Data with BLoC

Combine local storage with an offline queue to handle mutations when offline. Store pending actions in a local database and sync when connectivity returns.

DARTRead-only
1
class OfflineQueue {
  final Box<Map<String, dynamic>> queueBox;

  OfflineQueue(this.queueBox);

  void add(Map<String, dynamic> operation) {
    final id = DateTime.now().millisecondsSinceEpoch.toString();
    queueBox.put(id, operation);
  }

  List<Map<String, dynamic>> getAll() => queueBox.values.toList();

  void remove(String id) => queueBox.delete(id);
}

// In BLoC
on<AddTodo>((event, emit) async {
  if (await hasNetwork()) {
    await api.addTodo(event.todo);
    await localRepository.saveTodo(event.todo);
    emit(TodoAdded(event.todo));
  } else {
    queue.add({'type': 'ADD_TODO', 'data': event.todo.toJson()});
    await localRepository.saveTodo(event.todo); // Optimistic update
    emit(TodoAdded(event.todo));
  }
});

Best Practices

  • Use repository pattern – Keep data source logic separate from BLoC.
  • Initialize storage early – Open databases and boxes before runApp.
  • Handle errors gracefully – Use try/catch and emit error states.
  • Implement caching policies – Use TTL, versioning, or manual invalidation.
  • Test storage interactions – Mock databases or use in‑memory implementations for unit tests.
  • Consider encryption – For sensitive data, use encrypted boxes (Hive) or encrypted SharedPreferences.
  • Manage database migrations – For SQLite, version the database and handle schema changes.

Common Mistakes

  • ❌ Blocking the UI thread – Using synchronous storage operations in the main thread. ✅ Use async methods and await properly.
  • ❌ Not closing databases – Can cause resource leaks. ✅ Close boxes/connections when the app terminates or when no longer needed.
  • ❌ Storing too much data in SharedPreferences – Causes performance issues. ✅ Use Hive or SQLite for large datasets.
  • ❌ Forgetting to register adapters – Hive adapters must be registered before opening boxes. ✅ Register adapters in main.
  • ❌ Ignoring versioning – Changing data models without migrations breaks existing storage. ✅ Plan migrations or use serialization that tolerates changes.

Conclusion

Integrating local storage with BLoC gives you the best of both worlds: reactive state management and persistent data. By using repositories to abstract storage, you can switch between Hive, SharedPreferences, or SQLite without affecting your BLoCs. Combine caching strategies and offline queues to build robust, offline‑first Flutter applications.

Try it yourself

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

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();
  runApp(MyApp(prefs: prefs));
}

class MyApp extends StatelessWidget {
  final SharedPreferences prefs;
  const MyApp({required this.prefs});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => SettingsBloc(prefs),
        child: SettingsPage(),
      ),
    );
  }
}

// State
class SettingsState {
  final bool isDarkMode;
  SettingsState(this.isDarkMode);
}

// Events
abstract class SettingsEvent {}
class ToggleTheme extends SettingsEvent {}
class LoadSettings extends SettingsEvent {}

// BLoC
class SettingsBloc extends Bloc<SettingsEvent, SettingsState> {
  final SharedPreferences prefs;
  static const String _themeKey = 'is_dark_mode';

  SettingsBloc(this.prefs) : super(SettingsState(prefs.getBool(_themeKey) ?? false)) {
    on<LoadSettings>((event, emit) {
      emit(SettingsState(prefs.getBool(_themeKey) ?? false));
    });
    on<ToggleTheme>((event, emit) async {
      final newValue = !state.isDarkMode;
      await prefs.setBool(_themeKey, newValue);
      emit(SettingsState(newValue));
    });
  }
}

// UI
class SettingsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Settings')),
      body: Center(
        child: BlocBuilder<SettingsBloc, SettingsState>(
          builder: (context, state) {
            return SwitchListTile(
              title: Text('Dark Mode'),
              value: state.isDarkMode,
              onChanged: (_) => context.read<SettingsBloc>().add(ToggleTheme()),
            );
          },
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

Which pattern is recommended to separate data sources (local/remote) from BLoC?

A
Factory pattern
B
Repository pattern
C
Singleton pattern
D
Observer pattern
Q2
of 3

What is the best storage option for storing a list of custom objects with complex fields?

A
SharedPreferences
B
Hive with adapters
C
File storage
D
FlutterSecureStorage
Q3
of 3

Which caching strategy shows cached data immediately and then updates from the network?

A
Cache-first
B
Network-first
C
Cache-then-network
D
Time-based expiry

Frequently Asked Questions

Should I use Hive or SharedPreferences for user settings?

SharedPreferences is simpler for small key‑value data. Hive is better if you need to store complex objects or want encryption.

How do I handle database migrations in SQLite?

Increment the database version and use the onUpgrade callback in openDatabase. Write migration scripts to alter tables.

Can I use both Hive and SQLite in the same app?

Yes, they serve different purposes. Use Hive for fast key‑value and SQLite for complex relational data.

How do I test a BLoC that uses local storage?

Mock the repository or use an in‑memory implementation of the storage (e.g., Hive’s Hive.box with a temporary directory).

What’s the best way to cache API responses?

Use a repository that checks cache first, with a time‑to‑live policy. Store responses in Hive or SQLite with timestamps.

Previous

bloc hydrated bloc

Next

bloc testing

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.