← Back to blog
Mastering Riverpod: Provider Types · Part 6 of 8
August 13, 20266 min read

StreamNotifierProvider — Combining Streams with Mutations

RiverpodFlutterDart

StreamNotifierProvider

This is Part 6 of Mastering Riverpod: Provider Types — the final cell of the provider grid. StreamProvider gave us a read-only stream. AsyncNotifier gave us mutable async state. StreamNotifierProvider combines them: state that comes from a Stream and exposes methods to act on it. Think a chat room that streams incoming messages and lets you send one.

This is the least-used of the six (many apps never need it), but understanding it completes your mastery of the whole family — and it's the right tool for "live feed + actions."


The shape: build() returns a Stream, plus methods

A StreamNotifier mirrors AsyncNotifier, except build() returns a Stream instead of a Future:

import 'package:flutter_riverpod/flutter_riverpod.dart';

class ChatController extends StreamNotifier<List<Message>> {
  @override
  Stream<List<Message>> build() {
    // The live source — a stream of message lists.
    final roomId = ref.watch(activeRoomProvider);
    return ref.watch(chatRepositoryProvider).messageStream(roomId);
  }

  // A mutation that acts alongside the stream:
  Future<void> sendMessage(String text) async {
    await ref.read(chatRepositoryProvider).send(text);
    // No need to manually add to state — the stream will emit the new message.
  }
}

final chatProvider =
    StreamNotifierProvider<ChatController, List<Message>>(ChatController.new);

Key points:

  • Extend StreamNotifier<T>; build() returns a Stream<T>.
  • The exposed state is an AsyncValue<T> that updates on every stream event — same as StreamProvider. Render with .when/switch.
  • StreamNotifierProvider<Controller, T>(Controller.new) — the same two-type-arg pattern.
  • Riverpod manages the subscription (subscribe on watch, cancel on dispose) — no leaks.
  • You add methods (like sendMessage) that perform actions; the stream typically reflects the change on its next emission.

So it's StreamProvider (Part 3) with mutation methods bolted on — exactly the relationship AsyncNotifier has to FutureProvider.


Reading it: same AsyncValue, plus .notifier for methods

Reading combines what you know from StreamProvider and NotifierProvider:

class ChatScreen extends ConsumerWidget {
  const ChatScreen({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final messagesAsync = ref.watch(chatProvider); // AsyncValue<List<Message>>

    return Column(children: [
      Expanded(
        child: messagesAsync.when(
          loading: () => const CircularProgressIndicator(),
          error: (e, _) => Text('Error: $e'),
          data: (messages) => MessageList(messages), // updates live per event
        ),
      ),
      MessageInput(
        onSend: (text) =>
            ref.read(chatProvider.notifier).sendMessage(text), // method via .notifier
      ),
    ]);
  }
}
  • ref.watch(chatProvider) → the live AsyncValue (rebuilds on each message).
  • ref.read(chatProvider.notifier).sendMessage(...) → call a method (in a callback).

Same watch-for-state / read(.notifier)-for-methods rule from Part 4.


Why not just StreamProvider + a separate action?

You could use a read-only StreamProvider for the feed and put sendMessage on a separate repository call in the widget. That works for simple cases. StreamNotifierProvider is the right choice when the stream and the actions belong together as one cohesive piece of state/logic:

