riverpod icon indicating copy to clipboard operation
riverpod copied to clipboard

Auto dispose after first listener

Open talent-apps opened this issue 2 years ago • 70 comments

Riverpod version 2.0.0-dev.4 adds a disposeDelay feature to all autoDispose providers and to ProviderContainer/ProviderScope. This configures the amount of time before a provider is disposed when it is not listened.

To avoid timing issues, it would be nice to add a "auto dispose after the provider is listened to once" mode, which make the provider auto disposable only after it is listened to once.

Example:

  • Some function on screen A initalizes a provider with some initial value and then navigates to screen B.
  • Only screen B watches the provider.
  • Once screen B is disposed, the provider should be disposed.

talent-apps avatar Mar 30 '22 13:03 talent-apps

  • Only screen B watches the provider.

I would suggest have A listen to the provider until navigating to B

rrousselGit avatar Mar 30 '22 13:03 rrousselGit

The nature of the use case is that A is not interested in this provider. A classic example is a master/details scenario:

  • Screen A presents a list of items
  • Screen B allows to edit a single item
  • The provider represents the item that is currently being edited.

This scenario is very common and the library doesn't seem to provide an easy way to handle it.

talent-apps avatar Mar 30 '22 13:03 talent-apps

Maybe it's worth expanding on what "initializing with some initial value" means and also explain what's the problem you're currently facing

rrousselGit avatar Mar 30 '22 13:03 rrousselGit

The provider will be initalized with the item that was selected in the list. Then the app will navigate to screen B where you can edit the properties of the selected item.

talent-apps avatar Mar 30 '22 14:03 talent-apps

The provider will be initalized with the item that was selected in the list.

Right, but what exactly does this involve?

rrousselGit avatar Mar 30 '22 14:03 rrousselGit

Code example:

void onItemSelected(BuildContext context, WidgetRef ref, Item selectedItem) {
  ref.read(editedItemProvider.notifier).setItem(selectedItem);
  Navigator.of(context).pushNamed(screenB);
}
class Item {
  final String name;
  const Item(this.name);

  Item copyWith(String? name) => Item(name ?? this.name);
}

final editedItemProvider = StateNotifierProvider.autoDispose<EditedItemNotifier, Item?>((ref) {
  return EditedItemNotifier(ref: ref);
});

class EditedItemNotifier extends StateNotifier<Item?> {
  final Ref ref;

  EditedItemNotifier({required this.ref}) : super(null);

  void setItem(Item item) {
    state = item;
  }

  void updateName(String newName) {
    state = state?.copyWith(newName);
  }
}

talent-apps avatar Mar 30 '22 14:03 talent-apps

With the current implementation, editedItemProvider will be disposed before screen B is launched, even though screen B watches this provider.

talent-apps avatar Mar 30 '22 14:03 talent-apps

And why don't you want to set disposeDelay in this case?

rrousselGit avatar Mar 30 '22 15:03 rrousselGit

It would work, but relying on a specific timeout is more error prone, while relying on adding/removing a listener is exact. With a timeout you also hold the provider state longer than you actually need it, which is less efficient. This scenario is very common in many apps and it would be nice to have this if it's a small feature to implement.

talent-apps avatar Mar 30 '22 15:03 talent-apps

I agree in this case, even tho disposeDelay help the issuw. Still uncertain how long it should hold the state. There is cases when I switch screen and then back to the old screen. I would like to dispose it immediately and not delay the dispose.

robpot95 avatar Mar 31 '22 23:03 robpot95

Cool. An automatic disposal after at least one listener would help addressing issues like that.

talent-apps avatar Apr 01 '22 12:04 talent-apps

I've been dealing with this same issue - my use-case is initializing a AutoDisposing StateNotifierProvider that is used to cache form values until submission - the initialization occurs when "master" form widget is loaded, but this widget does not care about the state of the provider; the detailed form widgets subscribe to different parts of the form state via the provider.

I cannot use auto-dispose unless the master watches the provider, causing rebuilds in the entire tree - having the auto-dispose occur only after at least one listen would solve my issue.

danielkerwin avatar Apr 01 '22 12:04 danielkerwin

Indeed this scenario is very common.

talent-apps avatar Apr 01 '22 12:04 talent-apps

Well ultimately you can do that yourself already

