Flutter How to implement Bloc like pattern with Riverpod

eye-catch Dart and Flutter

I wrote the following post before.

This post explains how to apply Bloc like pattern by using flutter_bloc package. I guess Bloc pattern is widely used in Flutter applications to separate business logic from the UI part. Let’s call it as Riverpod pattern in this post.

It also needs to manage the state. flutter_bloc provides those states by cubit/bloc. flutter_bloc is not the only package that supports Flutter states. There are some other packages like Provider/Riverpod. In this post, I will try to use Riverpod for almost the same application. Let’s check the difference between Bloc and Riverpod.

The complete code is in my GitHub Repository.

In this post, I show only the different points from the one with flutter_bloc. Please go to my repository or another post above to check the implementation of some classes that are not shown here.

Sponsored links

Overview of Riverpod pattern

I drew the following diagram to understand how our example works with Riverpod.

The right side is the same as Bloc pattern. I can use the same code as Bloc pattern because it’s independent of the UI part.

The Riverpod instances can be defined as global constants. It means that it has less dependent as Bloc pattern. Top Provider has only one Repository instance. Two Riverpod StateNotifierProvider uses the same instance of Repository. Each StateNotifierProvider instance does something different and needs to keep the state individually.

When the state changes in the corresponding StateNotifierProvider, UI part gets the change notification. Then, it can rebuild the widget to reflect the data change to the UI.

Sponsored links

How to pass Riverpod class instances

A repository and Cubit/Bloc need to be defined in the root of the widgets where the data needs to be consumed. It means that the root widget has many widgets in it like the following.

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

  @override
  Widget build(BuildContext context) {
    return MultiRepositoryProvider(
        providers: [
          RepositoryProvider(
            create: (context) => SiteDataRepository(reader: SiteDataReader()),
          ),
          RepositoryProvider(
            create: (context) => SearchRepository(reader: SearchAPI()),
          ),
        ],
        child: MultiBlocProvider(
          child: MaterialApp(
            onGenerateRoute: ...,
          ),
          providers: [
            BlocProvider(
              create: (context) => TechnicalFeederCubit(
                RepositoryProvider.of<SiteDataRepository>(context),
                filename: FILENAME_TECHNICAL_FEEDER,
              ),
            ),
            BlocProvider(
              create: (context) => UnknownSiteCubit(
                RepositoryProvider.of<SiteDataRepository>(context),
                filename: FILENAME_UNKNOWN,
              ),
            ),
            BlocProvider(
              create: (context) => SearchBloc(
                context.read<SearchRepository>(),
              ),
            ),
          ],
        ));
  }
}

If the data is needed in another path or the parent widget, the definition must be moved to the other place.

On the other hand, the root widget has fewer widgets. The providers don’t have to be defined in the tree. What we have to do is to wrap the root widget with ProviderScope() to use Riverpod providers.

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:flutter_samples/bloc_pattern/presentation/riverpod_app_view1.dart';
import 'package:flutter_samples/bloc_pattern/presentation/riverpod_app_view2.dart';
import 'package:flutter_samples/main.dart';

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

  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      child: MaterialApp(
        onGenerateRoute: (settings) {
          switch (settings.name) {
            case "/":
              return MaterialPageRoute(
                builder: (_) => RiverpodAppView1(title: "first", color: Colors.pink),
              );
            case "/second":
              return MaterialPageRoute(
                builder: (_) => RiverpodAppView2(title: "second", color: Colors.red),
              );
            case "/home":
              return MaterialPageRoute(builder: (_) => MyApp());
            default:
              return null;
          }
        },
      ),
    );
  }
}

The provider instances are generally defined as global constants. Therefore, it’s not necessary to put the providers into widgets.

Data read implementation

What we want to achieve here is as follows.

It reads a file asynchronously. The data needs to be shared in another view.

We want to provide the current state of the file reading. When it starts to read a file, the state becomes loading. While the UI shows a loading widget, the file read should be processed in the background. When it completes reading the whole content, it provides the data.

Preparation of the files

The files contain the following content.

