Enhance your Bloc Events with callbacks

Enhance your Bloc Events with callbacks

An alternative to BlocListeners

·

7 min read

We'll be using Bloclibrary's Login example for our test, which can be found here:

https://bloclibrary.dev/#/flutterlogintutorial

In the tutorial, this is how the LoginState is updated:

  Future<void> _onSubmitted(
    LoginSubmitted event,
    Emitter<LoginState> emit,
  ) async {
    if (state.isValid) {
      emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
      try {
        await _authenticationRepository.logIn(
          username: state.username.value,
          password: state.password.value,
        );
        emit(state.copyWith(status: FormzSubmissionStatus.success));
      } catch (_) {
        emit(state.copyWith(status: FormzSubmissionStatus.failure));
      }
    }
  }

The _onSubmitted method handles the LoginSubmitted event and updates the LoginState accordingly. If the current state is valid, it updates the state with FormzSubmissionStatus.inProgress, then tries to log in using the AuthenticationRepository with the username and password from the state. If the login is successful, it updates the state with FormzSubmissionStatus.success, otherwise it updates the state with FormzSubmissionStatus.failure.

BlocListener's listener will then fire a callback notifying the UI of any state changes. It does this by listening to the LoginBloc state changes and shows a snackbar with an error message if the authentication fails.

class LoginForm extends StatelessWidget {
  const LoginForm({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocListener<LoginBloc, LoginState>(
      listener: (context, state) {
        if (state.status.isFailure) {
          ScaffoldMessenger.of(context)
            ..hideCurrentSnackBar()
            ..showSnackBar(
              const SnackBar(content: Text('Authentication Failure')),
            );
        }
      },
      child: Align(
        alignment: const Alignment(0, -1 / 3),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            _UsernameInput(),
            const Padding(padding: EdgeInsets.all(12)),
            _PasswordInput(),
            const Padding(padding: EdgeInsets.all(12)),
            _LoginButton(),
          ],
        ),
      ),
    );
  }
}

The LoginSubmitted event found in the login_event.dart file is added to the LoginBloc when the user submits their login credentials signaling the bloc to start the authentication process.

final class LoginSubmitted extends LoginEvent {
  const LoginSubmitted();
}

The ElevatedButton below is enabled only when the LoginState is valid but once the LoginState is in progress, a CircularProgressIndicator is displayed. When the button is pressed, the LoginSubmitted event is added to the LoginBloc as shown at the start.

class _LoginButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<LoginBloc, LoginState>(
      builder: (context, state) {
        return state.status.isInProgress
            ? const CircularProgressIndicator()
            : ElevatedButton(
                key: const Key('loginForm_continue_raisedButton'),
                onPressed: state.isValid
                    ? () {
                        context.read<LoginBloc>().add(const LoginSubmitted());
                      }
                    : null,
                child: const Text('Login'),
              );
      },
    );
  }
}

But you might not need the BlocListener after all if you employ callbacks.

From geeks for geeks

Callback is basically a function or a method that we pass as an argument into another function or a method to perform an action. In the simplest words, we can say that Callback or VoidCallback are used while sending data from one method to another and vice-versa

Modifying our LoginSubmitted event to use callback functions opens up a whole new way of interacting with the presentation layer.

final class LoginSubmitted extends LoginEvent {
  const LoginSubmitted(this.onSuccess, this.onError);

  final void Function() onSuccess;
  final void Function() onError;
}

The event now has two callbacks, onSuccess and onError, which are called when the login process succeeds or fails respectively.

  Future<void> _onSubmitted(
    LoginSubmitted event,
    Emitter<LoginState> emit,
  ) async {
    if (state.isValid) {
      emit(state.copyWith(status: FormzSubmissionStatus.inProgress));

      try {
        await _authenticationRepository.logIn(
          username: state.username.value,
          password: state.password.value,
        );

        event.onSuccess();

        emit(state.copyWith(status: FormzSubmissionStatus.success));
      } catch (_) {
        event.onError();

        emit(state.copyWith(status: FormzSubmissionStatus.failure));
      }
    }
  }

Now, if the login is successful, it calls LoginSubmitted 's onSuccess function and updates the state with FormzSubmissionStatus.success and if the login fails, it calls LoginSubmitted 's onError function and updates the state with FormzSubmissionStatus.failure .

class _LoginButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<LoginBloc, LoginState>(
      builder: (context, state) {
        return state.status.isInProgress
            ? const CircularProgressIndicator()
            : ElevatedButton(
                key: const Key('loginForm_continue_raisedButton'),
                onPressed: state.isValid
                    ? () {
                        context.read<LoginBloc>().add(
                              LoginSubmitted(
                                onSuccess: () {
                                  // TODO(felangel): navigate to main
                                },
                                onError: () {
                                  ScaffoldMessenger.of(context)
                                    ..hideCurrentSnackBar()
                                    ..showSnackBar(
                                      const SnackBar(
                                        content: Text('Authentication Failure'),
                                      ),
                                    );
                                },
                              ),
                            );
                      }
                    : null,
                child: const Text('Login'),
              );
      },
    );
  }
}

As you can see, we don't need the BlocListener anymore since the callbacks are immediately triggered depending on the result of the login.

However, if you use cubits, the following is an approach you can take:

Let's say you have an onSubmitted method that can be called directly from the presentation layer.

  Future<void> onSubmitted() async {
    if (state.isValid) {
      emit(state.copyWith(status: FormzSubmissionStatus.inProgress));

      try {
        await _authenticationRepository.logIn(
          username: state.username.value,
          password: state.password.value,
        );

        emit(state.copyWith(status: FormzSubmissionStatus.success));
      } catch (_) {
        emit(state.copyWith(status: FormzSubmissionStatus.failure));
      }
    }
  }

We can stick to the callbacks by declaring them as required parameters.

  Future<void> onSubmitted({
    required void Function() onSuccess,
    required void Function() onError,
  }) async {
    if (state.isValid) {
      emit(state.copyWith(status: FormzSubmissionStatus.inProgress));

      try {
        await _authenticationRepository.logIn(
          username: state.username.value,
          password: state.password.value,
        );

        onSuccess();

        emit(state.copyWith(status: FormzSubmissionStatus.success));
      } catch (_) {
        onError();

        emit(state.copyWith(status: FormzSubmissionStatus.failure));
      }
    }
  }

Or we can return the status like this:

  Future<FormzSubmissionStatus> onSubmitted() async {
    if (!state.isValid) {
      return FormzSubmissionStatus.failure;
    }

    emit(state.copyWith(status: FormzSubmissionStatus.inProgress));

    try {
      await _authenticationRepository.logIn(
        username: state.username.value,
        password: state.password.value,
      );

      emit(state.copyWith(status: FormzSubmissionStatus.success));

      return FormzSubmissionStatus.success;
    } catch (_) {
      emit(state.copyWith(status: FormzSubmissionStatus.failure));

      return FormzSubmissionStatus.failure;
    }
  }

In this case, when the login form is submitted the method returns a FormzSubmissionStatus indicating the status of the form submission. If the form is not valid, it returns FormzSubmissionStatus.failure. Otherwise, we attempt to log in using the AuthenticationRepository. If the login is successful, the method returns FormzSubmissionStatus.success else it returns FormzSubmissionStatus.failure.

And in the _LoginButton we call the method, taking note of the async operation going on. Since we're accessing the context across an async gap we have to make sure to check whether it's available using the mounted property on the context.

class _LoginButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<LoginCubit, LoginState>(
      builder: (context, state) {
        return state.status.isInProgress
            ? const CircularProgressIndicator()
            : ElevatedButton(
                key: const Key('loginForm_continue_raisedButton'),
                onPressed: state.isValid
                    ? () async {
                        final status =
                            await context.read<LoginCubit>().onSubmitted();

                        if (status.isFailure && context.mounted) {
                          ScaffoldMessenger.of(context)
                            ..hideCurrentSnackBar()
                            ..showSnackBar(
                              const SnackBar(
                                content: Text('Authentication Failure'),
                              ),
                            );
                        }
                      }
                    : null,
                child: const Text('Login'),
              );
      },
    );
  }
}

Why you might want to do this:

  1. More flexible: You can define different behaviours for success and error cases directly when dispatching the LoginSubmitted event, without needing to listen to the Bloc's state changes.

  2. Immediate feedback: The callbacks are invoked as soon as the login attempt succeeds or fails, which might be slightly earlier than when the new state is emitted and processed by the listeners.

Disadvantages of this approach:

  1. More complex: The additional callbacks can make the code harder to understand and maintain, especially if the behaviour defined in the callback is complex.

  2. Potential for bugs: If the callbacks modify the state of the app, it could lead to bugs or inconsistencies, since state changes would be happening outside of the Bloc's control.

While callbacks can be useful in certain scenarios, they are generally not recommended in Blocs or Cubits and this is just a showcase of what's possible for fun.

Reasons I don't recommend this:

  1. State Management: Blocs and Cubits are designed to manage state and notify listeners when that state changes. If you're using callbacks to communicate state changes, you're bypassing the Bloc's state management, which can lead to inconsistencies and bugs.

  2. Code Complexity: Callbacks can make the code more complex and harder to understand, especially if they're used to modify the state of the app.

  3. Testability: Blocs and Cubits are easy to test because you can simply provide a series of events and then assert on the resulting states. If you're using callbacks, you'll need to mock them in your tests, which can make the tests more complex and harder to maintain

If you do decide to use callbacks in your Blocs, here are some best practices to follow:

  • Don't Modify State: The callbacks should not modify the state of the app. Instead, they should be used to perform side effects (like navigation) or to provide additional information that doesn't fit into the state.

  • Keep Them Simple: The callbacks should be simple and not contain complex logic. If you find yourself writing complex callbacks, consider moving that logic into the Bloc itself.

  • Handle Errors: If the callbacks can throw exceptions, make sure to handle them properly to avoid crashing your app.

  • Document: Make sure to document what the callbacks do and when they're called, especially if other developers will be using your Blocs.

That's all I have for today but look forward to my next post on encapsulating BlocListeners and making your widget tree cleaner.

Did you find this article valuable?

Support Henry's blog by becoming a sponsor. Any amount is appreciated!