StatefulValueNotifier / StatefulValueListenableBuilder

Hello friends!

In this post, I would like to receive feedback regarding our solution to work with the loading, error, and idle states of data. We worked on this solution together with Mikhail Zotyev (author or Elementary package and GDE) and the Wolt internal team.


What is StatefulValueNotifier?

Source code

StatefulValueNotifier<T> is a class that extends ValueNotifier<ValueState<T>>, where ValueState<T> is a sealed class representing the state of a value of type T. It allows you to handle three primary states:

  • Idle: The default state when data is successfully loaded or initialized.
  • Loading: Indicates that an asynchronous operation is in progress.
  • Error: Represents an error state with an associated exception.

StatefulValueNotifier allows transitioning between these states and update the UI accordingly. This class also implements StatefulValueListenable so that the private notifier field can be exposed from the owner as listenable.


Understanding ValueState

Source code

The ValueState<T> sealed class has three implementations:

  1. IdleValueState: Represents the idle state with an optional value.
  2. LoadingValueState: Represents the loading state, optionally retaining the current value.
  3. ErrorValueState: Represents the error state with an associated error object and an optional value.

How to Use StatefulValueNotifier

Let’s walk through an example of how to use StatefulValueNotifier in a Flutter application.

1. Initialization

First, create an instance of StatefulValueNotifier with an initial value:

final valueNotifier = StatefulValueNotifier<int>.idle(value: 0);

You can also initialize it in a loading or error state:

// Loading state
final valueNotifier = StatefulValueNotifier<int>.loading();

// Error state with an initial error
final valueNotifier = StatefulValueNotifier<int>.error(Exception('Initial error'));

2. Listening to State Changes

You can listen to state changes by adding a listener:

valueNotifier.addListener(() {
  final state = valueNotifier.value;
  if (state.isIdle) {
    // Handle idle state
    print('Idle State: ${state.value}');
  } else if (state.isLoading) {
    // Handle loading state
    print('Loading...');
  } else if (state.isError) {
    // Handle error state
    final errorState = state as ErrorValueState<int>;
    print('Error: ${errorState.error}');
  }
});

or

valueNotifier.addListener(() {
  final state = valueNotifier.value;
  switch (state) {
    case IdleValueState(value: var value):
      // Handle idle state
      print('Idle State: $value');
      break;
    case LoadingValueState():
      // Handle loading state
      print('Loading...');
      break;
    case ErrorValueState(error: var error, value: var value):
      // Handle error state
      print('Error: $error');
      break;
  }
});

3. Updating the State

  • Set to Loading
valueNotifier.setLoading();

Optionally, retain the current value or set a new one:

// Retain current value when switching state
valueNotifier.setLoading(retainValue: true);

// Set a new value
valueNotifier.setLoading(value: 10);
  • Set to Error
valueNotifier.setError(error: Exception('An error occurred'));

Optionally, retain the current value or set a new one:

// Retain current value when switching state
valueNotifier.setError(error: Exception('An error occurred'), retainValue: true);

// Set a new value
valueNotifier.setError(error: Exception('An error occurred'), value: 20);
  • Set to Idle with a New Value
valueNotifier.setIdle(value: 42);

4. Using with Widgets

You can use StatefulValueListenableBuilder to rebuild your widgets based on the current state:

StatefulValueListenableBuilder<int>(
  valueListenable: valueNotifier,
  idleBuilder: (context, value) {
    return Text('Idle State. Value: $value');
  },
  loadingBuilder: (context, value) {
    return CircularProgressIndicator();
  },
  errorBuilder: (context, error, value) {
    return Text('Error: $error');
  },
);

Complete Example

Below is an example demonstrating how to use StatefulValueNotifier and StatefulValueListenableBuilder.

class _MyWidgetState extends State<MyWidget> {
  late StatefulValueNotifier<String> jokeNotifier;

  @override
  void initState() {
    super.initState();
    // Initialize the value notifier with a loading state
    jokeNotifier = StatefulValueNotifier<String>.loading();
    // Fetch the first joke
    fetchJoke();
  }

  @override
  void dispose() {
    jokeNotifier.dispose();
    super.dispose();
  }

