1

I have an isLoading StateNotifierProvider to add a loading indicator to my buttons when they are tapped until the async method has completed.

However, when I have two buttons visible at once, they both show the indicator when only one was tapped.

How do I scope the StateNotifierProvider to one instance per button instead of one instance for all buttons?

Button:

class AsyncSubmitButton extends ConsumerWidget {
  AsyncSubmitButton(
      {required this.text,
      required this.onPressed})
      : super(key: key);

  final String text;
  final Future<void> Function() onPressed;

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final isLoading = watch(isLoadingProvider);
    final form = ReactiveForm.of(context)!;
    return ElevatedButton(
        onPressed: form.invalid
            ? null
            : () => context.read(isLoadingProvider.notifier).pressed(onPressed),
        child: isLoading
            ? const CircularProgressIndicator()
            : Text(text),
        );
  }
}

Provider:

final isLoadingProvider =
    StateNotifierProvider<AsyncSubmitButtonLoadingStateNotifier, bool>((ref) {
  return AsyncSubmitButtonLoadingStateNotifier(isLoading: false);
});

class AsyncSubmitButtonLoadingStateNotifier extends StateNotifier<bool> {
  AsyncSubmitButtonLoadingStateNotifier({required bool isLoading})
      : super(isLoading);

  dynamic pressed(Future<void> Function() onPressedFunction) async {
    state = true;
    try {
      await onPressedFunction();
    } catch (error) {
      state = false;
    } finally {
      state = false;
    }
  }
}
BeniaminoBaggins
  • 11,202
  • 41
  • 152
  • 287

1 Answers1

3

As this answer, and also this answer suggest, use the family modifier. This question is borderline a duplicate of the question in the linked answer but I found this use case was not documented well (only in SO/GitHub answers) and I think this answer is more full so want to leave this here.

The state of a provider is always shared globally. But you can access that state with a passed in id by using the family modifier, and then compare the id when reading the provider to see if it is the corresponding instance. Remember to use .autoDispose when using family to prevent memory leaks. I think it might create a new reference to the provider with the passed in param for each read with a different passed in param, and providers are never disposed unless using the autoDispose modifier.

So the code in the question becomes:

Button:

class AsyncSubmitButton extends ConsumerWidget {
  AsyncSubmitButton(
      {required this.text,
      this.id = 'global',
      required this.onPressed})
      : super(key: key);

  final String text;
  final Future<void> Function() onPressed;
  final String id;

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final isLoading = watch(isLoadingProvider(id)) &&
        id == context.read(isLoadingProvider(id).notifier).id;
    final form = ReactiveForm.of(context)!;
    return ElevatedButton(
        onPressed: form.invalid
            ? null
            : () => context.read(isLoadingProvider.notifier).pressed(onPressed),
        child: isLoading
            ? const CircularProgressIndicator()
            : Text(text),
        );
  }
}

Provider:

final isLoadingProvider = StateNotifierProvider.family
    .autoDispose<AsyncSubmitButtonLoadingStateNotifier, bool, String>(
        (ref, id) {
  return AsyncSubmitButtonLoadingStateNotifier(isLoading: false, id: id);
});

class AsyncSubmitButtonLoadingStateNotifier extends StateNotifier<bool> {
  AsyncSubmitButtonLoadingStateNotifier(
      {required bool isLoading, required this.id})
      : super(isLoading);

  String id;

  dynamic onPressed(Future<void> Function() onPressedFunction) async {
    state = true;
    try {
      await onPressedFunction();
    } catch (error) {
      state = false;
    } finally {
      state = false;
    }
  }
}

And in the widget, if you don't care about it being unique, just don't pass the id, and it'll default. If you do care, since you have 2 unique buttons using it etc, specify the id...

child: AsyncSubmitButton(text: 'Sign Out', id: 'signOutButton', onPressed: vm.signOut)
BeniaminoBaggins
  • 11,202
  • 41
  • 152
  • 287