  • the controller encapsulates both the live source and the operations on it,
  • methods can read the current streamed value to make decisions,
  • it's a single testable unit (one controller, mockable repository),
  • and consumers get one provider for "the chat," not a stream here and an action there.

Rule of thumb: if the live data and the mutations are the same feature (a chat, a collaborative document, a live cart synced over a socket), model them as one StreamNotifier. If you only ever read the stream, keep it a simple StreamProvider.


The complete grid, filled in

With this part, you've met all six provider types. Here's the whole map, now with everything you know:

| | Read-only | Mutable (methods) | | --- | --- | --- | | Sync | Provider — derived values, DI (P1) | NotifierProvider — sync mutable state (P4) | | Future | FutureProvider — fetch-and-show (P2) | AsyncNotifierProvider — async CRUD (P5) | | Stream | StreamProvider — live read-only (P3) | StreamNotifierProvider — live + actions (this part) |

Notice the symmetry: the right column is the left column plus methods, and each row changes only the value kind (sync → Future → Stream). Internalize this table and you'll always pick the right provider on the first try.


Practice Challenges

Challenge 1 — Stream build. Write a StreamNotifier<int> whose build() emits a counter every second.

Show solution
class Ticker extends StreamNotifier<int> {
  @override
  Stream<int> build() => Stream.periodic(const Duration(seconds: 1), (i) => i);
}
final tickerProvider = StreamNotifierProvider<Ticker, int>(Ticker.new);

Challenge 2 — Add a method. Add a reset() action concept — explain why mutating a live stream's state differs from a Notifier.

Show solution

In a StreamNotifier, the source of truth is the stream — methods usually perform an action (e.g. tell the backend), and the stream re-emits the new state. Unlike a sync Notifier where you set state = directly, here you typically let the stream drive state. (You can set state = AsyncData(...) for optimistic updates, but the stream remains the authority.)

Challenge 3 — Render + send. Show the chat list and wire a send button.

Show solution
final async = ref.watch(chatProvider);
// list: async.when(data: (m) => MessageList(m), loading: ..., error: ...)
onSend: (t) => ref.read(chatProvider.notifier).sendMessage(t),

Challenge 4 — Choose the cell. Stream-only price feed vs a live collaborative doc you also edit — which providers?

Show solution

Price feed (read-only) → StreamProvider. Collaborative doc (streams updates and you edit it) → StreamNotifierProvider (stream + mutation methods).

Challenge 5 — Fill the grid. Name the provider for: async + mutable; sync + read-only; stream + mutable.

Show solution

AsyncNotifierProvider; Provider; StreamNotifierProvider.


Questions to test yourself

Q1 (basic). What does a StreamNotifier's build() return, and what does the provider expose?

Show answer

build() returns a Stream<T>; the provider exposes the latest value as an AsyncValue<T> that updates on every stream event (and offers methods to act on the state).

Q2 (basic). How is StreamNotifierProvider related to StreamProvider?

Show answer

It's StreamProvider plus mutation methods — a read-only live stream becomes a mutable one with actions, just as AsyncNotifier is FutureProvider plus methods.

Q3 (intermediate). How do you read the live state vs call a method?

Show answer

ref.watch(provider) for the live AsyncValue (rebuilds per event); ref.read(provider.notifier).someMethod() to call a method (in a callback). Same watch-state / read(.notifier)-methods rule.

Q4 (intermediate). When should you choose StreamNotifierProvider over a StreamProvider plus a separate action?

Show answer

When the live data and the actions are one feature (chat, collaborative doc, socket-synced cart) — so the controller encapsulates both, methods can read the current streamed value, and consumers get a single testable provider. If you only read the stream, a plain StreamProvider is simpler.

Q5 (intermediate). Who manages the stream subscription in a StreamNotifier?

Show answer

Riverpod — it subscribes when the provider is first watched and cancels automatically on dispose, just like StreamProvider. No manual StreamSubscription/dispose.

Q6 (advanced). Describe the symmetry of the full provider grid in one sentence.

Show answer

The right (mutable) column is the left (read-only) column plus methods (Notifier/AsyncNotifier/StreamNotifier), and each row only changes the value kind — sync (Provider), Future (FutureProvider), or Stream (StreamProvider) — so the six types are just two axes: value kind × mutability.


Wrapping up

StreamNotifierProvider completes the grid:

  • A StreamNotifier<T> has a build() returning a Stream<T> and exposes state as a live AsyncValue<T>, plus methods to act on it.
  • It's StreamProvider + mutations — for features where the live feed and the actions belong together (chat, collaborative docs).
  • Read state with ref.watch, call methods via ref.read(.notifier); Riverpod manages the subscription.
  • The full grid is two axes: value kind (sync/Future/Stream) × mutability (read-only/methods).

There's one more group of providers you'll encounter — not new powers, but old ones you should recognize and migrate away from. Part 7 covers the legacy providers (StateProvider, StateNotifierProvider) — recognize, migrate, move on.