riverpod icon indicating copy to clipboard operation
riverpod copied to clipboard

ref.read() gets previous state, instead of the same value as next from ref.listen()

Open JifScharp opened this issue 11 months ago • 10 comments

Describe the bug I am listening for a state change, and when it occurs triggering some separate logic to do some more complex checks on the state. However, ref.listen() is updated, but when triggering the other code, the ref.read() gets the previous value.

It appears to only happen for AsyncValue.

To Reproduce

//example_provider.dart
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'example_provider.g.dart';

@riverpod
class Example extends _$Example {
  int counter = 0;

  @override
  Future<String> build() async {
    return "$counter";
  }

  void increment() {
    debugPrint("Updated counter from $counter to ${++counter}");
    state = AsyncData("$counter");
  }
}
//watcher_provider.dart
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import 'example_provider.dart';

part 'watcher_provider.g.dart';

@riverpod
void eventWatcher(EventWatcherRef ref) {
  ref.listen(exampleProvider, (previous, next) async {
    final readValue = await ref.read(exampleProvider.future);
    debugPrint(
        "Previous: ${previous?.valueOrNull}, next: ${next.valueOrNull}, read: $readValue");
  });
}
//main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mre_riverpod_outdated_read/example_provider.dart';

import 'watcher_provider.dart';

void main() {
  runApp(
    const ProviderScope(
      child: RiverpodEagerInit(
        child: MyApp(),
      ),
    ),
  );
}

class RiverpodEagerInit extends ConsumerWidget {
  const RiverpodEagerInit({super.key, required this.child});

  final Widget child;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ref.watch(eventWatcherProvider);

    return child;
  }
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends ConsumerWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(exampleProvider).maybeWhen(
          data: (data) => data,
          orElse: () => "-1",
        );

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              counter,
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: ref.read(exampleProvider.notifier).increment,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

From debugging:

flutter: Previous: null, next: 0, read: 0
flutter: Updated counter from 0 to 1      // button pressed
flutter: Previous: 0, next: 1, read: 0    // bug occurs, expected read to get "1"

Expected behavior ref.read() gets the same value as ref.listen() next

JifScharp avatar Dec 20 '24 16:12 JifScharp

Could you share something I can run?

rrousselGit avatar Jan 08 '25 18:01 rrousselGit

I have updated the code, and pasted in a reproducible example

JifScharp avatar Jan 13 '25 11:01 JifScharp

I don't know the inner workings of riverpod that well, but debugging through the code I have found this:

https://github.com/rrousselGit/riverpod/blob/3d3e57f070d16addf9f35af4bc57d7ae65da90a8/packages/riverpod/lib/src/async_notifier/base.dart#L282-L294

The asyncTransition method is what actually updates the state and calls the listeners of .listen and .watch clients. It is called before the future property is reassigned, so in your watcher you are still awaiting the previous future.

ref.listen(exampleProvider, (previous, next) async {
    // called when asyncTransition already happened and state is already 'next'
    final readValue = await ref.read(exampleProvider.future); // <-- but not before the future was reassigned to the new value
    debugPrint(
        "Previous: ${previous?.valueOrNull}, next: ${next.valueOrNull}, read: $readValue");
  });

Maybe this needs to be mentioned in the docs. (Or is this not intended behaviour? @rrousselGit)

alex-medinsh avatar Feb 22 '25 14:02 alex-medinsh

Hi @JifScharp and hi @RepliedSage11,

I'm not the maintainer but I would say this is not the "intended usage" of ref.listen.

Doing...

