DEV Community

Cover image for Overcoming Dart's Single Inheritance Wall: Composable CubitSignalMixin & BlocSignalMixin in Flutter

Overcoming Dart's Single Inheritance Wall: Composable CubitSignalMixin & BlocSignalMixin in Flutter

Breaking Free from Dart's Single Inheritance Constraint in State Management

Every Dart and Flutter developer eventually runs headfirst into a fundamental language constraint: single inheritance.

In Dart, a class can extend only one superclass.

In greenfield tutorials, this is rarely an issue because classes start from a clean slate. But in real-world Flutter engineering, domain repositories, controllers, and services frequently already belong to an established inheritance hierarchy:

  • A search field controller that must extend Flutter's TextEditingController (which itself extends ValueNotifier<TextEditingValue>).
  • A view controller that extends ChangeNotifier or AnimationController.
  • A domain repository that extends an enterprise BaseRepository<T>, EntityStore, or microservices client.

Historically, if you wanted that class to also be a Cubit or BLoC, you were out of luck. You could not write:

// ❌ Impossible in Dart (Multiple Inheritance is forbidden):
class SearchController extends TextEditingController, CubitSignal<SearchState> { ... }
Enter fullscreen mode Exit fullscreen mode

This forced developers into a frustrating dilemma:

  1. The Wrapper / Proxy Anti-Pattern: Creating a wrapper class that held an internal _cubit reference, requiring tedious method forwarding and manual synchronization.
  2. Duplicate Controller Lifecycles: Managing two separate objects in the widget tree—a TextEditingController for the input widget and a separate SearchCubit for the state—forcing you to wire listeners between them in initState/dispose.
  3. Inheritance Refactoring: Trying to refactor existing base classes, often breaking third-party library contracts or enterprise architectures.

With bloc_signals 1.2.0, that single inheritance wall has been completely demolished.

We have introduced CubitSignalMixin and BlocSignalMixin, enabling any class in an existing inheritance hierarchy to become a first-class, 0ms reactive BlocSignalBase container with zero wrapper boilerplate.


🧬 How the Composable Mixins Work

Because BlocSignal has a lean, highly disciplined API surface, mixing it into arbitrary classes introduces zero namespace pollution.

┌────────────────────────────────────────────────────────────────────────┐
│                        BlocSignal Mixin Architecture                   │
├────────────────────────────────┬───────────────────────────────────────┤
│ Mixin                          │ Capabilities Added                    │
├────────────────────────────────┼───────────────────────────────────────┤
│ CubitSignalMixin<StateType>    │ state, stateValue, emit(newState),    │
│                                │ equals(), createEffect(), close()     │
├────────────────────────────────┼───────────────────────────────────────┤
│ BlocSignalMixin<Event, State>  │ on<E>(), concurrency transformers     │
│                                │ (restartable, droppable), add(event)  │
└────────────────────────────────┴───────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

1. CubitSignalMixin<StateType>

CubitSignalMixin implements BlocSignalBase<StateType>. All you do is mix it in and invoke initCubitSignal(initialState: ...) in your constructor:

