blocfx 0.1.1 copy "blocfx: ^0.1.1" to clipboard
blocfx: ^0.1.1 copied to clipboard

A Flutter package that adds Effect (side-effects) support to flutter_bloc, following MVI pattern for handling single-shot events like navigation and dialogs, bottomsheet, etc.

blocfx #

A Flutter package that extends flutter_bloc with Effect streams for handling single-shot events separately from state. Inspired by MVI (Model-View-Intent) architecture pattern.

The Problem #

In traditional BLoC pattern, developers often misuse State for single-shot events like navigation or showing dialogs:

// Bad practice: Using state for navigation
abstract class LoginState {}
class LoginSuccess extends LoginState {} // This gets emitted for navigation
class LoginError extends LoginState {
  final String message;
  LoginError(this.message);
}

// Problems:
// 1. State gets replaced - can't show error AND navigate
// 2. Navigation logic mixed with UI state
// 3. Difficult to handle multiple events simultaneously

The Solution #

blocfx separates State (UI representation) from Effects (single-shot events):

// State represents UI
class LoginState {
  final bool isLoading;
  final String email;
  final String password;
}

// Effects represent single-shot events
abstract class LoginEffect {}
class NavigateToDashboard extends LoginEffect {}
class ShowErrorDialog extends LoginEffect {
  final String message;
  ShowErrorDialog(this.message);
}

Installation #

Add to your pubspec.yaml:

dependencies:
  blocfx: ^0.1.1

Usage #

Using with Bloc (Event-driven) #

1. Create your Bloc with Effects

import 'package:blocfx/blocfx.dart';

// Define effects
abstract class LoginEffect {}
class NavigateToDashboard extends LoginEffect {}
class ShowErrorDialog extends LoginEffect {
  final String message;
  ShowErrorDialog(this.message);
}

// Define state
class LoginState {
  final bool isLoading;
  final String email;
  final String password;

  LoginState({
    required this.isLoading,
    required this.email,
    required this.password,
  });

  LoginState copyWith({bool? isLoading, String? email, String? password}) {
    return LoginState(
      isLoading: isLoading ?? this.isLoading,
      email: email ?? this.email,
      password: password ?? this.password,
    );
  }
}

// Create Bloc with Effects
class LoginBloc extends BlocFx<LoginEvent, LoginState, LoginEffect> {
  final AuthRepository _authRepository;

  LoginBloc(this._authRepository)
      : super(LoginState(isLoading: false, email: '', password: '')) {
    on<LoginSubmittedEvent>(_onLoginSubmitted);
  }

  Future<void> _onLoginSubmitted(
    LoginSubmittedEvent event,
    Emitter<LoginState> emit,
  ) async {
    emit(state.copyWith(isLoading: true));

    try {
      await _authRepository.login(state.email, state.password);
      emit(state.copyWith(isLoading: false));
      emitEffect(NavigateToDashboard()); // Emit effect for navigation
    } catch (e) {
      emit(state.copyWith(isLoading: false));
      emitEffect(ShowErrorDialog(e.toString())); // Emit effect for dialog
    }
  }
}

2. Consume Effects in UI

Use BlocFxConsumer to handle both state changes and effects:

import 'package:blocfx/blocfx.dart';

class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => LoginBloc(authRepository),
      child: BlocFxConsumer<LoginBloc, LoginEvent, LoginState, LoginEffect>(
        // Handle state changes (rebuilds UI)
        builder: (context, state) {
          return Column(
            children: [
              if (state.isLoading)
                CircularProgressIndicator(),
              TextField(
                onChanged: (value) => context.read<LoginBloc>()
                    .add(EmailChangedEvent(value)),
              ),
              ElevatedButton(
                onPressed: () => context.read<LoginBloc>()
                    .add(LoginSubmittedEvent()),
                child: Text('Login'),
              ),
            ],
          );
        },

        // Handle effects (single-shot events)
        effectListener: (context, effect) {
          if (effect is NavigateToDashboard) {
            Navigator.pushReplacementNamed(context, '/dashboard');
          } else if (effect is ShowErrorDialog) {
            showDialog(
              context: context,
              builder: (_) => AlertDialog(
                title: Text('Error'),
                content: Text(effect.message),
              ),
            );
          }
        },
      ),
    );
  }
}

