← Back to blog
Mastering Riverpod: Core Concepts · Part 7 of 8
August 22, 20268 min read

ref.onDispose — Cleanup Logic in Providers

RiverpodFlutterDart

ref.onDispose

This is Part 7 of Mastering Riverpod: Core Concepts — the finale before the question bank. Providers often own resources: a StreamController, a Timer, a StreamSubscription, a socket, a database connection. When a provider is destroyed, those resources must be cleaned up or you leak. ref.onDispose is how a provider registers that cleanup — it's the provider equivalent of a State's dispose() (Flutter Part 3).


The problem: providers own resources too

You learned to cancel subscriptions and dispose controllers in a widget's dispose() (async series, Flutter Part 3). Providers have the same obligation. If a provider creates a Timer or opens a StreamController and never closes it, that resource lives on after the provider is gone — a leak, and a common one with autoDispose providers that actually get destroyed during normal use.

ref.onDispose(callback) registers a function to run when the provider's state is destroyed:

final tickerProvider = StreamProvider<int>((ref) {
  final controller = StreamController<int>();
  // ... wire up the controller ...
  ref.onDispose(() => controller.close()); // cleanup when the provider dies
  return controller.stream;
});

When tickerProvider is disposed, Riverpod calls the registered callback and the controller is closed. No leak.


When does onDispose fire?

A provider's state is destroyed — and onDispose runs — in several situations:

  • autoDispose (Part 3): the last listener left and the grace frame passed.
  • Invalidation/refresh (Part 5): the old state is destroyed before recomputing. (So onDispose runs on every recompute — important!)
  • A watched dependency changed (Part 6): the provider rebuilds, disposing the old state first.
  • The ProviderScope/ProviderContainer is disposed (app/test teardown).

Subtle but vital: onDispose fires every time the provider recomputes, not only at the very end of its life. Each rebuild disposes the previous state (running onDispose) and then re-creates. So register cleanup that matches the resource created in this computation — e.g. cancel this run's timer — and a fresh one is set up on the next run. This is how providers stay leak-free across rebuilds.

final pollingProvider = StreamProvider.autoDispose<Data>((ref) {
  final timer = Timer.periodic(const Duration(seconds: 5), (_) => /* poll */ {});
  ref.onDispose(timer.cancel); // cancel THIS run's timer on dispose/recompute
  // ... return a stream ...
});

The full lifecycle: onCancel, onResume, onDispose

onDispose is the most-used hook, but a provider has a small lifecycle of listener-related callbacks (especially relevant for autoDispose):

| Hook | Fires when | Use for | | --- | --- | --- | | ref.onCancel | the last listener stops watching | pause work, start a dispose timer | | ref.onResume | a new listener appears after onCancel | resume paused work | | ref.onDispose | the state is destroyed (or about to recompute) | clean up resources | | ref.onAddListener / ref.onRemoveListener | listeners come/go | advanced bookkeeping |

The autoDispose sequence from Part 3, now fully labeled:

last listener removed
   → ref.onCancel
   → wait one frame
   → still unused? → ref.onDispose → state destroyed
   → new listener within the frame? → ref.onResume (kept)

onCancel/onResume let you do things like "stop polling when nobody's looking, resume when they return" — but onDispose is the one you'll reach for in almost every resource-owning provider.


Common cleanup patterns

Anything you'd tear down in a widget's dispose(), you tear down in ref.onDispose:

// Close a StreamController:
final controller = StreamController<int>();
ref.onDispose(controller.close);

// Cancel a Timer:
final timer = Timer.periodic(d, cb);
ref.onDispose(timer.cancel);

// Cancel a StreamSubscription:
final sub = someStream.listen(handler);
ref.onDispose(sub.cancel);

// Close a connection / dispose a controller:
final db = await openDatabase();
ref.onDispose(db.close);

You can register multiple onDispose callbacks — each runs when the provider is destroyed, in registration order. (Note: StreamProvider/StreamNotifierProvider cancel their own stream subscription automatically — Provider Types Part 3 — but anything you create by hand inside a provider is your responsibility.)


onDispose + keepAlive: the caching toolkit

Recall the cache-for-duration pattern from Part 4? It combined keepAlive(), a Timer, and onDispose:

extension CacheForExtension on Ref {
  void cacheFor(Duration duration) {
    final link = keepAlive();
    final timer = Timer(duration, link.close);
    onDispose(timer.cancel); // if the provider is destroyed first, kill the timer
  }
}

onDispose(timer.cancel) is what keeps that pattern leak-free: if the provider is destroyed before the timer fires (e.g. the scope tears down), the dangling timer is cancelled. onDispose is the safety net under every resource a provider touches.