  /// Method to fetch a joke
  void fetchJoke() {
    jokeNotifier.setLoading(retainValue: true);

    // Simulate a network request
    Future.delayed(Duration(seconds: 2), () async {
      try {
        // Simulate fetching data from an API
        final joke = await _getRandomJoke();
        jokeNotifier.setIdle(value: joke);
      } catch (e) {
        jokeNotifier.setError(error: e, retainValue: true);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return StatefulValueListenableBuilder<String>(
      valueListenable: jokeNotifier,
      idleBuilder: (context, joke) {
        return JokeDisplay(
          joke: joke ?? 'No joke available',
          onRefresh: fetchJoke,
        );
      },
      loadingBuilder: (context, joke) {
        return Column(
          children: [
            CircularProgressIndicator(),
            if (joke != null) ...[
              SizedBox(height: 16),
              Text('Previous joke: $joke'),
            ],
          ],
        );
      },
      errorBuilder: (context, error, joke) {
        return ErrorDisplay(
          error: error,
          onRetry: fetchJoke,
        );
      },
    );
  }

  /// Simulate an API call to get a random joke
  Future<String> _getRandomJoke() async {
    // Simulate success or failure randomly
    if (DateTime.now().second % 2 == 0) {
      return 'Why did the programmer quit his job? Because he didn’t get arrays.';
    } else {
      throw Exception('Failed to fetch joke.');
    }
  }
}

Source code

2 Likes

It looks like you’re reinventing Riverpod. Other than reinvention, what’s the advantage here?

4 Likes

Riverpod is “reactive caching framework for Flutter/Dart.” This solution is not a framework and the goal is to be as familiar as possible to the Flutter SDK without a big learning curve.

  • Does not extend a special widget (ConsumerWidget)
  • Does not introduce implicit dependencies with watch methods.
  • No reason for code generation because it is simple enough
6 Likes

That’s exactly what any builder will do in Flutter already. By reading AsyncSnapshot<T> you already have something with waiting, error or value. You can do this with stream, futures, listenables (ChangeNotifier), etc.

Also, ValueListenable is bad. It allows changes from outside the class that declares it.

  • FutureBuilder or StreamBuilder requires expilicit Future or Stream. I don’t want to include calls to model from the view. This does not fit well with MVVM pattern.
  • The view should rebuild according data changes. This data is usually referred as state class but I don’t want the state class to include multiple data fields.
  • The ViewModel class can expose multiple ValueListenable fields. It does not have to extend ChangeNotifier. The widgets inside view only listens for the relevant data changes from the corresponding value listenables.

Also, ValueListenable is bad. It allows changes from outside the class that declares it.

ValueListenable does not allow changing outside. I guess you meant ValueNotifer. The ViewModel exposes listenables which view can only listen, but not modify.

1 Like

As already mentioned it resembles AsyncValue from Riverpod which allows you to easily wrap future in it.

I’m curious why fetchJoke is not run from inside the ValueNotifier which then would emit the state to widget?

Thanks for point out the documents. This is what the documents say:

/// A provider that asynchronously exposes the current user
final userProvider = StreamProvider<User>((_) async* {
  // fetch the user
});

class Example extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final AsyncValue<User> user = ref.watch(userProvider);

    return user.when(
      loading: () => CircularProgressIndicator(),
      error: (error, stack) => Text('Oops, something unexpected happened'),
      data: (user) => Text('Hello ${user.name}'),
    );
  }
}

vs

StatefulValueListenableBuilder<int>(
  valueListenable: valueNotifier,
  idleBuilder: (context, value) {
    return Text('Idle State. Value: $value');
  },
  loadingBuilder: (context, value) {
    return CircularProgressIndicator();
  },
  errorBuilder: (context, error, value) {
    return Text('Error: $error');
  },
);

As mentioned in the previous comment:

  • Although the idea is the same, the goal of the solution is to be as familiar as possible to SDK.
  • Using watch introduces implicit dependency which makes the widget testing difficult. My solution forces explicit dependency.
  • I would like to work with Stateless and Stateful component widgets only. ConsumerWidget is not part of the SDK.

Yeah riverpod is a distinct way of doing it ;). I probably wasn’t exactly clear. Basically your ValueState is the same as riverpod’s AsyncValue - it’s also a sealed class iirc.

Overall looks good :wink: I would probably name it more in a Loading/Data/Error fashion but that’s my preference.

Did you ever check out my flutter_command package?

It does combine your described functionality

Thanks for the input. The naming is very difficult.

idle vs data

  • We had internal discussions regarding data vs idle. I admit idle is not the best. If we choose data, then the difference between value and data would not be clear.

idle vs success

  • Discussed in this SO post. I believe the initial state should not be named as success

AsyncValue vs ValueState

  • I consider idle, error, loading as three distinct state of the value. Hence, ValueState sounds better if the idea is extending the ValueNotifier with State and naming it as StatefulValueNotifier.
  • If you don’t need state, using ValueNotifier would be enough which ican be considered as stateless value notifier in this context.
  • Furthermore, the value here does not necessarily need to be async.

Naming is important, and always ready to change my mind if a better name comes ups.

I just looked into the flutter_command package and it is my mistake for not hearing it before. It seems to follow a similar concept, but with more structured design pattern. One difference is that ValueState is sealed and what you can do with it is limited, whereas Command is not and can be much more powerful to be used in advanced scenarios. What you have provided with CommandBuilder makes this easier to use.

When designing internal APIs for our projects, our goal is to make them as beginner-friendly as possible. I’m not saying that the Command pattern is difficult to understand. It is actually quite smart and more powerful, but I would prefer to limit the API to be simple & familiar to SDK.

What did you mean by familiar to the sdk?
We use Commands together with watch_it so we rarely use the CommandBuilder anymore.
One killer feature turned out is the error hand3if commands. See my talk

1 Like

I like the idea of having StatefulValueNotifier as an abstraction of ValueNotifier. However, at first glance, the ValueState looks similar to riverpod’s AsyncValue.

In an offline-first scenario, how would we determine the refreshing state in ValueState? For example, showing cached data and when pulling to refresh, displaying the cached data along with a loading indicator at the bottom.

For this, AsyncValue has extensions isRefreshing and isReloading to identify this.

I like this idea, all frameworks are improving the way to deal with these these 3 states.
Angular added the new resource api, signalstore was added to ngrx and there is tanstack query.
I would use it if it’s internal to flutter sdk. :slightly_smiling_face:

I made that point today on Bluesky already. Flutter_command is based on the successful ReactiveUI on .Net
You naturally find similar solutions if they are practical

2 Likes

Signals in flutter can do this with a sealed class using AsyncState which is used most often with AsyncSignal but can be used in sync context without Stream/Future.

4 Likes