3. Or use BlocFxListener for effects only

When you only need to listen to effects without rebuilding:

BlocFxListener<LoginBloc, LoginEvent, LoginState, LoginEffect>(
  listener: (context, effect) {
    if (effect is NavigateToDashboard) {
      Navigator.pushReplacementNamed(context, '/dashboard');
    }
  },
  child: YourWidget(),
)

Using with Cubit (Simpler state management) #

1. Create your Cubit with Effects

import 'package:blocfx/blocfx.dart';

// Define effects
abstract class ProfileEffect {}
class ShowSuccessMessage extends ProfileEffect {
  final String message;
  ShowSuccessMessage(this.message);
}
class NavigateToSettings extends ProfileEffect {}

// Define state
class ProfileState {
  final bool isLoading;
  final String name;
  final String email;

  ProfileState({
    required this.isLoading,
    required this.name,
    required this.email,
  });

  ProfileState copyWith({bool? isLoading, String? name, String? email}) {
    return ProfileState(
      isLoading: isLoading ?? this.isLoading,
      name: name ?? this.name,
      email: email ?? this.email,
    );
  }
}

// Create Cubit with Effects
class ProfileCubit extends Cubitfx<ProfileState, ProfileEffect> {
  final ProfileRepository _repository;

  ProfileCubit(this._repository)
      : super(ProfileState(isLoading: false, name: '', email: ''));

  Future<void> updateProfile(String name, String email) async {
    emit(state.copyWith(isLoading: true));

    try {
      await _repository.update(name, email);
      emit(state.copyWith(isLoading: false, name: name, email: email));
      emitEffect(ShowSuccessMessage('Profile updated successfully'));
    } catch (e) {
      emit(state.copyWith(isLoading: false));
      emitEffect(ShowSuccessMessage('Failed to update profile'));
    }
  }
}

2. Consume Cubit Effects in UI

class ProfilePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => ProfileCubit(profileRepository),
      child: Scaffold(
        appBar: AppBar(title: Text('Profile')),
        body: CubitfxListener<ProfileCubit, ProfileState, ProfileEffect>(
          listener: (context, effect) {
            if (effect is ShowSuccessMessage) {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(effect.message)),
              );
            } else if (effect is NavigateToSettings) {
              Navigator.pushNamed(context, '/settings');
            }
          },
          child: BlocBuilder<ProfileCubit, ProfileState>(
            builder: (context, state) {
              if (state.isLoading) {
                return Center(child: CircularProgressIndicator());
              }
              return Column(
                children: [
                  TextField(
                    decoration: InputDecoration(labelText: 'Name'),
                    controller: TextEditingController(text: state.name),
                  ),
                  TextField(
                    decoration: InputDecoration(labelText: 'Email'),
                    controller: TextEditingController(text: state.email),
                  ),
                  ElevatedButton(
                    onPressed: () => context.read<ProfileCubit>()
                        .updateProfile('New Name', 'new@email.com'),
                    child: Text('Update'),
                  ),
                ],
              );
            },
          ),
        ),
      ),
    );
  }
}

Advanced Usage #

Using BlocSelector for optimized rebuilds #

BlocSelector<LoginBloc, LoginState, bool>(
  selector: (state) => state.isLoading,
  builder: (context, isLoading) {
    return ElevatedButton(
      onPressed: isLoading ? null : () => context.read<LoginBloc>()
          .add(LoginSubmittedEvent()),
      child: Text('Login'),
    );
  },
)

Conditional effect listening #

BlocFxListener<LoginBloc, LoginEvent, LoginState, LoginEffect>(
  listenWhen: (effect) => effect is ShowErrorDialog,
  listener: (context, effect) {
    // Only handles ShowErrorDialog effects
  },
  child: YourWidget(),
)

Testing #

Testing blocs with effects is straightforward:

test('emits NavigateToDashboard effect on successful login', () async {
  final authRepository = MockAuthRepository();
  when(() => authRepository.login(any(), any()))
      .thenAnswer((_) async => User());

  final bloc = LoginBloc(authRepository);

  bloc.add(LoginSubmittedEvent());

  await expectLater(
    bloc.effects,
    emits(isA<NavigateToDashboard>()),
  );
});