Why this matters: providers are leak-prone without it

It's easy to forget cleanup because providers don't look like they have a lifecycle the way widgets do. But they do — especially autoDispose and family providers, which are created and destroyed constantly during normal use. A Timer or subscription created in such a provider, without onDispose, leaks on every recompute. Rule: if a provider creates something that needs closing/cancelling, register an onDispose for it in the same breath — just like initState/dispose pairing in widgets (Flutter Part 3).


Practice Challenges

Challenge 1 — Close a controller. A provider creates a StreamController. Add cleanup.

Show solution
final controller = StreamController<int>();
ref.onDispose(controller.close);

Challenge 2 — Cancel a timer. A provider starts a Timer.periodic. Prevent the leak.

Show solution
final timer = Timer.periodic(const Duration(seconds: 1), cb);
ref.onDispose(timer.cancel);

Challenge 3 — How often? Does onDispose fire only once at the end of a provider's life? Explain.

Show solution

No — it fires every time the provider's state is destroyed, which includes each recompute (invalidation, refresh, or a watched dependency changing dispose the old state before re-creating). So it runs once per computation, cleaning up that run's resources before the next run sets up fresh ones.

Challenge 4 — Lifecycle order. Put in order for an autoDispose provider losing its last listener: onDispose, onCancel, (grace frame).

Show solution

onCancel → wait one frame → onDispose (if still unused). If a listener returns within the frame, onResume fires instead and the provider is kept.

Challenge 5 — Which providers manage their own stream subscription? And which resources are your responsibility?

Show solution

StreamProvider/StreamNotifierProvider auto-cancel the subscription to the stream they expose. Anything you create by hand inside any provider (a StreamController, Timer, manual listen subscription, DB connection) is your responsibility — register ref.onDispose for each.


Questions to test yourself

Q1 (basic). What does ref.onDispose do?

Show answer

It registers a callback that runs when the provider's state is destroyed, for cleaning up resources (close controllers, cancel timers/subscriptions). It's the provider equivalent of a widget's dispose().

Q2 (basic). Name three resources you'd clean up in onDispose.

Show answer

Any of: a StreamController (.close), a Timer (.cancel), a StreamSubscription (.cancel), a database/socket connection (.close).

Q3 (intermediate). Does onDispose fire only at the end of a provider's life?

Show answer

No — it fires whenever the state is destroyed, including on every recompute (invalidate/refresh or a watched dependency changing dispose the old state first). Register cleanup for the resources created in the current computation so each run is cleaned before the next.

Q4 (intermediate). What's the difference between onCancel and onDispose?

Show answer

onCancel fires when the last listener stops watching (state not yet destroyed — useful to pause work or start a dispose timer). onDispose fires when the state is actually destroyed — the place to release resources. With autoDispose, onCancel precedes a grace frame, then onDispose if still unused.

Q5 (intermediate). Why are autoDispose/family providers especially prone to leaks without onDispose?

Show answer

They're created and destroyed frequently during normal use (per screen, per argument, per recompute). Any resource they create — a timer, subscription, controller — without a matching onDispose leaks each time the provider is torn down, accumulating quickly.

Q6 (advanced). How does onDispose make the cacheFor (keepAlive + Timer) pattern safe?

Show answer

cacheFor pins the provider with keepAlive() and schedules a Timer(duration, link.close) to unpin later. ref.onDispose(timer.cancel) ensures that if the provider is destroyed before the timer fires (e.g. scope teardown or another disposal path), the dangling timer is cancelled — preventing a leaked timer (and a callback firing on a dead provider). It's the cleanup safety net under the caching policy.


Wrapping up

ref.onDispose is the provider's cleanup hook:

  • It registers a callback that runs when the provider's state is destroyed — close controllers, cancel timers/subscriptions, release connections.
  • It fires on every recompute (invalidate/refresh/dependency change) and on final disposal — so it cleans up each computation's resources.
  • The lifecycle: onCancel (last listener left) → grace frame → onDispose (destroyed) / onResume (listener returned).
  • Pair resource creation with onDispose — like initState/dispose in widgets — especially for leak-prone autoDispose/family providers.
  • It's the safety net under caching patterns like cacheFor.

That completes the Core Concepts: AsyncValue, family, autoDispose, keepAlive, invalidate/refresh, dependencies, and onDispose — the cross-cutting tools that make providers powerful. Time to prove it. Part 8 is the 100-question Riverpod Core Concepts mastery bank with coding mini-exercises.