Riverpod: List provider is not rebuilding - flutter

Flutter riverpod is not notifying the Consumer on the state change when the StateNotifier's type is List, while the same implementation works just fine for other types.
here, I provided a minimal reproducable example:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
#override
Widget build(BuildContext context) {
return ProviderScope(
child: MaterialApp(
home: MyHomePage(),
),
);
}
}
class CounterState extends StateNotifier<List<int>> {
static final provider = StateProvider(
(ref) => CounterState(),
);
int get last {
print('last');
return state.last;
}
int get length {
print('len');
return state.length;
}
// the body of this will be provided below
add(int p) {}
CounterState() : super(<int>[0]);
}
class MyHomePage extends ConsumerWidget {
#override
Widget build(BuildContext context, watch) {
void _incrementCounter() {
final _count = Random.secure().nextInt(100);
context.read(CounterState.provider.notifier).state.add(_count);
}
var count = watch(CounterState.provider.notifier).state.length;
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(
'You have pushed the button this many times: $count',
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
child: Icon(Icons.add),
),
);
}
}
as for the add method, I tried implementing it in a lot of ways, but neither works.
here is what I tried:
1: just add it straight away:
add(int p) {
state.add(p);
}
2: I also tried the solution suggested in this answer:
add(int p) {
state = [...state, p];
}
3: I tried to destroy the list entirely, and reassign it:
add(int p) {
final _state = [];
// copy [state] to [_state]
for (var item in state) {
_state.add(item);
}
// empty the state
state = [];
// add the new element
_state.add(p);
// refill [state] from [_state]
for (var item in _state) {
state.add(item);
}
print(state.length); // it continues until here and prints
}

Firstly, you are not creating the correct provider to listen to a StateNotifier. You need to change this:
static final provider = StateProvider(
(ref) => CounterState(),
);
to this:
static final provider = StateNotifierProvider<CounterState, List<int>>(
(ref) => CounterState(),
);
Please refer to the Riverpod documentation about the different types of providers.
Secondly, you are not actually watching for state changes, but you are just getting the state object from the notifier.
Change this line:
var count = watch(CounterState.provider.notifier).state.length;
to this:
final count = watch(CounterState.provider).length;
also, your increment method is not correct for StateNotifier providers. Please change this:
context.read(CounterState.provider.notifier).state.add(_count);
to this:
context.read(CounterState.provider.notifier).add(_count);
It should rebuild now when the state changes. However, you do need an implementation of your add method that actually changes the state object itself. I would suggest the second variant you mentioned, that is in my opinion the nicest way to do this:
add(int p) {
state = [...state, p];
}