test('emits ShowErrorDialog effect on login failure', () async {
  final authRepository = MockAuthRepository();
  when(() => authRepository.login(any(), any()))
      .thenThrow(Exception('Invalid credentials'));

  final bloc = LoginBloc(authRepository);

  bloc.add(LoginSubmittedEvent());

  await expectLater(
    bloc.effects,
    emits(isA<ShowErrorDialog>()),
  );
});

API Reference #

BlocFx #

abstract class BlocFx<Event, State, Effect> extends Bloc<Event, State> {
  Stream<Effect> get effects;
  void emitEffect(Effect effect);
}

Cubitfx #

abstract class Cubitfx<State, Effect> extends Cubit<State> {
  Stream<Effect> get effects;
  void emitEffect(Effect effect);
}

BlocFxConsumer #

Widget that rebuilds on state changes AND listens to effects.

BlocFxConsumer<B extends BlocFx<Event, S, E>, Event, S, E>({
  required Widget Function(BuildContext context, S state) builder,
  required void Function(BuildContext context, E effect) effectListener,
  bool Function(S previous, S current)? buildWhen,
  bool Function(E effect)? listenWhen,
})

BlocFxListener #

Widget that only listens to effects without rebuilding.

BlocFxListener<B extends BlocFx<Event, S, E>, Event, S, E>({
  required void Function(BuildContext context, E effect) listener,
  bool Function(E effect)? listenWhen,
  required Widget child,
})

CubitfxListener #

Widget that only listens to Cubit effects without rebuilding.

CubitfxListener<C extends Cubitfx<S, E>, S, E>({
  required void Function(BuildContext context, E effect) listener,
  bool Function(E effect)? listenWhen,
  required Widget child,
})

Migration from flutter_bloc #

From Bloc to BlocFx #

  1. Change extends Bloc to extends BlocFx
  2. Add Effect type parameter to your Bloc class
  3. Replace state-based navigation/dialogs with emitEffect()
  4. Use BlocFxConsumer or BlocFxListener in your UI
  5. Handle effects in effectListener callback

From Cubit to Cubitfx #

  1. Change extends Cubit to extends Cubitfx
  2. Add Effect type parameter to your Cubit class
  3. Replace state-based navigation/dialogs with emitEffect()
  4. Use CubitfxListener in your UI
  5. Handle effects in listener callback

Example:

// Before
class LoginBloc extends Bloc<LoginEvent, LoginState> {
  LoginBloc() : super(LoginInitial()) {
    on<LoginSubmitted>((event, emit) async {
      try {
        await login();
        emit(LoginSuccess()); // State used for navigation
      } catch (e) {
        emit(LoginError(e.toString()));
      }
    });
  }
}

// After
class LoginBloc extends BlocFx<LoginEvent, LoginState, LoginEffect> {
  LoginBloc() : super(LoginState(isLoading: false)) {
    on<LoginSubmitted>((event, emit) async {
      emit(state.copyWith(isLoading: true));
      try {
        await login();
        emit(state.copyWith(isLoading: false));
        emitEffect(NavigateToDashboard()); // Effect for navigation
      } catch (e) {
        emit(state.copyWith(isLoading: false));
        emitEffect(ShowErrorDialog(e.toString())); // Effect for error
      }
    });
  }
}

License #

MIT License

Contributing #

Contributions are welcome! Please open an issue or submit a pull request.

Credits #

Created by fajarxfce.

Inspired by MVI pattern from Android development and side-effect handling patterns from other reactive frameworks.

0
likes
0
points
101
downloads

Publisher

unverified uploader

Weekly Downloads

A Flutter package that adds Effect (side-effects) support to flutter_bloc, following MVI pattern for handling single-shot events like navigation and dialogs, bottomsheet, etc.

Repository (GitHub)
View/report issues

Topics

#bloc #state-management #mvi #side-effects #flutter

License

unknown (license)

Dependencies

flutter, flutter_bloc

More

Packages that depend on blocfx