# lib/resources/technicalfeeder.json
{
    "id": 1,
    "url": "technicalfeeder.com",
    "author": "Yuto",
    "category": "Programming"
}

# lib/resources/unknown.json
{
    "id": 0,
    "url": "example.com",
    "author": "Unknown person",
    "category": "Undefined"
}

The target files have to be added to the artifacts. Add the following setting to pubspec.yaml.

flutter:
  # To add assets to your application, add an assets section, like this:
  assets:
    - lib/resources/

Define StateNotifier that provides State

Let’s check the Cubit implementation first. It provides a loading state at first and then processes the procedure.

abstract class SiteDataCubit extends Cubit<SiteDataState> {
  final SiteDataRepository _repo;
  final String filename;
  SiteDataCubit(this._repo, {required this.filename}) : super(SiteDataState(status: SiteDataStatus.initial));

  void read({bool isFail = false}) async {
    emit(SiteDataState(status: SiteDataStatus.loading));
    await Future.delayed(Duration(seconds: 1));
    if (isFail) {
      emit(SiteDataState(status: SiteDataStatus.failure));
      return;
    }

    final result = await _repo.readSiteData(filename);
    emit(SiteDataState(status: SiteDataStatus.success, siteData: result));
  }
}

It’s almost the same in Riverpod implementation. To provide the read method, the class extends StateNotifier with the State class.

import 'package:flutter_samples/bloc_pattern/site_data/site_data_repository.dart';
import 'package:flutter_samples/bloc_pattern/site_data/site_data_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Replacement of cubit in Bloc
class SiteDataRiverpodNotifier extends StateNotifier<SiteDataState> {
  final SiteDataRepository _repo;
  SiteDataRiverpodNotifier(this._repo) : super(SiteDataState(status: SiteDataStatus.initial));

  Future<void> read({required String filename, bool isFail = false}) async {
    state = SiteDataState(status: SiteDataStatus.loading);

    await Future.delayed(Duration(seconds: 1));
    if (isFail) {
      state = SiteDataState(status: SiteDataStatus.failure);
      return;
    }

    try {
      final result = await _repo.readSiteData(filename);
      state = SiteDataState(status: SiteDataStatus.success, siteData: result);
    } catch (e) {
      print(e);
      state = SiteDataState(status: SiteDataStatus.failure);
    }
  }
}

emit() function is called in Cubit implementation while a new State instance is assigned to state in Riverpod implementation. Both use a new instance in common. That’s an important point.

When a new instance is assigned to state property, the change notification is notified to the provider.

Define Provider

Providers have to be defined in the root widget in flutter_bloc but they can be defined as global constants in Riverpod.

final siteDataRepositoryProvider = Provider<SiteDataRepository>((ref) {
  final reader = SiteDataReader();
  return SiteDataRepository(reader: reader);
});

final tfSiteDataStateNotifierProvider = StateNotifierProvider<SiteDataRiverpodNotifier, SiteDataState>((ref) {
  final repository = ref.read(siteDataRepositoryProvider);
  return SiteDataRiverpodNotifier(repository);
});

final unknownSiteDataStateNotifierProvider = StateNotifierProvider<SiteDataRiverpodNotifier, SiteDataState>((ref) {
  final repository = ref.read(siteDataRepositoryProvider);
  return SiteDataRiverpodNotifier(repository);
});

There are two widgets to show the result. Please check the video above if you have not seen the app view. Those two results must be kept in a different state. Otherwise, one result affects to another widget. That’s why I defined tfSiteDataStateNotifierProvider and unknownSiteDataStateNotifierProvider that have the same implementation.

UI part

This is the basic implementation without Riverpod. Riverpod dependent code is written in the methods.

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_samples/bloc_pattern/search/search_state.dart';
import 'package:flutter_samples/bloc_pattern/site_data/site_data_riverpod_notifier.dart';
import 'package:flutter_samples/bloc_pattern/site_data/site_data_state.dart';
import 'package:flutter_samples/bloc_pattern/riverpod_definitions.dart';
import 'package:flutter_samples/custom_widgets/labeled_divider.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class RiverpodAppView1 extends HookConsumerWidget {
  final String title;
  final Color color;
  RiverpodAppView1({Key? key, required this.title, required this.color}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final scrollController = useScrollController();
    return SafeArea(
      child: Scaffold(
        appBar: AppBar(
          backgroundColor: color,
          title: Text("Riverpod pattern test: ${title}"),
        ),
        body: SingleChildScrollView(
          controller: scrollController,
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  ElevatedButton(
                    onPressed: () => Navigator.of(context).pushNamed('/home'),
                    child: Text("Go to Home"),
                    style: ElevatedButton.styleFrom(minimumSize: Size(100, 50)),
                  ),
                  ElevatedButton(
                    onPressed: () => Navigator.of(context).pushNamed('/second'),
                    child: Text("Go to Second"),
                    style: ElevatedButton.styleFrom(minimumSize: Size(100, 50)),
                  ),
                ],
              ),
              LabeledDivider("technicalfeeder.com"),
              generateRow(ref, tfSiteDataStateNotifierProvider, "technicalfeeder.json"),
              LabeledDivider("example.com"),
              generateRow(ref, unknownSiteDataStateNotifierProvider, "unknown.json"),

              // following part will be explained later
              LabeledDivider("Input Search Query (Controller.addListener)"),
              generateSearchBox1(ref),
              LabeledDivider("Input Search Query (onChanged)"),
              generateSearchBox2(ref),
              LabeledDivider("Input Search Query (Controller.addListener2)"),
              generateSearchBox3(ref),
            ],
          ),
        ),
      ),
    );
  }
}

We have to get the instance of Repository. It is contained in SiteDataRiverpodNotifier. It’s provided by tfSiteDataStateNotifierProvider and unknownSiteDataStateNotifierProvider. It can be accessed by ref.read(notifierProvider.notifier) and read method can be called with a dot chain.

Widget generateRow(
    WidgetRef ref, StateNotifierProvider<SiteDataRiverpodNotifier, SiteDataState> notifierProvider, String filename) {
  return Row(
    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
    children: [
      ElevatedButton(
        onPressed: () {
          ref.read(notifierProvider.notifier).read(filename: filename);
        },
        child: Text("Successful Trigger"),
      ),
      ElevatedButton(
        onPressed: () {
          ref.read(notifierProvider.notifier).read(filename: filename, isFail: true);
        },
        child: Text("Failing Trigger"),
      ),
      generateResultDisplayArea(notifierProvider),
    ],
  );
}

The state changes during the process of the read method. UI part needs to update the widget according to the current state. The state can be watched by ref.watch(notifierProvider).

Since we want to trigger rebuild only for the target widget, we should wrap the content with Consumer() widget. When the data changes, the Consumer widget is rebuilt, and the UI is updated depending on the state.

Widget generateResultDisplayArea(StateNotifierProvider<SiteDataRiverpodNotifier, SiteDataState> notifierProvider) {
  return Container(
    child: Consumer(
      builder: (context, ref, child) {
        final state = ref.watch(notifierProvider);
        switch (state.status) {
          case SiteDataStatus.initial:
            return SizedBox.shrink();
          case SiteDataStatus.failure:
            return Text("Failed to load");
          case SiteDataStatus.loading:
            return CircularProgressIndicator();
          case SiteDataStatus.success:
            return Text(state.siteData.toString());
          default:
            throw Exception("Unhandled SiteDataStatus: ${state.status}");
        }
      },
    ),
    height: 100,
    width: 200,
    decoration: BoxDecoration(
      border: Border.all(color: Colors.black, width: 1),
    ),
  );
}

Okay, that’s all for the file read part. I just copied the file and renamed it to RiverpodAppView2 to create a second view. The value is shared between the two views because the same Provider is used.

Real time search with debounce

The next part is to implement a real-time search feature with debounce.

Define StateNotifier that provides State

Let’s implement StateNotifier for SearchState. Let’s check the implementation for Bloc.

class SearchBloc extends Bloc<SearchQueryEvent, SearchState> {
  final SearchRepository _repository;
  SearchResult? _cache;

