10  comments

Riverpod is not only about providing objects around the app. It comes bundled and closely integrated with StateNotifier which is a state management class. It resembles the default Flutter's ValueNotifier or even Cubit from the Bloc package without the underlying streams. This kind of immutable state management is great for keeping unpleasant surprises at bay. 

Building an app

It has become a habit on Reso Coder to demonstrate different state management solutions by building a simple weather forecast app. Grab the starter project to follow along without writing any unnecessary code. Also, this tutorial assumes that you understand the basics of working with Riverpod.

The app displays a randomly generated temperature in the given city which allows us to demonstrate asynchronous fetching of data. We'll also show a loading indicator while awaiting a Future and an error snackbar whenever an exception is thrown while fetching the forecast.

Let's now quickly take a look at the implemented classes from the starter project. If you haven't seen the simple weather forecast app in some of my previous tutorials, you should know that it's centered around a model class called Weather.

weather.dart

class Weather {
  final String cityName;
  final double temperatureCelsius;

  Weather({
    @required this.cityName,
    @required this.temperatureCelsius,
  });

  // == and hashCode overrides...
}

This weather will contain a randomly generated temperature gotten from a FakeWeatherRepository.  There's also a chance that a NetworkException will be thrown instead.

weather_repository.dart

abstract class WeatherRepository {
  Future<Weather> fetchWeather(String cityName);
}

class FakeWeatherRepository implements WeatherRepository {
  double cachedTempCelsius;

  @override
  Future<Weather> fetchWeather(String cityName) {
    // Simulate network delay
    return Future.delayed(
      Duration(seconds: 1),
      () {
        final random = Random();

        // Simulate some network exception
        if (random.nextBool()) {
          throw NetworkException();
        }

        // Since we're inside a fake repository, we need to cache the temperature
        // in order to have the same one returned in for the detailed weather
        cachedTempCelsius = 20 + random.nextInt(15) + random.nextDouble();

        // Return "fetched" weather
        return Weather(
          cityName: cityName,
          // Temperature between 20 and 35.99
          temperatureCelsius: cachedTempCelsius,
        );
      },
    );
  }
}

class NetworkException implements Exception {}

Adding dependencies

Although state_notifier exists as a package on its own, riverpod comes bundled with it, so we need to have only a single dependency.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^0.12.1

Introduction to StateNotifier

The shortest explanation of a StateNotifier is that it's a Flutter-independent ValueNotifier - a sibling of the mutable ChangeNotifier. If you have any experience with immutable state, you're going to feel right at home. If not, please, check out a separate tutorial about its core principles.

If you're familiar with the default Flutter ValueNotifier, you can basically skip this section. The whole StateNotifier revolves around one property called state. This property can be listened to from the widgets using Riverpod Provider classes. Whenever the state property is set to a new value, all of its listeners are notified. In Flutter, this means that widgets are rebuilt. Neat!

A very simple StateNotifier operating with a single int as its state would look like this:

counter_notifier.dart

class CounterNotifier extends StateNotifier<int> {
  // The value passed into the super constructor is the initial state, in this case, zero.
  CounterNotifier() : super(0);
  
  void increment() {
    // Reassigning state
    // Could also be written as `state = state + 1;`.
    // Notifies all listeners about the state change.
    state++;
  }
}

A more complex state

In the weather forecast app we're building, having a single int for a state just doesn't cut it. Whenever a state is complex, for example, consisting of multiple subclasses or a freezed union, you should write code for it first and only then worry about implementing the actual StateNotifier.

We're going to be asynchronously loading a single resource - the Weather model. In such occasions, it's best to represent the state as multiple subclasses of a WeatherState abstract class.

We're using subclasses to keep the code focused on StateNotifier and Riverpod. Consider using freezed unions in your projects.

In order to keep the code that relates to the StateNotifier concise,  let's put all of the states into application/weather_notifier.dart. All of the classes override their equality and hash code.

weather_notifier.dart

abstract class WeatherState {
  const WeatherState();
}

class WeatherInitial extends WeatherState {
  const WeatherInitial();
}

class WeatherLoading extends WeatherState {
  const WeatherLoading();
}

class WeatherLoaded extends WeatherState {
  final Weather weather;
  const WeatherLoaded(this.weather);

  @override
  bool operator ==(Object o) {
    if (identical(this, o)) return true;

    return o is WeatherLoaded && o.weather == weather;
  }

  @override
  int get hashCode => weather.hashCode;
}

class WeatherError extends WeatherState {
  final String message;
  const WeatherError(this.message);

  @override
  bool operator ==(Object o) {
    if (identical(this, o)) return true;

    return o is WeatherError && o.message == message;
  }

  @override
  int get hashCode => message.hashCode;
}

With the state classes written, we can now move to implementing the WeatherNotifier which will be responsible for what I call the application logic - getting data from the already implemented WeatherRepository and then updating the state field, so that the UI can reflect the latest data.

weather_notifier.dart