class UserProfileRepository extends BaseRepository
    with CubitSignalMixin<UserProfileState> {
  UserProfileRepository(super.apiClient) {
    initCubitSignal(initialState: const UserProfileInitial());
  }

  Future<void> fetchProfile(String userId) async {
    emit(const UserProfileLoading());
    try {
      final profile = await apiClient.getProfile(userId);
      emit(UserProfileLoaded(profile));
    } catch (error, stackTrace) {
      emit(UserProfileError(error.toString()));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

2. BlocSignalMixin<Event, StateType>

When you need full event-driven state machines with concurrency transformers (restartable(), droppable(), sequential()), mix in both CubitSignalMixin and BlocSignalMixin:

class OrderService extends BaseService
    with CubitSignalMixin<OrderState>, BlocSignalMixin<OrderEvent, OrderState> {
  OrderService(super.networkClient) {
    initCubitSignal(initialState: const OrderInitial());

    on<SubmitOrder>((event, emit) async {
      emit(const OrderSubmitting());
      final result = await networkClient.postOrder(event.order);
      emit(OrderSuccess(result.orderId));
    }, transformer: droppable()); // Discards duplicate taps while in flight!
  }
}
Enter fullscreen mode Exit fullscreen mode

🎯 Real-World Killer Use Case: The Self-Debouncing TextEditingController

Let us look at a practical scenario where this pattern shines: a live product search input.

In traditional Flutter architectures, building a debounced search input requires:

  1. Creating a TextEditingController in widget state.
  2. Creating a SearchBloc or SearchCubit.
  3. Adding a listener in initState that forwards controller.text into bloc.add(SearchQueryChanged(text)).
  4. Remembering to dispose both in dispose().

With BlocSignalMixin, your TextEditingController IS the debounced BLoC:

sealed class SearchEvent {
  const SearchEvent();
}

final class QueryChanged extends SearchEvent {
  const QueryChanged(this.query);
  final String query;
}

sealed class SearchState {
  const SearchState();
}

final class SearchInitial extends SearchState {
  const SearchInitial();
}

final class SearchLoading extends SearchState {
  const SearchLoading();
}

final class SearchSuccess extends SearchState {
  const SearchSuccess(this.results);
  final List<Product> results;
}

final class SearchError extends SearchState {
  const SearchError(this.message);
  final String message;
}

/// A standard Flutter TextEditingController with built-in BLoC reactivity!
class SearchTextEditingController extends TextEditingController
    with
        CubitSignalMixin<SearchState>,
        BlocSignalMixin<SearchEvent, SearchState> {
  SearchTextEditingController(this._api) {
    initCubitSignal(initialState: const SearchInitial());

    // ⚡ Built-in restartable concurrency transformer automatically cancels
    // previous in-flight queries when new text is entered!
    on<QueryChanged>((event, emit) async {
      final query = event.query.trim();
      if (query.isEmpty) {
        emit(const SearchInitial());
        return;
      }

      emit(const SearchLoading());
      try {
        final products = await _api.search(query);
        emit(SearchSuccess(products));
      } catch (error) {
        emit(SearchError(error.toString()));
      }
    }, transformer: restartable());

    // 🎯 Forward controller text mutations straight into the event pipeline
    addListener(() => add(QueryChanged(text)));
  }

  final SearchApiClient _api;

  @override
  void dispose() {
    close(); // Closes signal subscriptions and cancels pending async transformers
    super.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

Clean, Declarative Flutter UI Binding

Because SearchTextEditingController extends TextEditingController AND implements BlocSignalBase<SearchState>, you pass it directly to TextField and read it directly with BlocSignalBuilder:

class SearchScreen extends StatefulWidget {
  const SearchScreen({super.key, required this.api});
  final SearchApiClient api;

  @override
  State<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
  late final SearchTextEditingController _searchController;

  @override
  void initState() {
    super.initState();
    _searchController = SearchTextEditingController(widget.api);
  }

  @override
  void dispose() {
    _searchController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: TextField(
          controller: _searchController,
          decoration: const InputDecoration(
            hintText: 'Search products...',
            border: InputBorder.none,
          ),
        ),
      ),
      body: BlocSignalBuilder<SearchTextEditingController, SearchState>(
        bloc: _searchController,
        builder: (context, state) => switch (state) {
          SearchInitial() => const Center(
              child: Text('Type a query to search products.'),
            ),
          SearchLoading() => const Center(
              child: CircularProgressIndicator(),
            ),
          SearchSuccess(:final results) when results.isEmpty => const Center(
              child: Text('No products found.'),
            ),
          SearchSuccess(:final results) => ListView.builder(
              itemCount: results.length,
              itemBuilder: (context, index) => ListTile(
                title: Text(results[index].name),
                subtitle: Text('\$${results[index].price}'),
              ),
            ),
          SearchError(:final message) => Center(
              child: Text('Error: $message', style: const TextStyle(color: Colors.red)),
            ),
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Look at that simplicity:

  • Zero glue code.
  • Single object lifecycle: one controller to instantiate, one controller to dispose.
  • Native Flutter widget compatibility: passed directly to TextField(controller: ...).
  • 0ms Reactive UI updates: rebuilt synchronously on every state transition.

🏛️ DRY Core Architecture & Universal Polymorphism

One of our guiding principles in BlocSignal is avoiding parallel, divergent abstractions.

In bloc_signals 1.2.0, CubitSignal and BlocSignal themselves compose CubitSignalMixin and BlocSignalMixin as their single source of truth:

abstract class CubitSignal<StateType> extends BlocSignalBase<StateType>
    with CubitSignalMixin<StateType> {
  CubitSignal({
    required StateType initialState,
    bool Function(StateType, StateType)? equals,
    SignalOptions<StateType>? options,
  }) {
    initCubitSignal(
      initialState: initialState,
      equals: equals,
      options: options,
    );
  }
}

abstract class BlocSignal<Event, StateType> extends BlocSignalBase<StateType>
    with CubitSignalMixin<StateType>, BlocSignalMixin<Event, StateType> {
  BlocSignal({
    required StateType initialState,
    bool Function(StateType, StateType)? equals,
    SignalOptions<StateType>? options,
  }) {
    initCubitSignal(
      initialState: initialState,
      equals: equals,
      options: options,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Because CubitSignalMixin implements BlocSignalBase<StateType>, any class mixing it in is 100% polymorphic with the entire ecosystem:

  • BlocSignalProvider: Provide your mixed-in class directly with $O(1)$ lookup.
  • context.select: Fine-grained rebuilds on state sub-properties (context.select<SearchTextEditingController, int>((c) => c.stateValue.results.length)).
  • blocSignalTest: Declarative unit testing with zero mocking.
  • bloc_signals_riverpod: Convert mixed-in classes to Riverpod providers via .toProvider().
  • bloc_signals_hydrate: Add synchronous Frame-1 persistence by adding with HydratedMixin.
  • bloc_signals_replay: Add undo/redo change history by adding with ReplayMixin.
  • DevTools Extension: Automatically monitored in the DevTools timeline and instance tree.

📦 Getting Started

CubitSignalMixin and BlocSignalMixin are available now in bloc_signals 1.2.0:

dependencies:
  bloc_signals: ^1.2.0
  bloc_signals_flutter: ^1.2.1
Enter fullscreen mode Exit fullscreen mode

Or install via terminal:

dart pub add bloc_signals
flutter pub add bloc_signals_flutter
Enter fullscreen mode Exit fullscreen mode

Check out the interactive documentation, live demo visualizer, and architectural decision matrix at blocsignal.dev.


💬 Let's Discuss!

Have you run into Dart's single inheritance constraint when building custom Flutter controllers or enterprise repositories? How do you currently bridge TextEditingController or ChangeNotifier into your state management layer?

Drop your thoughts, questions, and feedback in the comments below!

Top comments (0)