Exploring Riverpod for Compile-Safe State in Flutter

Flutter, Google’s UI toolkit, has gained massive popularity for building beautiful and performant applications across multiple platforms. As Flutter apps grow in complexity, managing state effectively becomes critical. While there are several state management solutions available, Riverpod stands out as a compile-safe, predictable, and testable alternative to Provider. In this blog post, we’ll dive into Riverpod, exploring its features, benefits, and implementation with comprehensive code examples.

What is Riverpod?

Riverpod is a reactive caching and data-binding framework and is a complete rewrite of Provider. It is designed to be safer, more flexible, and easier to use. Key features of Riverpod include:

  • Compile-Safe: Catches errors during compilation rather than at runtime.
  • Testable: Simplifies testing with overrides and test scopes.
  • Predictable: Follows a strict unidirectional data flow, enhancing predictability.
  • Composable: Allows easy composition and combination of state providers.
  • Multi-Platform: Supports multiple platforms, including web, desktop, and mobile.

Why Choose Riverpod?

Riverpod addresses several common issues found in other state management solutions, providing benefits such as:

  • Safety: Eliminates context-related issues, improving reliability.
  • Clarity: Uses providers as a single source of truth, reducing complexity.
  • Extensibility: Simplifies integration with existing and new widgets, promoting reusability.

Getting Started with Riverpod

Step 1: Add Riverpod Dependencies

Include Riverpod in your pubspec.yaml file:


dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.4.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  riverpod_generator: ^2.3.0
  build_runner: ^2.4.6

Run flutter pub get to install the dependencies.

Step 2: Wrap Your App with ProviderScope

Ensure that your application is wrapped with ProviderScope, which makes providers available throughout your app:


import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(
    // Wrapping the entire app with "ProviderScope"
    // enables widgets to read providers.
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Riverpod Example')),
        body: const MyHomePage(),
      ),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('Hello Riverpod'),
    );
  }
}

Basic Provider Example

Step 1: Define a Provider

Create a simple provider to hold a value:


import 'package:flutter_riverpod/flutter_riverpod.dart';

// A simple provider that returns a String value.
final helloWorldProvider = Provider((_) => 'Hello world');

Step 2: Consume the Provider in a Widget

Use Consumer or ConsumerWidget to read and display the provider’s value:


import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Define the provider
final helloWorldProvider = Provider((_) => 'Hello world');

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final helloWorldValue = ref.watch(helloWorldProvider);

    return Center(
      child: Text(helloWorldValue),
    );
  }
}

In this example:

  • helloWorldProvider is defined using Provider.
  • MyHomePage extends ConsumerWidget to easily access providers.
  • ref.watch(helloWorldProvider) retrieves the value of helloWorldProvider.

StateProvider Example

Step 1: Define a StateProvider

Create a StateProvider to manage simple, mutable state:


import 'package:flutter_riverpod/flutter_riverpod.dart';

// A StateProvider that holds an integer and can be modified.
final counterProvider = StateProvider((ref) => 0);

Step 2: Update the State in a Widget

Use ref.read(counterProvider.notifier).state++ to update the state when a button is pressed:


import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Define the provider
final counterProvider = StateProvider((ref) => 0);

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counterValue = ref.watch(counterProvider);

    return Scaffold(
      body: Center(
        child: Text('Counter value: $counterValue'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // Increment the counter state using .notifier
          ref.read(counterProvider.notifier).state++;
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

In this example:

  • counterProvider is defined using StateProvider with an initial value of 0.
  • Clicking the FloatingActionButton increments the state using ref.read(counterProvider.notifier).state++.

FutureProvider Example

Step 1: Define a FutureProvider

Use FutureProvider to handle asynchronous data fetching:


import 'package:flutter_riverpod/flutter_riverpod.dart';

// A FutureProvider that fetches data asynchronously.
final dataProvider = FutureProvider((ref) async {
  // Simulate fetching data from an API
  await Future.delayed(const Duration(seconds: 2));
  return 'Fetched data';
});

Step 2: Consume the FutureProvider in a Widget

Handle the state of the future with AsyncValue:


import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Define the provider
final dataProvider = FutureProvider((ref) async {
  // Simulate fetching data from an API
  await Future.delayed(const Duration(seconds: 2));
  return 'Fetched data';
});

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final data = ref.watch(dataProvider);

    return Scaffold(
      body: Center(
        child: data.when(
          data: (value) => Text('Data: $value'),
          loading: () => const CircularProgressIndicator(),
          error: (error, stack) => Text('Error: $error'),
        ),
      ),
    );
  }
}

In this example:

  • dataProvider uses FutureProvider to simulate fetching data from an API.
  • The .when method handles different states: data, loading, and error.

StreamProvider Example

Step 1: Define a StreamProvider

Use StreamProvider to listen to streams and update the UI:


import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dart:async';

// A StreamProvider that emits values every second.
final clockProvider = StreamProvider((ref) {
  return Stream.periodic(const Duration(seconds: 1), (count) => DateTime.now());
});

Step 2: Consume the StreamProvider in a Widget

Display the current time using AsyncValue:


import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart'; // Import intl package for date formatting

// Define the provider
final clockProvider = StreamProvider((ref) {
  return Stream.periodic(const Duration(seconds: 1), (count) => DateTime.now());
});

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final time = ref.watch(clockProvider);

    return Scaffold(
      body: Center(
        child: time.when(
          data: (dateTime) => Text('Current time: ${DateFormat('HH:mm:ss').format(dateTime)}'), // Format the date
          loading: () => const CircularProgressIndicator(),
          error: (error, stack) => Text('Error: $error'),
        ),
      ),
    );
  }
}

In this example:

  • clockProvider emits the current time every second using a Stream.
  • The UI updates dynamically with the new time using the .when method to handle different states.

Provider with Parameters (Family)

Family allows you to create providers that depend on external parameters. It’s beneficial when dealing with lists, where each item needs its own unique state.


import 'package:flutter_riverpod/flutter_riverpod.dart';

// A provider family that returns a personalized greeting.
final greetingProvider = Provider.family((ref, name) {
  return 'Hello, $name!';
});

Then, you can use this provider like this:


import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Define the provider
final greetingProvider = Provider.family((ref, name) {
  return 'Hello, $name!';
});

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final greeting = ref.watch(greetingProvider('Riverpod User'));

    return Center(
      child: Text(greeting),
    );
  }
}

Here:

  • greetingProvider uses Provider.family to take a name as a parameter.
  • The widget then consumes this provider, passing ‘Riverpod User’ as the name.

Using Riverpod with StateNotifierProvider

Step 1: Create a StateNotifier

Implement a StateNotifier to manage more complex state logic:


import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/foundation.dart'; // Import foundation for required keyword

// A simple data class for our state
@immutable
class CounterState {
  const CounterState({required this.count});

  final int count;

  // Methods to help update the state
  CounterState copyWith({int? count}) {
    return CounterState(
      count: count ?? this.count,
    );
  }
}

// The StateNotifier managing the counter state
class CounterNotifier extends StateNotifier {
  CounterNotifier() : super(const CounterState(count: 0)); // Initial state

  // Method to increment the counter
  void increment() {
    state = state.copyWith(count: state.count + 1);
  }
}

Step 2: Define a StateNotifierProvider

Associate the StateNotifier with a StateNotifierProvider:


import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/foundation.dart'; // Import foundation for required keyword

// A simple data class for our state
@immutable
class CounterState {
  const CounterState({required this.count});

  final int count;

  // Methods to help update the state
  CounterState copyWith({int? count}) {
    return CounterState(
      count: count ?? this.count,
    );
  }
}

// The StateNotifier managing the counter state
class CounterNotifier extends StateNotifier {
  CounterNotifier() : super(const CounterState(count: 0)); // Initial state

  // Method to increment the counter
  void increment() {
    state = state.copyWith(count: state.count + 1);
  }
}

// Define a StateNotifierProvider that creates and exposes the CounterNotifier
final counterNotifierProvider = StateNotifierProvider((ref) {
  return CounterNotifier();
});

Step 3: Consume the StateNotifierProvider in a Widget


import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/foundation.dart'; // Import foundation for required keyword

// A simple data class for our state
@immutable
class CounterState {
  const CounterState({required this.count});

  final int count;

  // Methods to help update the state
  CounterState copyWith({int? count}) {
    return CounterState(
      count: count ?? this.count,
    );
  }
}

// The StateNotifier managing the counter state
class CounterNotifier extends StateNotifier {
  CounterNotifier() : super(const CounterState(count: 0)); // Initial state

  // Method to increment the counter
  void increment() {
    state = state.copyWith(count: state.count + 1);
  }
}

// Define a StateNotifierProvider that creates and exposes the CounterNotifier
final counterNotifierProvider = StateNotifierProvider((ref) {
  return CounterNotifier();
});

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counterState = ref.watch(counterNotifierProvider);

    return Scaffold(
      body: Center(
        child: Text('Counter value: ${counterState.count}'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // Access and call the increment method from the CounterNotifier
          ref.read(counterNotifierProvider.notifier).increment();
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

In this setup:

  • CounterState represents the state using an immutable data class.
  • CounterNotifier manages the state and provides methods to update it.
  • The widget rebuilds every time the state changes and calls the increment method.

Conclusion

Riverpod provides a compile-safe, testable, and composable solution for managing state in Flutter applications. By embracing Riverpod, you can avoid common issues found in traditional state management approaches and build robust, predictable applications. The comprehensive examples covered—from basic providers to complex StateNotifier integrations—offer a solid foundation for leveraging Riverpod effectively in your Flutter projects.