Provider.autoDispose((ref) {
  ref.keepAlive();
  ref.onCancel(ref.invalidateSelf);
}

This effectively produces the same result as autoDispose, without the state getting destroyed during navigation

rrousselGit avatar Apr 01 '22 12:04 rrousselGit

Well ultimately you can do that yourself already

Provider((ref) {
  ref.onCancel(ref.invalidateSelf);
}

This effectively produces the same result as autoDispose, without the state getting destroyed during navigation

When will it get destroyed then?

robpot95 avatar Apr 01 '22 13:04 robpot95

While the proposal is interesting, I'm not sure it's ultimately a good idea.

I could see a few flaws:

  • what if the provider is never listened?

  • what if you have:

     final a = Provider.autoDispose(..., waitUntilListenedBeforeDispose);
    
     final b = Provider((ref) { 
        ref.watch(a);
        return Something();
     });
    
    // inside some widget:
    ref.read(b);
    Navigator.push();
    

    That'd still cause a to be disposed, since it's temporarily listened by b

rrousselGit avatar Apr 01 '22 13:04 rrousselGit

When will it get destroyed then?

When all listeners are removed.

rrousselGit avatar Apr 01 '22 13:04 rrousselGit

If we want to do something other than disposeDelay (which I personally think is good enough for the problem described), I'd personally prefer having a way to make A temporarily listen to the provider until B is poped

Like maybe:

Button(
  onTap: () async {
    final sub = ref.listen(provider, (prev, value) {});
    await Navigator.push(); 
    sub.close();
  },
)

rrousselGit avatar Apr 01 '22 13:04 rrousselGit

@rrousselGit this feels a bit awkward since you listen to something that you are not interested it. One more thing, the fact that this feature doesn't solve ALL scenarios is irrrelavnt since it obsivously helps solve some common scenarios. How exactly can we achieve this behaviour ourselves with the current implementation?

talent-apps avatar Apr 01 '22 13:04 talent-apps

While the proposal is interesting, I'm not sure it's ultimately a good idea.

I could see a few flaws:

  • what if the provider is never listened?

  • what if you have:

     final a = Provider.autoDispose(..., waitUntilListenedBeforeDispose);
    
     final b = Provider((ref) { 
        ref.watch(a);
        return Something();
     });
    
    // inside some widget:
    ref.read(b);
    Navigator.push();
    

    That'd still cause a to be disposed, since it's temporarily listened by b

I agree with you, that this will cause a issue. But tbh when i set the provider state and navigate screen. First problem i'm bit unsure how long the disposeDelay should last. Maybe navigate screen takes a microsecond or couple seconds. Then i have the problem with that i would like to use the state in screen B that is setted from screen A. But i would like to dispose the state immediately after i leave screen B and not delay it.

Suggestion would be to autoDispose the state, if it's never watched after some time. But if it's being watched once, then it should autoDipose immediately.

robpot95 avatar Apr 01 '22 13:04 robpot95

@rrousselGit As for your example:

  • If the provider is never listened to, it will remain alive, that's the expected behaviour I guess.
  • If it's possible to restrict this feature to "Dispose when you don't have listeners, but only after being WATCHED once" it would be better.

talent-apps avatar Apr 01 '22 13:04 talent-apps

Maybe navigate screen takes a microsecond or couple seconds

Navigating to a screen is not duration based. It's event-loop based. It'll take one cycle of the event loop

As such, any non-zero duration will do.

Also, "disposing immediately" doesn't matter. What's wrong with delaying the dispose by a few seconds?

rrousselGit avatar Apr 01 '22 13:04 rrousselGit

One more thing, the fact that this feature doesn't solve ALL scenarios is irrrelavnt since it obsivously helps solve some common scenarios.

It is important, since there are workarounds. Adding a new API comes with a significant cost

How exactly can we achieve this behaviour ourselves with the current implementation?

You can do the ref.listen variant I showed by doing:

final container = ProviderScope.containerOf(context);
final sub = container.listen(...);
await push();
sub.close();

This works today.

And I plan on streamlining this a bit by adding a WidgetRef.listen variant which returns the subscription instead of being used inside build like the current one.

rrousselGit avatar Apr 01 '22 13:04 rrousselGit

I agree with others that a temporary listener, or time based condition, feel awkward - what if the detailed components that subscribe to the provider themselves depend on some other unknown time-based async event, like fetching data before subscribing; the delay can't be set programmatically, but a dispose after first listen can.

danielkerwin avatar Apr 01 '22 13:04 danielkerwin

I agree with others that a temporary listener, or time based condition, feel awkward - what if the detailed components that subscribe to the provider themselves depend on some other unknown time-based async event, like fetching data before subscribing; the delay can't be set programmatically, but a dispose after first listen can.

You can close the temporary listener whenever you want.

Same goes for the initialization. You'll call ref.read when you want. If your onTap has other async functions, they'd likely execute before the ref.read – since the read will depend on them.

rrousselGit avatar Apr 01 '22 13:04 rrousselGit

@rrousselGit The entire point of this feature is separation of concerns. Creating an ad-hoc listenter where it is not needed seems a bit weird.

talent-apps avatar Apr 01 '22 13:04 talent-apps

There's no reason for it to feel weird. It's a fairly common pattern for testing

final container = ProviderContainer();
final sub = container.listen(autoDisposeProvider, (prev, value) {});

expect(sub.read(), 42);

That's the expected way to "read" autoDispose providers.

rrousselGit avatar Apr 01 '22 13:04 rrousselGit

Just for the sake of the example, if the navigation to the screen that uses that auto disposable provider is done in 10 different places, each one would have to be aware of this and create its own dummy listener, instead of encapsulating this behaviour in the provider itself.

talent-apps avatar Apr 01 '22 13:04 talent-apps

I already shared how to do that in the provider itself, using onCancel/invalidate.

To be clear, I've been thinking for a while about removing the ability to read autoDispose providers. That's ultimately the root of the issue.

There are ways to achieve the same thing without relying on ref.read(autoDispose)

rrousselGit avatar Apr 01 '22 13:04 rrousselGit

What I really want to avoid is adding an option flag.
Options tend to increase the complexity of the API quite a bit.

If this behavior is really desirable, I would prefer having a breaking change and making this the new behavior for all autoDispose providers. Having such behavior as a flag IMO is a bad idea

It's actually something I originally wanted to do for the 1.0.0 (cf https://github.com/rrousselGit/river_pod/issues/531).

rrousselGit avatar Apr 01 '22 13:04 rrousselGit