#TmKVU explained well, so I'm skipping that part. You can also follow riverpod document.
here is my example of riverPod:
stateNotifierProvider
stateProvider
Your widget
import 'dart:math';
import 'package:stack_overflow/exports.dart';
class CounterState extends StateNotifier<List<int>> {
static final provider = StateNotifierProvider(
(ref) => CounterState(),
);
int get last {
print('last');
return state.last;
}
int get length {
print('len');
return state.length;
}
// the body of this will be provided below
add(int p) {}
CounterState() : super(<int>[0]);
}
class MyHomePageSSSS extends ConsumerWidget {
#override
Widget build(BuildContext context, watch) {
void _incrementCounter() {
final _count = Random.secure().nextInt(100);
context.read(CounterState.provider.notifier).state =
context.read(CounterState.provider.notifier).state..add(_count);
}
final countprovider = watch(CounterState.provider);
return Scaffold(
appBar: AppBar(),
body: Center(
child: Text(
'You have pushed the button this many times: ${countprovider.length}',
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
child: Icon(Icons.add),
),
);
}
}

Related

Flutter awesome notifications how to fix StateError (Bad state: Stream has already been listened to.)

I am getting this error when I have signed out from my flutter app and trying to log in again:
StateError (Bad state: Stream has already been listened to.)
The code that gives me this error is on my first page:
#override
void initState() {
AwesomeNotifications().actionStream.listen((notification) async {
if (notification.channelKey == 'scheduled_channel') {
var payload = notification.payload['payload'];
var value = await FirebaseFirestore.instance
.collection(widget.user.uid)
.doc(payload)
.get();
navigatorKey.currentState.push(PageRouteBuilder(
pageBuilder: (_, __, ___) => DetailPage(
user: widget.user,
i: 0,
docname: payload,
color: value.data()['color'].toString(),
createdDate: int.parse((value.data()['date'].toString())),
documentId: value.data()['documentId'].toString(),)));
}
});
super.initState();
}
And on another page that contains the sign out code.
await FirebaseAuth.instance.signOut();
if (!mounted) return;
Navigator.pushNamedAndRemoveUntil(context,
"/login", (Route<dynamic> route) => false);
What can I do to solve this? Is it possible to stop listen to actionstream when I log out? Or should I do it in another way?
Streams over all are single use, they replace the callback hell that that ui is, at first a single use streams can seem useless but that may be for a lack of foresight. Over all (at lest for me) flutter provides all the necessary widgets to not get messy with streams, you can find them in the Implementers section of ChangeNotifier and all of those implement others like TextEditingController.
With that, an ideal (again, at least for me) is to treat widgets as clusters where streams just tie them in a use case, for example, the widget StreamBuilder is designed to build on demand so it only needs something that pumps changes to make a "live object" like in a clock, a periodic function adds a new value to the stream and the widget just needs to listen and update.
To fix your problem you can make .actionStream fit the case you are using it or change a bit how are you using it (having a monkey patch is not good but you decide if it is worth it).
This example is not exactly a "this is what is wrong, fix it", it is more to showcase a use of how pushNamedAndRemoveUntil and StreamSubscription can get implemented. I also used a InheritedWidget just because is so useful in this cases. One thing you should check a bit more is that the variable count does not stop incrementing when route_a is not in focus, the stream is independent and it will be alive as long as the widget is, which in your case, rebuilding the listening widget is the error.
import 'dart:async';
import 'package:flutter/material.dart';
void main() => runApp(App());
const String route_a = '/route_a';
const String route_b = '/route_b';
const String route_c = '/route_c';
class App extends StatelessWidget {
Stream<int> gen_nums() async* {
while (true) {
await Future.delayed(Duration(seconds: 1));
yield 1;
}
}
#override
Widget build(BuildContext ctx) {
return ReachableData(
child: MaterialApp(
initialRoute: route_a,
routes: <String, WidgetBuilder>{
route_a: (_) => Something(stream: gen_nums()),
route_b: (_) => FillerRoute(),
route_c: (_) => SetMount(),
},
),
);
}
}
class ReachableData extends InheritedWidget {
final data = ReachableDataState();
ReachableData({super.key, required super.child});
static ReachableData of(BuildContext ctx) {
final result = ctx.dependOnInheritedWidgetOfExactType<ReachableData>();
assert(result != null, 'Context error');
return result!;
}
#override
bool updateShouldNotify(ReachableData old) => false;
}
class ReachableDataState {
String? mount;
}
// route a
class Something extends StatefulWidget {
// If this widget needs to be disposed then use the other
// constructor and this call in the routes:
// Something(subscription: gen_nums().listen(null)),
// final StreamSubscription<int> subscription;
// Something({required this.subscription, super.key});
final Stream<int> stream;
Something({required this.stream, super.key});
#override
State<Something> createState() => _Something();
}
class _Something extends State<Something> {
int count = 0;
void increment_by(int i) => setState(
() => count += i,
);
#override
void initState() {
super.initState();
widget.stream.listen(increment_by);
// To avoid any funny errors you should set the subscription
// on pause or the callback to null on dispose
// widget.subscription.onData(increment_by);
}
#override
Widget build(BuildContext ctx) {
var mount = ReachableData.of(ctx).data.mount ?? 'No mount';
return Scaffold(
body: InkWell(
child: Text('[$count] Push Other / $mount'),
onTap: () {
ReachableData.of(ctx).data.mount = null;
Navigator.of(ctx).pushNamed(route_b);
},
),
);
}
}
// route b
class FillerRoute extends StatelessWidget {
const FillerRoute({super.key});
#override
Widget build(BuildContext ctx) {
return Scaffold(
body: InkWell(
child: Text('Go next'),
// Option 1: go to the next route
// onTap: () => Navigator.of(ctx).pushNamed(route_c),
// Option 2: go to the next route and extend the pop
onTap: () => Navigator.of(ctx)
.pushNamedAndRemoveUntil(route_c, ModalRoute.withName(route_a)),
),
);
}
}
// route c
class SetMount extends StatelessWidget {
const SetMount({super.key});
#override
Widget build(BuildContext ctx) {
return Scaffold(
body: InkWell(
child: Text('Set Mount'),
onTap: () {
ReachableData.of(ctx).data.mount = 'Mounted';
// Option 1: pop untill reaches the correct route
// Navigator.of(ctx).popUntil(ModalRoute.withName(route_a));
// Option 2: a regular pop
Navigator.of(ctx).pop();
},
),
);
}
}

Widget not updating flutter

I'm trying to change the variable from another stateful class.
class first extends statefulwidget {
bool text = false;
Widget build(BuildContext context) {
setState((){});
return Container(
child: text ? Text('Hello') : Text('check')
);
}
}
class second extends statefulwidget {
Widget build(BuildContext context) {
return Container(
child: IconButton(
onPressed: () {
first fir = first();
setState((){
fir.test = true;
});
}
)
);
}
}
widget shows only check not showing Hello
This is my code...Ignore spelling mistakes and camelcase
Give me the solutions if you know..
If you are trying to access data on multiple screens, the Provider package could help you. It stores global data accessible from all classes, without the need of creating constructors. It's good for big apps.
Here are some steps to use it (there is also a lot of info online):
Import provider in pubspec.yaml
Create your provider.dart file. For example:
class HeroInfo with ChangeNotifier{
String _hero = 'Ironman'
get hero {
return _hero;
}
set hero (String heroName) {
_hero = heroName;
notifyListeners();
}
}
Wrap your MaterialApp (probably on main.dart) with ChangeNotifierProvider.
return ChangeNotifierProvider(
builder: (context) => HeroInfo(),
child: MaterialApp(...),
);
Use it on your application! Call the provider inside any build method and get data:
#override
Widget build(BuildContext context){
final heroProvider = Provider.of<HeroInfo>(context);
return Column {
children: [
Text(heroProvider.hero)
]
}
}
Or set data:
heroProvider.hero = 'Superman';
try to reference to this answer, create function to set boolean in class1 and pass as parameter to class 2 and execute it :
typedef void MyCallback(int foo);
class MyClass {
void doSomething(int i){
}
MyOtherClass myOtherClass = new MyOtherClass(doSomething);
}
class MyOtherClass {
final MyCallback callback;
MyOtherClass(this.callback);
}

flutter bloc library percentage progress bar

How can a bloc show percentage progress bar
//For example, there is a regular bloc
#override
Stream<JobState> mapEventToState(JobEvent event) async* {
if (event is HardJobEvent) {
yield* _mapHardJobToState();
}
}
Stream<UpdateState> _mapHardJobToState() async* {
try {
//It is necessary to display a progress bar for this method.
await doSomeHardJob();
} catch (e) {
print(e);
}
}
doSomeHardJob() async* {
for( var i = 1 ; i < 1000; i++ ) {
//This yield does not work. Doesn't display any errors
//State not transfer
yield HardJob(nowCounter: i);
}
}
I use cubit instead of bloc. but the technique should be similar.
I have a broadcast stream controller in the payload generating function. In the event dispatcher (this should be your bloc) I listen to it and emit loading states with a double value. the bloc builder in the widgets can react to it. check put my little implementation:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// Model
class Data {
final DateTime date;
Data(this.date);
}
/// Repo
class DataRepository {
/// here comes the trick:
final StreamController<double> progress = StreamController.broadcast(); // make it broadcast to allow multiple subcribtions
Future<List<Data>> generateData() async {
/// here comes your time taking work
progress.sink.add(0.0); // set progress to 0
List<Data> payload =
[]; // this will be the data you want to transport in the loadED event
await Future.forEach(List.generate(10, (i) => i), (int i) async {
/// here comes the progess; dont send it too late otherwise
/// the loadED state will be followed by a loadING state and
/// you will see a never ending spinner
progress.sink.add(i / 10);
/// this would be like eg a http call
payload.add(Data(DateTime.now()));
await Future.delayed(
const Duration(seconds: 1)); // simulate the time consuming action
});
return payload;
}
}
/// State
#immutable
abstract class DataState {}
class DataInitial extends DataState {
DataInitial();
}
class DataLoading extends DataState {
/// this state will emit the actual progress value to the spinner
final double progress;
DataLoading(this.progress);
/// boilerplate code to tell state events apart from each other even though they are of the same type
#override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is DataLoading && other.progress == progress;
}
#override
int get hashCode => progress.hashCode;
}
class DataLoaded extends DataState {
/// this state will transport the payload data
final List<Data> data;
DataLoaded(this.data);
/// same as aboth
#override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is DataLoaded && other.data == data;
}
#override
int get hashCode => data.hashCode;
}
/// Cubit
class DataCubit extends Cubit<DataState> {
/// Cubit works like a simple Bloc
final DataRepository dataRepository;
/// the repo will do the actual work
DataCubit({required this.dataRepository}) : super(DataInitial());
Future<void> generateData() async {
/// this will bring the progress value to the loading spinner widget.
/// each time a new progress is made a new DataLoadING state will be emitted
dataRepository.progress.stream
.listen((progress) => emit(DataLoading(progress)));
/// this await is sincere; it will take aaages; really
final payload = await dataRepository.generateData();
/// finally the payload will be sent to the widgets
emit(DataLoaded(payload));
}
}
late DataRepository dataRepository;
void main() {
/// init the repo that will do the heavy lifting like eg a db or http request
dataRepository = DataRepository();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
/// makes the cubit (like a baby bloc) available to all child widgets of the app
return BlocProvider<DataCubit>(
create: (context) => DataCubit(dataRepository: dataRepository),
child: const MaterialApp(
title: 'Flutter Progress Demo',
home: MyHomePage(),
),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({Key? key}) : super(key: key);
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Progress Example'),
),
body: Center(
child: BlocBuilder<DataCubit, DataState>(
builder: (context, state) {
if (state is DataLoading) {
return CircularProgressIndicator.adaptive(
value: state.progress,
);
} else if (state is DataLoaded) {
List<Data> longAnticipatedData = state.data;
return ListView.builder(
itemCount: longAnticipatedData.length,
itemBuilder: (context, i) => ListTile(
title:
Text(longAnticipatedData[i].date.toIso8601String()),
));
} else {
/// initial state
return const Center(
child: Text('press the FAB'),
);
}
},
),
),
floatingActionButton: FloatingActionButton(
/// generate data; will take a good while
onPressed: () => context.read<DataCubit>().generateData(),
child: const Icon(Icons.start),
),
);
}
}

