skills/flutter-drift/references/streams.md

6.7 KiB

title description
Stream Queries Use Drift reactive query streams in Flutter

Basic Streams

Every runnable Drift select can become a stream.

final todosStream = database.select(database.todoItems).watch();

Flutter can consume the stream with StreamBuilder:

StreamBuilder<List<TodoItem>>(
  stream: database.select(database.todoItems).watch(),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const Center(child: CircularProgressIndicator());
    }

    if (snapshot.hasError) {
      return Text('Error: ${snapshot.error}');
    }

    final todos = snapshot.data ?? [];
    return ListView.builder(
      itemCount: todos.length,
      itemBuilder: (context, index) {
        return ListTile(title: Text(todos[index].title));
      },
    );
  },
)

Drift streams emit an up-to-date value after listening. You usually do not need to call get() before watch().

Single Row Streams

Use watchSingle() only when exactly one row must exist:

final todoStream = (database.select(database.todoItems)
      ..where((t) => t.id.equals(id)))
    .watchSingle();

Use watchSingleOrNull() when the row may not exist:

final todoStream = (database.select(database.todoItems)
      ..where((t) => t.id.equals(id)))
    .watchSingleOrNull();

Filtered Streams

final activeTodos = (database.select(database.todoItems)
      ..where((t) => t.isCompleted.equals(false)))
    .watch();
final sortedTodos = (database.select(database.todoItems)
      ..orderBy([
        (t) => OrderingTerm.desc(t.createdAt),
      ]))
    .watch();

StreamBuilder With State

class TodoList extends StatelessWidget {
  const TodoList({required this.database, super.key});

  final AppDatabase database;

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<List<TodoItem>>(
      stream: (database.select(database.todoItems)
            ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
          .watch(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Center(child: CircularProgressIndicator());
        }

        if (snapshot.hasError) {
          return Text('Error: ${snapshot.error}');
        }

        final todos = snapshot.data ?? [];
        if (todos.isEmpty) {
          return const Center(child: Text('No todos yet'));
        }

        return ListView.builder(
          itemCount: todos.length,
          itemBuilder: (context, index) {
            final todo = todos[index];
            return CheckboxListTile(
              value: todo.isCompleted,
              title: Text(todo.title),
              onChanged: (value) {
                (database.update(database.todoItems)
                      ..where((t) => t.id.equals(todo.id)))
                    .write(
                  TodoItemsCompanion(
                    isCompleted: Value(value ?? false),
                  ),
                );
              },
            );
          },
        );
      },
    );
  }
}

Riverpod StreamProvider

Do not treat AsyncValue<List<TodoItem>> as a list. Use when.

final databaseProvider = Provider<AppDatabase>((ref) {
  final database = AppDatabase();
  ref.onDispose(database.close);
  return database;
});

final todosProvider = StreamProvider<List<TodoItem>>((ref) {
  final database = ref.watch(databaseProvider);
  return (database.select(database.todoItems)
        ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
      .watch();
});

class TodoList extends ConsumerWidget {
  const TodoList({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todosAsync = ref.watch(todosProvider);

    return todosAsync.when(
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (error, stackTrace) => Text('Error: $error'),
      data: (todos) => ListView.builder(
        itemCount: todos.length,
        itemBuilder: (context, index) {
          return ListTile(title: Text(todos[index].title));
        },
      ),
    );
  }
}

Join Streams

class TodoWithCategory {
  TodoWithCategory({required this.todo, required this.category});

  final TodoItem todo;
  final TodoCategory? category;
}

Stream<List<TodoWithCategory>> watchTodosWithCategory(AppDatabase database) {
  final query = database.select(database.todoItems).join([
    leftOuterJoin(
      database.categories,
      database.categories.id.equalsExp(database.todoItems.category),
    ),
  ]);

  return query
      .map(
        (row) => TodoWithCategory(
          todo: row.readTable(database.todoItems),
          category: row.readTableOrNull(database.categories),
        ),
      )
      .watch();
}

Custom Query Streams

For custom SQL, use customSelect(...).watch() and always declare readsFrom so Drift knows which tables invalidate the stream.

Stream<List<TodoItem>> watchCompletedTodos(AppDatabase database) {
  return database
      .customSelect(
        '''
        SELECT * FROM todo_items
        WHERE is_completed = ?
        ORDER BY created_at DESC
        ''',
        variables: [Variable.withBool(true)],
        readsFrom: {database.todoItems},
      )
      .watch()
      .map(
        (rows) => rows
            .map((row) => database.todoItems.map(row.data))
            .toList(),
      );
}

Table Update Events

Use tableUpdates for low-level update events:

final subscription = database
    .tableUpdates(
      TableUpdateQuery.onTable(
        database.todoItems,
        limitUpdateKind: UpdateKind.update,
      ),
    )
    .listen((updates) {
  for (final update in updates) {
    debugPrint('Todo table update: $update');
  }
});

Cancel subscriptions you create manually:

await subscription.cancel();

Manual Update Trigger

If a write happens outside Drift and streams must refresh, manually notify the affected table:

database.notifyUpdates({
  TableUpdate.onTable(
    database.todoItems,
    kind: UpdateKind.update,
  ),
});

Stream Transformations

Map streams to lightweight UI state when widgets do not need full rows:

final titlesStream = database
    .select(database.todoItems)
    .watch()
    .map((todos) => todos.map((todo) => todo.title).toList());

Debounce user input before rebuilding the query in UI state. Do not depend on a debounceTime extension unless the app already imports a package that provides it.

Limitations

  • Updates outside Drift do not trigger query streams unless you call notifyUpdates.
  • Drift stream invalidation is table-based, so streams can rerun more often than the exact row changes require.
  • Keep stream queries indexed and reasonably small for UI use.