Featured image of post Using Isar as a Reactive Database for Flutter

Using Isar as a Reactive Database for Flutter

In this tutorial, I will show you how to create reactive, local data store in Flutter using the Isar package.

This article is part of the Data Storage in Flutter series.

Almost every app relies on storing data locally on the device. In the past, I already created tutorials for how to use the sembast package.

Initially, I wanted to write a tutorial on the hive package, but the last release was made over two years ago. Instead, the developers created a new package called * *isar**, which is now the recommended alternative.

The Cake App revisited

As the basis of this tutorial, I will use the same structure as I already used in my tutorial “Using sembast with riverpod”. Therefore, I will here mainly talk about the differences, since the UI and the state management are completely identical.

As always, the source code of this example is available on GitHub: https://github.com/bettercoding-dev/isar-reactive-database

What is a reactive database?

First, let’s quickly have a look at what I mean with reactive databases. In a conventional database, when you make changes to the data (create, update, delete something), the changes are applied and that’s it. This means, to display the changes in the UI, you will have to explicitly read the database to get the most recent data.

Some modern databases, like isar, allow listening to changes in the data. You can create a stream to the database, which automatically updates every time the data changes. This way, instead of manually pulling the data from the database, every time something changes, the database actively pushes changes to the UI.

In our case, isar provides a watch function that can be used on queries, so everytime the query result changes, the UI updates automatically.

Using isar as a reactive local storage

Prerequisites

First, let’s have a look at the dependencies we need for this tutorial. The dependencies section in our pubspec.yaml should look like this

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1
  freezed_annotation: ^2.4.4
  isar: ^3.1.0+1
  isar_flutter_libs: ^3.1.0+1
  json_annotation: ^4.9.0
  path_provider: ^2.1.4
  riverpod_annotation: ^2.3.5

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^4.0.0
  isar_generator: ^3.1.0+1
  build_runner: ^2.4.13
  riverpod_generator: ^2.4.0
  freezed: ^2.5.2
  json_serializable: ^6.8.0

To quickly install the dependencies, you can run:

flutter pub add flutter_riverpod freezed_annotation isar isar_flutter_libs json_annotation path_provider riverpod_annotation dev:isar_generator dev:build_runner dev:riverpod_generator dev:freezed dev:json_serializable

Initializing Isar

Before we can use isar we need to initialize the database. In the global_providers.dart file, let’s create a new provider for our database.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import 'package:isar/isar.dart';
import 'package:isar_reactive_database/cake/model/cake.dart';
import 'package:path_provider/path_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'global_providers.g.dart';

@riverpod
Isar isar(IsarRef ref) => throw UnimplementedError();

Future<List<Override>> initProviders() async {
  final overrides = <Override>[];

  final directory = await getApplicationDocumentsDirectory();
  final isar = await Isar.open([CakeSchema], directory: directory.path);

  overrides.add(isarProvider.overrideWithValue(isar));

  return overrides;
}

We’re using riverpod overrides to initialize the database before running the app. We put the database in the ApplicationsDocumentsDirectory that we retrieved with the help of the path_provider package.

Note, that we also need to reference the CakeShema here. We will shortly see where this comes from, when looking at the data model.

In case you’re wondering how the riverpod overrides work, hat a look at this blog post.

The Cake Model

The isar package required to annotate models that should be stored in the database with the @Collection() annotation. Using code generation with the build_runner package, this will create code to store the model in the database. That way also the CakeSchema is created, that we used when initializing the database above.

Although it is not required, I would still recommend using freezed with your models. This way, you’ll get handy functions as copyWith and hashCode.

The only thing that you need to do, to make freezed and isar work together, is to use ignore field on the annotation and create a getter for the model’s id.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:isar/isar.dart';

part 'cake.freezed.dart';

part 'cake.g.dart';

@freezed
@Collection(ignore: {'copyWith'})
class Cake
    with _$Cake {
  const Cake._();

  const factory Cake({
    required int id,
    required String name,
    required int yummyness,
  }) = _Cake;


  @override
  Id get id => id;

  factory Cake.fromJson(Map<String, dynamic> json) => _$CakeFromJson(json);
}

The CakeRepository with isar

As with the tutorial on sembast, the CakeRepository interface looks the same:

1
2
3
4
5
6
7
8
9
abstract interface class CakeRepository {
  Future<int> insertCake(Cake cake);

  Future updateCake(Cake cake);

  Future deleteCake(int cakeId);

  Stream<List<Cake>> getAllCakesStream();
}

Notice the getAllCakesStream() function. Using the returned Stream, we can make our UI update without manually querying the database.

Finally, let’s have a look at the isar-specific implementation of the repository:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import 'package:isar/isar.dart';
import 'package:isar_reactive_database/cake/model/cake.dart';
import 'package:isar_reactive_database/cake/repository/cake_repository.dart';

class IsarCakeRepository implements CakeRepository {
  final Isar isar;

  const IsarCakeRepository(this.isar);

  @override
  Future deleteCake(int cakeId) =>
      isar.writeTxn(() {
        return isar.cakes.delete(cakeId);
      });

  @override
  Stream<List<Cake>> getAllCakesStream() {
    return isar.cakes.where().watch(fireImmediately: true);
  }

  @override
  Future<int> insertCake(Cake cake) =>
      isar.writeTxn(() {
        return isar.cakes.put(cake);
      });

  @override
  Future updateCake(Cake cake) =>
      isar.writeTxn(() {
        return isar.cakes.put(cake);
      });
}

Actually, the implementation is dead simple. In comparison to other databases, isar takes care of serialization and deserialization for us. This way, there’s almost no code left for us to write.

For all writing operations on the isar database, the package requires us to wrap the code in writeTxn functions. This means that we’re creating transactions for our operations.

Transactions are a way to group database operations. If one of the operations fails, all operations in the same transactions are reversed as well. Or in other words: the operations are only persisted if all the operations are successful.

To create a query, we can use the where() function on an isar collection. If we don’t specify any parameters, all objects are returned.

Using the watch function, we can listen to any changes of this query. With fireImmediately: true we tell the stream, to immediately emmit the latest data when a listener connects. Otherwise, the stream would only return data once the data changes the first time.

Summary

With isar it is very straightforward to create a local database. The watch function on queries is very helpful to listen for database changes. In comparison to sembast, isar uses code generation, to handle serialization and deserialization for us.

The isar database also comes with a lot of other features, like indexes, links and a query builder. To learn more about these features, be sure to check out their documentation.

Built with Hugo
Theme based on Stack designed by Jimmy