ref.listen(someProvider, (previous, next) async {
  final lol = await ref.read(someProvider.future);
  // do whatever with "lol"
}

... definitely smells. Most likely this is an xy-problem.

The correct API usage would be:

ref.listen(someProvider.future, (previous, next) async {
  final lol = await next;
  // do whatever with "lol"
}

But I digress.

lucavenir avatar May 04 '25 21:05 lucavenir

Writing down here a self-containing and failing test, so that @rrousselGit can verify which path to take.

repro w/ failing test
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod/riverpod.dart';
import 'package:test/test.dart';

final exampleProvider = AutoDisposeAsyncNotifierProvider<Example, int>(
  () => Example(),
);

class Example extends AutoDisposeAsyncNotifier<int> {
  @override
  Future<int> build() async {
    return 0;
  }

  Future<void> increment() async {
    final prev = state;
    await update((state) => state + 1);
    debugPrint(
      "Updated counter from ${prev.valueOrNull} to ${state.valueOrNull}",
    );
  }
}


void main() {
  test('self-read inside a ref.listen is consistent', () async {
    final container = ProviderContainer();
    addTearDown(container.dispose);

    late int? prev;
    late int? next;
    late int read;

    container.listen(exampleProvider, (p, n) async {
      final readValue = await container.read(exampleProvider.future);
      prev = p?.valueOrNull;
      next = n.valueOrNull;
      read = readValue;
      debugPrint(
        "Previous: ${p?.valueOrNull}, next: ${n.valueOrNull}, read: $readValue",
      );
    });
    await container.read(exampleProvider.future);
    await container.pump();

    expect(prev, isNull);
    expect(next, 0);
    expect(read, 0);

    container.read(exampleProvider.notifier).increment();
    await container.read(exampleProvider.future);
    await container.pump();

    expect(prev, 0);
    expect(next, 1);
    expect(read, 1);  // fails here
  });
}

lucavenir avatar May 04 '25 21:05 lucavenir

Tbh you're not really supposed to use ref inside listeners.
I've recently disabled using ref within onDispose and other life-cycle hooks. Not sure if I did it inside ref.listen too, but maybe I should.

rrousselGit avatar May 04 '25 22:05 rrousselGit

I'm using ref inside listeners. I'm sure many are. We would want a good migration guide if that becomes prohibited.

benji-farquhar avatar May 04 '25 22:05 benji-farquhar

What are you using it for?

The behavior of ref inside listeners/life-cycles is undetermined/untested. I wouldn't be able to tell you for sure what happens in some cases or whether there's no unexpected bug

rrousselGit avatar May 04 '25 22:05 rrousselGit

Toggling another provider on/off when opening/closing this provider, (closed means it is toggled off, hidden means it is toggled on but not displayed because another provider became open (toggled on and displayed). It seems to work as expected. I had to do it via an outside listener because it would be a circular dependency if it was in the two providers (there is an equivalent listener listening to detailsShownProvider).

    ref.listen(filtersShownProvider(_locality), (PanelState? previous, PanelState next) {
      final PanelState detailsShown = ref.read(detailsShownProvider(_locality));

      if (previous != next && next.isClosed && detailsShown == PanelState.hidden) {
        ref.read(detailsShownProvider(_locality).notifier).open();
      } else if (previous != next && next.isOpen && detailsShown == PanelState.open) {
        ref.read(detailsShownProvider(_locality).notifier).hide();
      }
    });

benji-farquhar avatar May 04 '25 22:05 benji-farquhar

Sounds reasonable.

rrousselGit avatar May 04 '25 23:05 rrousselGit

I've just realised that this is a duplicate of https://github.com/rrousselGit/riverpod/issues/4185

rrousselGit avatar Aug 12 '25 18:08 rrousselGit

Toggling another provider on/off when opening/closing this provider, (closed means it is toggled off, hidden means it is toggled on but not displayed because another provider became open (toggled on and displayed). It seems to work as expected. I had to do it via an outside listener because it would be a circular dependency if it was in the two providers (there is an equivalent listener listening to detailsShownProvider).

    ref.listen(filtersShownProvider(_locality), (PanelState? previous, PanelState next) {
      final PanelState detailsShown = ref.read(detailsShownProvider(_locality));

      if (previous != next && next.isClosed && detailsShown == PanelState.hidden) {
        ref.read(detailsShownProvider(_locality).notifier).open();
      } else if (previous != next && next.isOpen && detailsShown == PanelState.open) {
        ref.read(detailsShownProvider(_locality).notifier).hide();
      }
    });

Hey @benji-farquhar,

You can do something similar to this:

enum PanelState { hidden, open, closed }

extension PanelStateX on PanelState {
  bool get isOpen => this == PanelState.open;
  bool get isClosed => this == PanelState.closed;
}
@riverpod
class DetailsShown extends _$DetailsShown {
  @override
  PanelState build(String locality) => PanelState.hidden;

  void open() => state = PanelState.open;
  void hide() => state = PanelState.hidden;
}
@riverpod
class FiltersShown extends _$FiltersShown {
  @override
  PanelState build(String locality) => PanelState.hidden;

  void open() => state = PanelState.open;
  void hide() => state = PanelState.hidden;
}

Then instead of ref.listen, use a sync provider:

@riverpod
void panelSync(PanelSyncRef ref, String locality) {
  final filters = ref.watch(filtersShownProvider(locality));
  final details = ref.watch(detailsShownProvider(locality));
  final detailsNotifier = ref.read(detailsShownProvider(locality).notifier);

  if (filters.isClosed && details == PanelState.hidden) {
    detailsNotifier.open();
  } else if (filters.isOpen && details == PanelState.open) {
    detailsNotifier.hide();
  }
}

And in your widget:

ref.watch(panelSyncProvider(locality));

SalehTZ avatar Aug 18 '25 10:08 SalehTZ