  SearchBloc(this._repository) : super(SearchStateEmpty()) {
    on<SearchQueryEvent>(
      _onTextChange,
      transformer: debounce(const Duration(milliseconds: 500)),
    );
  }

  Future<void> _onTextChange(SearchQueryEvent event, Emitter<SearchState> emit) async {
    if (event.query.isEmpty) {
      print("emptyr query");
      return emit(SearchStateEmpty());
    }

    print("query something");
    emit(SearchStateInProgress(_cache));

    try {
      final searchResult = await _repository.searchData(event.query);
      _cache = searchResult;
      emit(SearchStateCompleted(searchResult));
    } on SearchResultError catch (error) {
      emit(SearchStateError(error.toString()));
    }
  }

  @override
  void onChange(Change<SearchState> change) {
    super.onChange(change);
    print(change);
  }

  @override
  void onTransition(Transition<SearchQueryEvent, SearchState> transition) {
    super.onTransition(transition);
    print(transition);
  }
}

The implementation is almost the same. state is used instead of emit().

import 'package:flutter_samples/bloc_pattern/common.dart';
import 'package:flutter_samples/bloc_pattern/search/search_api.dart';
import 'package:flutter_samples/bloc_pattern/search/search_repository.dart';
import 'package:flutter_samples/bloc_pattern/search/search_result.dart';
import 'package:flutter_samples/bloc_pattern/search/search_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class SearchRiverpodNotifier extends StateNotifier<SearchState> {
  final SearchRepository _repository;
  SearchResult? _cache;
  final _debouncer = Debouncer(duration: Duration(milliseconds: 500));

  SearchRiverpodNotifier(this._repository) : super(SearchStateEmpty());

  Future<void> search(String query) async {
    if (query.isEmpty) {
      print("emptyr query");
      state = SearchStateEmpty();
      return;
    }

    print("query something");
    state = SearchStateInProgress(_cache);

    try {
      final searchResult = await _repository.searchData(query);
      _cache = searchResult;
      state = SearchStateCompleted(searchResult);
    } on SearchResultError catch (error) {
      state = SearchStateError(error.toString());
    }
  }

  void debouncedSearch(String query) {
    _debouncer.run(() {
      print("search starts");
      search(query);
    });
  }
}

I implemented two methods. It’s up to you where the debounce logic is placed. I think it can be placed in StateNotifier or Provider. It shouldn’t be in the UI part because it’s part of business logic.

Define Debouncer class

To handle debounce, we need the following class.

class Debouncer {
  final Duration duration;
  Timer? _timer;

  Debouncer({required this.duration});

  void run(VoidCallback action) {
    if (_timer != null) {
      print("Cancelled.");
      _timer!.cancel();
    }
    _timer = Timer(duration, action);
  }
}

If the method is called within the debounce time, it must cancel the previous timer and start the new timer.

Search box implementation

This is the common logic for search UI.

Widget generateSearchBox(WidgetRef ref, Widget textField, SearchProvider searchProvider) {
  return Row(
    mainAxisAlignment: MainAxisAlignment.spaceEvenly,
    children: [
      SizedBox(
        child: textField,
        width: 100,
        height: 50,
      ),
      Container(
        child: Consumer(
          builder: (context, ref, child) {
            final state = ref.watch(searchProvider);
            if (state is SearchStateEmpty) {
              return SizedBox.shrink();
            }
            if (state is SearchStateInProgress) {
              if (state.cache == null) {
                return SizedBox.shrink();
              } else {
                return Text(state.cache.toString());
              }
            }
            if (state is SearchStateCompleted) {
              return Text(state.data.toString());
            }
            if (state is SearchStateError) {
              return Text(state.error);
            }
            return Text("input your query");
          },
        ),
        height: 100,
        width: 200,
        decoration: BoxDecoration(
          border: Border.all(color: Colors.black, width: 1),
        ),
      ),
      Container(
        child: Consumer(
          builder: (context, ref, child) {
            final state = ref.watch(searchProvider);
            return Text(state.toString());
          },
        ),
        height: 100,
        width: 200,
        decoration: BoxDecoration(
          border: Border.all(color: Colors.black, width: 1),
        ),
      ),
    ],
  );
}