class WeatherNotifier extends StateNotifier<WeatherState> {
  final WeatherRepository _weatherRepository;

  WeatherNotifier(this._weatherRepository) : super(WeatherInitial());

  Future<void> getWeather(String cityName) async {
    try {
      state = WeatherLoading();
      final weather = await _weatherRepository.fetchWeather(cityName);
      state = WeatherLoaded(weather);
    } on NetworkException {
      state = WeatherError("Couldn't fetch weather. Is the device online?");
    }
  }
}

The initial state that is passed to the super constructor is unsurprisingly WeatherInitial. The logic inside of the getWeather method is quite self-explanatory.

Providers

Up to this point, we've been working only with the StateNotifier class. Let's now utilize its close integration with a special Riverpod's StateNotifierProvider in order to nicely rebuild the widgets whenever a new state is set.

providers.dart

final weatherRepositoryProvider = Provider<WeatherRepository>(
  (ref) => FakeWeatherRepository(),
);

final weatherNotifierProvider = StateNotifierProvider(
  (ref) => WeatherNotifier(ref.watch(weatherRepositoryProvider)),
);

User Interface

As with any project using Riverpod, we first need to wrap the whole app in a ProviderScope.

main.dart

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      child: MaterialApp(
        title: 'Weather Search',
        home: WeatherSearchPage(),
      ),
    );
  }
}

As you could have seen in the video at the beginning of this article, the app will either show only a city search bar for initial and error states, a progress indicator for the loading state, and lastly, the temperature and city name for the loaded state. Additionally, we want to show a SnackBar if an error occurs.

The widgets representing each state are already prepared in the starter project. We just need to connect them up using a Consumer.

Notice the highlighted code where we're not watching the weatherNotifierProvider but instead a weatherNotifierProvider.state. This is a special provider bundled inside of the StateNotifierProvider that allows us to easily rebuild the widget tree when a new state is set.

weather_search_page.dart

class WeatherSearchPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Weather Search"),
      ),
      body: Container(
        padding: EdgeInsets.symmetric(vertical: 16),
        alignment: Alignment.center,
        child: Consumer(
          builder: (context, watch, child) {
            final state = watch(weatherNotifierProvider.state);
            if (state is WeatherInitial) {
              return buildInitialInput();
            } else if (state is WeatherLoading) {
              return buildLoading();
            } else if (state is WeatherLoaded) {
              return buildColumnWithData(state.weather);
            } else {
              // (state is WeatherError)
              return buildInitialInput();
            }
          },
        ),
      ),
    );
  }

  // The build* methods are here...
}

We're still not showing the SnackBar anywhere. But where should we put that code?

Anything that should be run only once when the state is updated has no place inside of the build method directly as that can run many times over. This applies to navigating, showing SnackBars and performing any side effects.

As if that wasn't enough, showing a SnackBar directly from a build method will result in the setState() or markNeedsBuild() called during build error message.

That's why there's a widget that allows you to listen to a provider outside of the build method. Let's wrap the Consumer with it.

weather_search_page.dart

ProviderListener<WeatherState>(
  provider: weatherNotifierProvider.state,
  onChange: (context, state) {
    if (state is WeatherError) {
      Scaffold.of(context).showSnackBar(
        SnackBar(
          content: Text(state.message),
        ),
      );
    }
  },
  child: Consumer(
    builder: (context, watch, child) {
      // Previously written code here...
    },
  ),
),

That's not all though! We're still not calling the getWeather method from anywhere, so let's do so vrom the very bottom of the weather_search_page.dart file in the CityInputField.

weather_search_page.dart

class CityInputField extends StatelessWidget {
  // Code here...

  void submitCityName(BuildContext context, String cityName) {
    context.read(weatherNotifierProvider).getWeather(cityName);
  }
}

Whereas we specify weatherNotifierProvider.state for listening to state changes, when we want to call methods on our notifier, we read only the weatherNotifierProvider itself.

And there you go! You've just finished a real-worldish app using the Riverpod + StateNotifier combo for state management. It's effective, immutable and clean. What more can you wish for?

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter freelancer and most importantly developer educator, he doesn't have a lot of free time 😅 Yet he still manages to squeeze in tough workouts 💪

You may also like

Flutter SVG Animations With Rive

Flutter SVG Animations With Rive
  • Hello, Thank you for the excellent, state-of-the-art tutorials.

    I have a question that perhaps is aimed at state management with StateNotifier:

    How do you handle editable states? For example, what if the weather, after being loaded, was user editable? How would you set this up in your State and Notifier classes?

    Eg: Let’s say you had an Edit Weather button that calls an editWeather(double newWeather) method. Where would the implementation of this method happen? How can you edit the Weather property of the state, if the state is of type WeatherState which may or may not have a Weather object depending on the current State?

  • Great tutorial. But I’m a bit confused on where you should initialize data of the widget (future, like getting current UID or fetching some data from firestore). I am doing it wrong in the buildInitial() since it gives me a “dirty state (parent/child)”.
    Where should one fetch initial data of a widget?

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >