flutter
/

BLoC Split Widgets: Optimizing Performance & Code Organization

Last Sync: Today

On this page

11
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

BLoC Split Widgets: Optimizing Performance & Code Organization

Introduction

In large Flutter applications using BLoC, it's common to have complex screens with multiple parts reacting to state changes. If not structured properly, a single BlocBuilder at the top level can cause entire screens to rebuild unnecessarily, hurting performance and making code harder to maintain. Splitting widgets—breaking down UI into smaller, focused components—is a key practice to leverage BLoC's reactivity efficiently. This guide covers strategies for splitting widgets, using BlocBuilder at the right granularity, context.select, and organizing your UI for optimal rebuilds and maintainability.

Why Split Widgets?

  • Performance – Reduce rebuilds by limiting BlocBuilder to only the widgets that actually depend on state changes.
  • Separation of Concerns – Keep business logic in BLoC, UI rendering in widgets, and split widgets by responsibility.
  • Testability – Smaller widgets are easier to test in isolation.
  • Reusability – Granular components can be reused across different screens.
  • Readability – Avoid monolithic build methods; each widget has a clear purpose.

Strategy 1: Granular BlocBuilder Placement

Instead of placing a single BlocBuilder at the root of a screen, place it as close as possible to the UI that depends on the state. This isolates rebuilds to the smallest necessary subtree.

DARTRead-only
1
// ❌ Bad: Entire screen rebuilds on any state change
@override
Widget build(BuildContext context) {
  return BlocBuilder<ProfileBloc, ProfileState>(
    builder: (context, state) {
      return Scaffold(
        appBar: AppBar(title: Text('Profile')),
        body: Column(
          children: [
            Text('Name: ${state.name}'),
            Text('Email: ${state.email}'),
            // ... many other widgets
          ],
        ),
      );
    },
  );
}

// ✅ Good: Only parts that need state rebuild
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: Text('Profile')), // Never rebuilds
    body: Column(
      children: [
        BlocBuilder<ProfileBloc, ProfileState>(
          buildWhen: (prev, curr) => prev.name != curr.name,
          builder: (context, state) => Text('Name: ${state.name}'),
        ),
        BlocBuilder<ProfileBloc, ProfileState>(
          buildWhen: (prev, curr) => prev.email != curr.email,
          builder: (context, state) => Text('Email: ${state.email}'),
        ),
      ],
    ),
  );
}

Strategy 2: Extract Widgets with Builders

For complex UI parts, extract them into separate widgets that internally use BlocBuilder or context.select. This keeps the main screen clean and allows independent testing.

DARTRead-only
1
class ProfileName extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ProfileBloc, ProfileState>(
      buildWhen: (prev, curr) => prev.name != curr.name,
      builder: (context, state) => Text('Name: ${state.name}'),
    );
  }
}

class ProfileEmail extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ProfileBloc, ProfileState>(
      buildWhen: (prev, curr) => prev.email != curr.email,
      builder: (context, state) => Text('Email: ${state.email}'),
    );
  }
}

class ProfileScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Profile')),
      body: Column(
        children: [
          ProfileName(),
          ProfileEmail(),
        ],
      ),
    );
  }
}

Strategy 3: Using context.select

context.select allows you to listen to a specific part of the state without a BlocBuilder. The widget rebuilds only when the selected value changes. This is cleaner for simple selections.

DARTRead-only
1
class ProfileName extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final name = context.select((ProfileBloc bloc) => bloc.state.name);
    return Text('Name: $name');
  }
}

class ProfileEmail extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final email = context.select((ProfileBloc bloc) => bloc.state.email);
    return Text('Email: $email');
  }
}

Strategy 4: Decompose by Responsibility

Split widgets not only by state dependencies but also by functional responsibility: header, list, form, buttons, etc. Each can have its own BlocBuilder and possibly its own child BLoC if needed.

DARTRead-only
1
class TodoListScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: TodoListAppBar(),
      body: TodoList(),
      floatingActionButton: AddTodoButton(),
    );
  }
}

class TodoList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<TodoBloc, TodoState>(
      builder: (context, state) {
        return ListView.builder(
          itemCount: state.todos.length,
          itemBuilder: (_, i) => TodoItem(todo: state.todos[i]),
        );
      },
    );
  }
}

class TodoItem extends StatelessWidget {
  final Todo todo;
  const TodoItem({required this.todo});

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(todo.title),
      trailing: Checkbox(
        value: todo.completed,
        onChanged: (_) => context.read<TodoBloc>().add(ToggleTodo(todo.id)),
      ),
    );
  }
}

Strategy 5: Passing Bloc to Children

When splitting widgets, avoid passing the BLoC instance manually unless necessary. Use context.read or context.watch inside the child widget to access the BLoC. This keeps the widget tree clean and ensures the BLoC is properly scoped.

DARTRead-only
1
// ✅ Good: Child accesses BLoC via context
class IncrementButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => context.read<CounterBloc>().add(Increment()),
      child: Text('Increment'),
    );
  }
}

// ❌ Avoid: Passing bloc down manually (unless you have multiple blocs)
class IncrementButton extends StatelessWidget {
  final CounterBloc bloc;
  const IncrementButton(this.bloc);

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => bloc.add(Increment()),
      child: Text('Increment'),
    );
  }
}

Strategy 6: Using BlocProvider.value for Deep Trees

If you have a deeply nested widget that needs access to the same BLoC, you can use BlocProvider.value to provide the existing instance to a subtree. This avoids passing the BLoC through constructors.

DARTRead-only
1
class ParentScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => MyBloc(),
      child: Scaffold(
        body: Column(
          children: [
            ChildA(),
            BlocProvider.value(
              value: context.read<MyBloc>(),
              child: DeeplyNestedWidget(),
            ),
          ],
        ),
      ),
    );
  }
}

Best Practices

  • Place BlocBuilder as deep as possible – Rebuild only what needs to change.
  • Use buildWhen or context.select – Fine‑tune rebuilds even further.
  • Extract reusable widgets – Each widget should have a single responsibility.
  • Prefer context.read for events, context.select for state – Avoid BlocBuilder when a simple selector suffices.
  • Name widgets descriptively – TodoList, TodoItem, AddTodoButton make the structure clear.
  • Avoid passing BLoC instances down – Use BlocProvider and context.read to access them.
  • Test widgets in isolation – Mock the BLoC or use BlocProvider.value with a mock.

Common Mistakes

  • ❌ One giant BlocBuilder at the top – Rebuilds the whole screen on any change. ✅ Split into smaller builders.
  • ❌ Passing BLoC instances through constructors – Makes testing harder and clutters code. ✅ Use context.read or BlocProvider.of.
  • ❌ Creating widgets inside build without extracting – Can lead to unnecessary rebuilds and less readability. ✅ Extract into separate widgets.
  • ❌ Over‑splitting – Too many widgets can make navigation complex. ✅ Balance granularity with simplicity.
  • ❌ Forgetting to use const constructors – Missing out on performance optimization.

Conclusion

Splitting widgets in BLoC applications is essential for achieving high performance and maintainable code. By placing BlocBuilder at the right granularity, using context.select, and extracting focused widgets, you ensure that your UI reacts efficiently to state changes. These practices also improve code organization, making it easier to test and extend your app over time.

Try it yourself

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

void main() => runApp(MyApp());

// Counter Cubit
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
  void increment() => emit(state + 1);
}

// Split widgets
class CounterDisplay extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final count = context.select((CounterCubit cubit) => cubit.state);
    return Text('Count: $count', style: TextStyle(fontSize: 32));
  }
}

class IncrementButton extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () => context.read<CounterCubit>().increment(),
      child: Text('Increment'),
    );
  }
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => CounterCubit(),
        child: Scaffold(
          appBar: AppBar(title: Text('Split Widgets Demo')),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                CounterDisplay(),
                SizedBox(height: 20),
                IncrementButton(),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Test Your Knowledge

Q1
of 3

What is the primary benefit of placing BlocBuilder as deep as possible?

A
It improves code readability
B
It reduces unnecessary rebuilds
C
It makes testing easier
D
It allows using more widgets
Q2
of 3

Which method allows listening to a specific part of the state without a BlocBuilder?

A
BlocProvider.of
B
context.select
C
BlocListener
D
buildWhen
Q3
of 3

How should a child widget access a BLoC that is provided higher up?

A
Pass it through constructor
B
Use context.read or context.select
C
Create a new instance
D
Use GlobalKey

Frequently Asked Questions

When should I use `context.select` instead of `BlocBuilder`?

Use context.select when you need to listen to a single property and want minimal boilerplate. Use BlocBuilder when you need more control (e.g., buildWhen) or when you need the full state.

Can I split widgets that use the same BLoC across different files?

Yes, that's encouraged. Each widget can access the BLoC via context.read or context.select as long as the BLoC is provided higher up.

How many `BlocBuilder`s is too many?

There's no fixed limit; aim for as many as needed to isolate rebuilds. Each BlocBuilder that listens to different parts of the state is beneficial.

Should I use `const` constructors for split widgets?

Yes, whenever possible, use const constructors to avoid unnecessary rebuilds of stateless widgets.

How do I test a split widget that uses `context.read`?

Wrap it in BlocProvider.value with a mock BLoC inside your test. Use blocTest for the BLoC itself and widget tests for the UI.

Previous

bloc buildwhen listenwhen

Next

bloc side effects

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.