Based on this code, we will try 3 different implementations with Riverpod. The difference is TextField.

We define the following 3 providers.

typedef SearchProvider = StateNotifierProvider<SearchRiverpodNotifier, SearchState>;
final searchProvider1 = StateNotifierProvider<SearchRiverpodNotifier, SearchState>((ref) {
  final repository = ref.read(searchRepositoryProvider);
  return SearchRiverpodNotifier(repository);
});
final searchProvider2 = StateNotifierProvider<SearchRiverpodNotifier, SearchState>((ref) {
  final repository = ref.read(searchRepositoryProvider);
  return SearchRiverpodNotifier(repository);
});
final searchProvider3 = StateNotifierProvider<SearchRiverpodNotifier, SearchState>((ref) {
  final repository = ref.read(searchRepositoryProvider);
  return SearchRiverpodNotifier(repository);
});

Let’s try the 3 ways.

TextField with TextEditingController

The first one is to use TextEditingController. We have to call debouncedSearch method to update the data when the search query changes. So the listener needs to be registered by addListener(). By the way, flutter_hooks package is used to instantiate TextEditingController.

Widget generateSearchBox1(WidgetRef ref) {
  final queryController = useTextEditingController();
  useEffect(() {
    final searchApi = ref.read(searchProvider1.notifier);
    queryController.addListener(() => searchApi.debouncedSearch(queryController.text));
    // return queryController.dispose;
  });
  final textField = TextField(controller: queryController);

  return generateSearchBox(ref, textField, searchProvider1);
}

I commented out the return statement because it throws an error when the window size changes. It can be written if the application is for mobile.

The result is as follows.

It looks working but it has a bug there. The search method is called even though the query string doesn’t change. It seems that the listener is called when the cursor moves. It somehow needs to be fixed.

The current query string is not shared in another view in this way.

TextField with onChanged callback

Let’s define onChanged callback to fix the problem above. It’s called if the text changes.

Widget generateSearchBox2(WidgetRef ref) {
  final textField = TextField(
    onChanged: (value) {
      print("onChanged");
      final searchApi = ref.read(searchProvider2.notifier);
      searchApi.debouncedSearch(value);
    },
  );
  return generateSearchBox(ref, textField, searchProvider2);
}

The callback is not called when the cursor moves. It’s called only when the query string changes. The result is shared between the two views but the query string is not shared.

If it’s not necessary to share the query string, this way might be better than using a controller.

TextField with controller provided by a Riverpod Provider

The last way is to provide a TextEditingController by Riverpod Provider. When a controller is provided by a Provider, the text string can be shared with another view.

The implementation is as follows. It’s simple for the UI side.

Widget generateSearchBox3(WidgetRef ref) {
  final queryController = ref.watch(queryControllerProvider);
  final textField = TextField(controller: queryController);
  return generateSearchBox(ref, textField, searchProvider3);
}

The Provider side is not as simple as the UI side but it just defines the controller’s behavior. The difference from the first solution is to compare the current query string with the previous string.

final queryControllerProvider = Provider<TextEditingController>((ref) {
  final searchApi = ref.read(searchProvider3.notifier);
  final controller = TextEditingController();
  final debouncer = Debouncer(duration: Duration(milliseconds: 500));

  String lastValue = "";
  controller.addListener(() async {
    if (lastValue == controller.text) {
      return;
    }
    lastValue = controller.text;
    debouncer.run(() => searchApi.search(controller.text));
  });

  ref.onDispose(() {
    controller.dispose();
  });
  return controller;
});

The search method is called only when the query string changes. The query string is shared in another view in this way.

The query text is not cleared automatically because the controller is used by ref.watch. If you want to clear it when a user leaves the screen, you somehow need to clear the text.

Overview

I like Riverpod better than using flutter_bloc because Provider can be defined as global constants. We don’t have to add Provider related classes to the UI part.

Comments

Copied title and URL