What is "dirty" in Flutter & what is causing this "dirty" state?

I am trying to learn both state management & dependancy injection with this demo project. I'm trying to demo injecting some methods all over the place like I may need to in my program. I'm using GetX because I like being able to do this without context in non-widget classes.
So my problem here is the last method, summationReturns(), in the last class below. Attempts to take methods with return statements and add them together. I call this in two places. In the floating button, this works fine but in my text widget I get a dirty state error.
Why is this not working when everything else works? And I assume this will be a corollary from the last question, what is a dirty state? Seems like two questions but I would imagine that they are one in the same.
///
///
/// DEMO PROJECT WORKING OUT GETX
/// WORKOUT DEPENDANCY INJECTION AND STATE MANAGEMENT
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get/get_state_manager/get_state_manager.dart';
void main() {
runApp(GetMaterialApp(
home: Home(),
debugShowCheckedModeBanner: false,
));
}
class Home extends StatelessWidget {
// Injection of dependancy
final Controller controller = Get.put(Controller());
final Observable observable = Get.put(Observable());
final SimpleMath simpleMath = Get.put(SimpleMath());
#override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('GetX Demo'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('Get builders:'),
GetBuilder<Controller>(builder: (controller) {
return Text(controller.count.toString());
}),
GetBuilder<Controller>(builder: (controller) {
return Text(controller.countList.toString());
}),
GetBuilder<Controller>(builder: (controller) {
return Text(controller.returnCount().toString());
}),
GetBuilder<Controller>(builder: (controller) {
return Text(controller.returnList().toString());
}),
SizedBox(height: 20.0),
Text('Get observables:'),
Obx(() => Text(observable.count.value.toString())),
Obx(() => Text(observable.countList.value.toString())),
Obx(() => Text(observable.returnCount().toString())),
Obx(() => Text(observable.returnList().toString())),
SizedBox(height: 20.0),
Text('Get from other class:'),
GetBuilder<SimpleMath>(builder: (simpleMath) {
return Text('Variable summation: ' + simpleMath.summationVariables().toString());
}),
GetBuilder<SimpleMath>(builder: (simpleMath) {
return Text(simpleMath.summationReturns().toString());
}),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
controller.crunch();
observable.crunch();
simpleMath.summationVariables();
simpleMath.summationReturns();
},
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
}
}
class Controller extends GetxController {
int count = 0;
List<int> countList = [];
void crunch() {
count += 1;
countList.add(count);
update();
}
int returnCount() {
return count;
}
List<int> returnList() {
return countList;
}
}
class Observable extends GetxController {
RxInt count = 0.obs;
Rx<RxList> countList = RxList().obs;
void crunch() {
count.value += 1;
countList.value.add(count.value);
}
int returnCount() {
return count.value;
}
List<dynamic> returnList() {
return countList.value.toList();
}
}
class SimpleMath extends GetxController {
final Controller controller = Get.find<Controller>();
final Observable observable = Get.find<Observable>();
int summationVariables() {
int sum = controller.count + observable.count.value;
update();
return sum;
}
int summationReturns() {
int sum = controller.returnCount() + observable.returnCount();
print('Summation of return values: ' + sum.toString());
update();
return sum;
}
}
Error:
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following assertion was thrown building GetBuilder<SimpleMath>(dirty, state:
GetBuilderState<SimpleMath>#4d62d):
setState() or markNeedsBuild() called during build.
This GetBuilder<SimpleMath> widget cannot be marked as needing to build because the framework is
already in the process of building widgets. A widget can be marked as needing to be built during
the build phase only if one of its ancestors is currently building. This exception is allowed
because the framework builds parent widgets before children, which means a dirty descendant will
always be built. Otherwise, the framework might not visit this widget during this build phase.
The widget on which setState() or markNeedsBuild() was called was:
GetBuilder<SimpleMath>
The widget which was currently being built when the offending call was made was:
GetBuilder<SimpleMath>
The relevant error-causing widget was:
GetBuilder<SimpleMath>
file:///Users/robertobuttazzoni/Documents/Flutter%20Tutorials/Flutter%20Learning/getx_basics/getx_basics/lib/main.dart:57:13
Calling update while build is ongoing is an example of dirty scenario. To fix your issue, do not call update inside the GetBuilder.
Sample...
In Home
GetBuilder<SimpleMath>(
builder: (simpleMath) => Text('Variable summation: ' +
simpleMath
.summationVariables(shouldUpdate: false)
.toString())),
GetBuilder<SimpleMath>(
builder: (simpleMath) => Text(simpleMath
.summationReturns(shouldUpdate: false)
.toString())),
In SimpleMath
int summationVariables({bool shouldUpdate = true}) {
int sum = controller.count + observable.count.value;
if (shouldUpdate) update();
return sum;
}
int summationReturns({bool shouldUpdate = true}) {
int sum = controller.returnCount() + observable.returnCount();
print('Summation of return values: ' + sum.toString());
if (shouldUpdate) update();
return sum;
}

Flutter BLoC can't update my list of boolean

So, I tried to learn flutter especially in BLoC method and I made a simple ToggleButtons with BLoC. Here it looks like
ToggleUI.dart
class Flutter501 extends StatelessWidget {
#override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter 50 With Bloc Package',
home: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BlocProvider<ToggleBloc>(
builder: (context) => ToggleBloc(maxToggles: 4),
child: MyToggle(),
)
],
),
),
),
);
}
}
class MyToggle extends StatelessWidget {
const MyToggle({
Key key,
}) : super(key: key);
#override
Widget build(BuildContext context) {
ToggleBloc bloc = BlocProvider.of<ToggleBloc>(context);
return BlocBuilder<ToggleBloc, List<bool>>(
bloc: bloc,
builder: (context, state) {
return ToggleButtons(
children: [
Icon(Icons.arrow_back),
Icon(Icons.arrow_upward),
Icon(Icons.arrow_forward),
Icon(Icons.arrow_downward),
],
onPressed: (idx) {
bloc.dispatch(ToggleTap(index: idx));
},
isSelected: state,
);
},
);
}
}
ToogleBloc.dart
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/cupertino.dart';
abstract class ToggleEvent extends Equatable {
const ToggleEvent();
}
class ToggleTap extends ToggleEvent {
final int index;
ToggleTap({this.index});
#override
// TODO: implement props
List<Object> get props => [];
}
class ToggleBloc extends Bloc<ToggleEvent, List<bool>> {
final List<bool> toggles = [];
ToggleBloc({
#required int maxToggles,
}) {
for (int i = 0; i < maxToggles; i++) {
this.toggles.add(false);
}
}
#override
// TODO: implement initialState
List<bool> get initialState => this.toggles;
#override
Stream<List<bool>> mapEventToState(ToggleEvent event) async* {
// TODO: implement mapEventToState
if (event is ToggleTap) {
this.toggles[event.index] = !this.toggles[event.index];
}
yield this.toggles;
}
}
The problem came when I tried to Tap/Press one of the buttons, but it doesn't want to change into the active button. But it works whenever I tried to press the "Hot Reload". It likes I have to make a setState whenever the button pressed.
The BlocBuilder.builder method is only executed if the State changes. So in your case the State is a List<bool> of which you only change a specific index and yield the same object. Because of this, BlocBuilder can't determine if the List changed and therefore doesn't trigger a rebuild of the UI.
See https://github.com/felangel/bloc/blob/master/docs/faqs.md for the explanation in the flutter_bloc docs:
Equatable properties should always be copied rather than modified. If an Equatable class contains a List or Map as properties, be sure to use List.from or Map.from respectively to ensure that equality is evaluated based on the values of the properties rather than the reference.
Solution
In your ToggleBloc, change the List like this, so it creates a completely new List object:
#override
Stream<List<bool>> mapEventToState(ToggleEvent event) async* {
// TODO: implement mapEventToState
if (event is ToggleTap) {
this.toggles[event.index] = !this.toggles[event.index];
this.toggles = List.from(this.toggles);
}
yield this.toggles;
}
Also, make sure to set the props for your event, although it won't really matter for this specific question.
BlocBuilder will ignore the update if a new state was equal to the old state. When comparing two lists in Dart language, if they are the same instance, they are equal, otherwise, they are not equal.
So, in your case, you would have to create a new instance of list for every state change, or define a state object and send your list as property of it.
Here is how you would create new list instance for every state:
if (event is ToggleTap) {
this.toggles[event.index] = !this.toggles[event.index];
}
yield List.from(this.toggles);
You can read more about bloc library and equality here:
https://bloclibrary.dev/#/faqs?id=when-to-use-equatable