HomeDocumentationc_001_architectural-insigh
c_001_architectural-insigh
19

Flutter BLoC + GetIt: State Management with Clean Architecture (2026 Guide)

Everything about BLoC and GetIt in Flutter — with dependency injection, and common mistakes. A guide written for real production apps.

February 25, 2026

Introduction

You start a Flutter project. One screen, one setState, zero problems.

Three months later, you have fourteen screens, a navigation stack that feels like spaghetti, and a HomePage widget that somehow knows about the user session, the shopping cart, and three different API call results simultaneously. You add a small feature. Two unrelated things break. You spend an afternoon figuring out why.

This is not a Flutter problem. It's not even really a skill problem. It's an architecture problem, and nearly every Flutter team hits it at some point. The app starts simple, grows organically, and before anyone notices, the codebase becomes genuinely hard to reason about.

BLoC and GetIt don't solve this by magic. But applied together with a clean architecture mindset, they give you a structure that holds up as your app grows — one where adding a feature doesn't break something else, and where you can actually trace the cause of a bug without reading half the codebase.

This is the stack we use at Amazing Resources for production mobile apps. This article is the guide we wish existed when we were getting started — practical, direct, with real code, common pitfalls, and enough depth to be useful whether you're a beginner or you've been shipping Flutter apps for years.

Clean Architecture in Flutter: What It Means in Practice

The term "clean architecture" gets thrown around a lot. Let's be concrete about what it actually means for a Flutter app.

The core idea comes from Uncle Bob's Dependency Rule: inner layers should not depend on outer layers. In Flutter, this translates into three layers with clear responsibilities:

  • Domain Layer — the heart of the app. Pure Dart. No Flutter imports, no HTTP clients, no databases. This is where you define what your app does, not how. Entities (your core data models), repository interfaces (contracts for data access), and use cases (single, focused business operations) all live here.
  • Data Layer — the outer implementation. Talks to APIs, local databases, shared preferences. Contains concrete implementations of the repository interfaces defined in the domain layer. Converts raw data (JSON, SQL rows) into domain entities.
  • Presentation Layer — what the user sees. Contains your BLoCs and Cubits, your page widgets, and your reusable UI components. Calls use cases from the domain layer, reacts to the states they emit.

The rule that makes this work: data flow goes inward, dependencies point inward. The domain layer knows nothing about the data layer or the presentation layer. The presentation layer knows about the domain layer (use cases) but nothing about HTTP clients or databases.

Where does GetIt fit? It's the one place that does know about all three layers — because its job is to wire them together. More on that soon.

A Minimal Folder Structure

Don't over-engineer the folder structure early. Here's one that works well for feature-based modules:

javascript
lib/
├── core/
│   ├── error/
│   │   └── failures.dart           # App-wide failure types
│   └── usecases/
│       └── usecase.dart            # Abstract UseCase base (optional but tidy)
│
├── features/
│   └── posts/
│       ├── data/
│       │   ├── datasources/        # Remote & local data sources
│       │   ├── models/             # JSON-serializable versions of entities
│       │   └── repositories/       # Concrete repository implementations
│       ├── domain/
│       │   ├── entities/           # Pure Dart data classes
│       │   ├── repositories/       # Abstract interfaces (contracts)
│       │   └── usecases/           # Business operations
│       └── presentation/
│           ├── bloc/               # BLoC/Cubit + Event + State files
│           └── pages/              # Screen widgets
│
├── injection_container.dart        # GetIt wiring — the only file that imports get_it
└── main.dart
Pro Tip
Feature-first structure (organizing by feature, not by layer) is almost always the right call for anything beyond a toy app.

When you need to delete or modify the posts feature, everything related to it is in one folder. You don't have to hunt across models/, repositories/, and controllers/ scattered through separate top-level directories.

This structure isn't rigid. For a small app, you might not need all these subfolders. Start with what you actually need and let the structure grow as the code warrants it.

The State Management Problem

Let's be honest about why state management is hard.

Flutter rebuilds widgets — that's the whole model. When something changes, you call setState, the framework rebuilds the relevant subtree, and the user sees the new state. For a single counter, this is perfect. Simple, fast, obvious.

The cracks appear when state needs to be shared. A user session that a dozen screens need to know about. A shopping cart that updates while you're browsing. A real-time data feed that comes in over WebSocket. Now setState stops being a tool and starts being a trap:

  1. State lives in widgets — but widgets have lifecycles. When a widget is destroyed, its state is gone. Moving to a different screen and back? You're starting fresh.
  2. Prop drilling — to share state between two sibling widgets, you lift it to their nearest common ancestor. That ancestor now passes it down through layers of intermediate widgets that don't actually need it. Two months later, every widget in the tree has a dozen parameters it's just passing through.
  3. Tangled logic — UI concerns (is the button in a loading state?) and business concerns (is this user even allowed to do this?) end up in the same widget. Testing becomes hard. Reasoning becomes harder.
  4. No single source of truth — local widget state, API caches, and user input are all living in different places. It's genuinely unclear what the real state of the app is at any given moment.

Flutter developers have found lots of answers to these problems. Provider, Riverpod, GetX, MobX, plain ChangeNotifier — each approach has a home, a set of tradeoffs, and a community that swears by it.

BLoC + GetIt occupies a specific position in that landscape. It's more verbose than Provider and more explicit than GetX. It asks you to define events, states, and wiring up front. That overhead is a real cost. But in return, you get:

  • A strict, enforced separation between UI and business logic
  • Unidirectional data flow where every state change has a traceable cause
  • BLoCs that are pure Dart classes — testable without a widget in sight
  • A dependency injection setup that makes the entire app's wiring visible in one file
  • No BuildContext required to access services from anywhere in the app

This is why BLoC + GetIt tends to show up in teams building apps that need to last — apps with complex business rules, multiple developers, and clients who aren't planning a rewrite anytime soon.

Note: We're deliberately not covering Riverpod here. It's a strong alternative with different tradeoffs, and it deserves its own article. If you're choosing a state management approach for a new project, it's worth understanding both, side by side, before committing. This article focuses on the BLoC + GetIt combo.

What Is GetIt?

GetIt is a service locator for Dart and Flutter. It solves one specific, practical problem:

How do you get an instance of a service — a repository, an API client, a use case — from somewhere deep in your app, without threading it through every constructor and function call in between?

It works like a global, type-safe registry. You register objects once (usually at app startup), and retrieve them by type from anywhere.

javascript
# pubspec.yaml
dependencies:
  get_it: ^9.2.1
javascript
// lib/injection_container.dart
import 'package:get_it/get_it.dart';

final sl = GetIt.instance;

Future<void> init() async {
  // Register all your services, repositories, and BLoCs here
}
javascript
// lib/main.dart
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await init(); // Run GetIt setup before anything else
  runApp(const MyApp());
}

The Three Registration Types

This is where most of the important decisions happen. Getting these right makes everything else fall into place.

`registerSingleton` — Eager, Lives Forever

javascript
sl.registerSingleton<AppRouter>(AppRouter());

The instance is created immediately when you call this. Every future sl<AppRouter>() returns the same object. Use this for things you know will always be needed and are cheap to initialize.

`registerLazySingleton` — Lazy, Lives Forever

javascript
sl.registerLazySingleton<UserRepository>(
  () => UserRepositoryImpl(remoteDataSource: sl()),
);

The factory function runs on the first call to sl<UserRepository>(). After that, the instance is cached and reused. This is your default for services, repositories, and use cases — the things that should exist once and persist for the app's lifetime.

javascript
Type here...

`registerFactory` — Fresh Instance Every Time

javascript
sl.registerFactory<PostBloc>(
  () => PostBloc(getPostsUseCase: sl()),
);

Every sl<PostBloc>() creates a brand-new instance. This is what you always use for BLoCs. Every single one. Here's why this matters so much:

BlocProvider creates your BLoC when the widget mounts, and calls close() on it when the widget unmounts. If GetIt returned a singleton BLoC, that BLoC would be closed after the first widget using it is destroyed — and then returned closed to the next widget that asks for it. That's a crash.

More subtly: a singleton BLoC would accumulate state across route visits. Navigate away, come back, and you're looking at whatever state the BLoC was in when you left. Sometimes that's what you want. Usually it isn't, and it causes confusing bugs that are hard to trace.

Pro Tip
Registering BLoCs as singletons instead of factories is the single most common GetIt + BLoC mistake.
Pro Tip
If you remember nothing else from this section, remember: BLoCs go in registerFactory. Always.

Async Initialization

Some services need to be awaited before they're usable — SharedPreferences, Hive, a database connection:

javascript
sl.registerSingletonAsync<SharedPreferences>(
  () async => await SharedPreferences.getInstance(),
);

// If ApiClient depends on SharedPreferences being ready first:
sl.registerSingletonAsync<ApiClient>(
  () async {
    final prefs = sl<SharedPreferences>();
    return ApiClient(token: prefs.getString('auth_token'));
  },
  dependsOn: [SharedPreferences], // Waits for SharedPreferences to finish
);

Then before runApp():

javascript
await sl.allReady(); // Blocks until all async singletons have initialized
Pro Tip
Use the dependsOn parameter when one async service depends on another. It makes the initialization order explicit and prevents the kind of subtle timing bug that only appears in production when startup is slow.

What Is BLoC?

BLoC stands for Business Logic Component. The pattern is built around one constraint: your UI doesn't mutate state directly. Instead:

  1. The UI dispatches an Event to the BLoC
  2. The BLoC handles the event (calls a use case, processes data, makes a decision)
  3. The BLoC emits a new State
  4. The UI reacts to the new state and rebuilds
javascript
UI  ──(dispatches Event)──▶  BLoC  ──(emits State)──▶  UI rebuilds

This is unidirectional data flow. There's exactly one way for the app's state to change, and it goes through the BLoC. This constraint — which can feel like ceremony at first — is what makes BLoC code so traceable and testable.

javascript
# pubspec.yaml
dependencies:
  flutter_bloc: ^9.1.1

Cubit vs BLoC — Which One Do You Need?

The flutter_bloc package actually gives you two tools: Cubit and BLoC. They're related but not the same.

Cubit is the simpler one. You call methods on it directly:

javascript
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0); // Initial state is 0

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
  void reset() => emit(0);
}

// In the UI:
context.read<CounterCubit>().increment();

BLoC is event-driven. The UI dispatches typed event objects:

javascript
// Events (sealed class — Dart 3 pattern)
sealed class CounterEvent {}
class IncrementPressed extends CounterEvent {}
class DecrementPressed extends CounterEvent {}

// BLoC
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<IncrementPressed>((event, emit) => emit(state + 1));
    on<DecrementPressed>((event, emit) => emit(state - 1));
  }
}

// In the UI:
context.read<CounterBloc>().add(IncrementPressed());

The difference isn't just syntax — it's about the audit trail. In a BLoC, every state change comes from a typed event. You can log every event, replay sequences, and understand exactly what triggered a particular state. In a Cubit, you just see the state change — the call that caused it isn't represented as data.

Practical decision guide:

Use Cubit when:

  • State is simple and UI-adjacent
  • A method call is obvious enough
  • Form validation, tab state, modal visibility
  • You're moving fast on a small feature

Use BLoC when:

  • Business logic is complex
  • You need to trace "what caused this?"
  • Authentication flows, payments, multi-step processes >You're on a team and debugging matters

You don't have to pick one for your whole app.

A LoginFormCubit that manages which fields are focused and whether the submit button is active, alongside an AuthBloc that handles the actual login/logout domain flow — that's a great pattern. Use the right tool for each job.

Defining States Properly

Your state classes are the contract between your BLoC and your UI. Dart 3's sealed classes are the cleanest way to do this — they let you exhaustively pattern-match and give you a compile-time warning if you add a new state variant and forget to handle it somewhere:

javascript
// post_state.dart
import 'package:equatable/equatable.dart';
import '../../domain/entities/post.dart';

sealed class PostState extends Equatable {
  const PostState();

  @override
  List<Object?> get props => [];
}

class PostInitial extends PostState {}

class PostLoading extends PostState {}

class PostLoaded extends PostState {
  final List<Post> posts;
  const PostLoaded(this.posts);

  @override
  List<Object?> get props => [posts];
}

class PostError extends PostState {
  final String message;
  const PostError(this.message);

  @override
  List<Object?> get props => [message];
}

Why Equatable? Without value equality, BLoC's change detection compares state instances by reference. Two PostError('same message') objects look like different states, which causes either unexpected rebuilds or missed updates depending on timing. Equatable (or freezed, if you want the full immutability story) fixes this.

And in the UI, pattern matching becomes clean and exhaustive:

javascript
return switch (state) {
  PostInitial() => const SizedBox.shrink(),
  PostLoading() => const Center(child: CircularProgressIndicator()),
  PostLoaded(:final posts) => _PostList(posts: posts),
  PostError(:final message) => _ErrorView(message: message),
};

The Four Flutter Widgets You Need to Know

`BlocProvider` — creates a BLoC and provides it to the widget subtree. Closes the BLoC automatically when the widget unmounts:

javascript
BlocProvider(
  create: (_) => sl<PostBloc>()..add(PostsFetchRequested()),
  child: const PostsView(),
)

`BlocBuilder` — rebuilds part of the UI when the BLoC emits a new state. Add buildWhen to filter unnecessary rebuilds:

javascript
BlocBuilder<PostBloc, PostState>(
  buildWhen: (previous, current) => current is! PostLoading, // skip rebuilds during loading
  builder: (context, state) {
    return switch (state) { /* ... */ };
  },
)

`BlocListener` — runs side effects on state changes, without triggering a widget rebuild. Use this for navigation, snackbars, dialogs — anything that happens once in response to a state:

javascript
BlocListener<AuthBloc, AuthState>(
  listenWhen: (_, current) => current is AuthError,
  listener: (context, state) {
    if (state is AuthError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text((state as AuthError).message)),
      );
    }
  },
  child: const LoginForm(),
)

Never put navigation, dialogs, or snackbars inside BlocBuilder's builder function. The builder can run multiple times.

Side effects that should happen once go in BlocListener. This is one of the most common early BLoC mistakes.

`BlocConsumer` — when you need both a rebuild and a side effect from the same state change. Combines BlocBuilder and BlocListener in one widget:

javascript
BlocConsumer<LoginBloc, LoginState>(
  listenWhen: (_, current) => current is LoginSuccess || current is LoginFailure,
  listener: (context, state) {
    if (state is LoginSuccess) Navigator.pushReplacementNamed(context, '/home');
    if (state is LoginFailure) showErrorDialog(context);
  },
  builder: (context, state) {
    return state is LoginLoading
        ? const CircularProgressIndicator()
        : const LoginButton();
  },
)

Bonus: `BlocSelector` — if you only want to rebuild when a specific field of your state changes, without writing a full buildWhen:

javascript
BlocSelector<ProfileBloc, ProfileState, String>(
  selector: (state) => state.username,
  builder: (context, username) => Text(username),
)

How BLoC and GetIt Work Together

Here's the thing about BLoC and GetIt: they don't compete. They solve completely different problems. In advanced setups, BLoC doesn't compete with Riverpod either.

BLoC manages what state the app is in and how it changes in response to events. It knows nothing about how to find or construct its own dependencies.

GetIt manages how objects are created and provided across the app. It knows nothing about reactive state.

They fit together naturally: GetIt provides the dependencies that BLoC's constructor needs, and BlocProvider bridges GetIt's world (type-safe registry) with BLoC's world (widget-tree lifecycle management).

javascript
// GetIt creates the BLoC with its dependencies injected
BlocProvider(
  create: (_) => sl<PostBloc>(), // sl<PostBloc>() calls registerFactory, fresh instance
  child: const PostsView(),
)
  1. That single line does a lot:
  2. sl<PostBloc>() calls your registerFactory, creating a new PostBloc with all its dependencies already injected by GetIt
  3. BlocProvider takes ownership of that instance's lifecycle
  4. When PostsView unmounts, BlocProvider closes the BLoC
  5. Next time someone asks for a PostBloc, GetIt creates a fresh one

The injection_container.dart file is where it all comes together. It's the only file in your project that needs to know about GetIt — and it's the single, authoritative declaration of how your entire app is wired:

javascript
// lib/injection_container.dart
final sl = GetIt.instance;

Future<void> init() async {
  // ── Presentation (factories — always fresh for BLoCs) ────────────
  sl.registerFactory<PostBloc>(
    () => PostBloc(getPostsUseCase: sl()),
  );

  // ── Domain (lazy singletons — created once, reused) ──────────────
  sl.registerLazySingleton<GetPostsUseCase>(
    () => GetPostsUseCase(sl()),
  );

  // ── Data ─────────────────────────────────────────────────────────
  sl.registerLazySingleton<PostRepository>(
    () => PostRepositoryImpl(remoteDataSource: sl()),
  );
  sl.registerLazySingleton<PostRemoteDataSource>(
    () => PostRemoteDataSourceImpl(client: sl()),
  );

  // ── External ─────────────────────────────────────────────────────
  sl.registerLazySingleton<http.Client>(() => http.Client());
}

Register from the inside out: presentation → domain → data → external. Each layer's objects are registered before the things that depend on them.

A Minimal Working Example

Enough concepts. Let's build something real. A simple app that fetches posts from a public API, displays them in a list, and handles loading and error states cleanly.

Add dependencies first:

javascript
# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^9.1.1
  get_it: ^9.2.1
  equatable: ^2.0.5
  http: ^1.2.1

Step 1: The Domain Layer (Pure Dart)

No imports from Flutter or third-party packages here — just plain Dart:

javascript
// lib/features/posts/domain/entities/post.dart
class Post {
  final int id;
  final String title;
  final String body;

  const Post({required this.id, required this.title, required this.body});
}
javascript
// lib/features/posts/domain/repositories/post_repository.dart
import '../entities/post.dart';

abstract interface class PostRepository {
  Future<List<Post>> getPosts();
}
javascript
// lib/features/posts/domain/usecases/get_posts_usecase.dart
import '../entities/post.dart';
import '../repositories/post_repository.dart';

class GetPostsUseCase {
  final PostRepository repository;

  GetPostsUseCase(this.repository);

  // Callable class pattern — use it like a function: await getPosts()
  Future<List<Post>> call() => repository.getPosts();
}

The call() method lets you invoke the use case like a function (await getPostsUseCase()) instead of await getPostsUseCase.execute() or something similar. It's a small thing, but it reads cleanly in BLoC handlers.

Step 2: The Data Layer

javascript
// lib/features/posts/data/models/post_model.dart
import '../../domain/entities/post.dart';

class PostModel extends Post {
  const PostModel({required super.id, required super.title, required super.body});

  factory PostModel.fromJson(Map<String, dynamic> json) => PostModel(
        id: json['id'] as int,
        title: json['title'] as String,
        body: json['body'] as String,
      );
}
javascript
// lib/features/posts/data/datasources/post_remote_datasource.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/post_model.dart';

abstract interface class PostRemoteDataSource {
  Future<List<PostModel>> fetchPosts();
}

class PostRemoteDataSourceImpl implements PostRemoteDataSource {
  final http.Client client;

  PostRemoteDataSourceImpl({required this.client});

  @override
  Future<List<PostModel>> fetchPosts() async {
    final response = await client.get(
      Uri.parse('https://jsonplaceholder.typicode.com/posts'),
    );

    if (response.statusCode == 200) {
      final List<dynamic> json = jsonDecode(response.body);
      return json.map((e) => PostModel.fromJson(e as Map<String, dynamic>)).toList();
    }

    throw Exception('Failed to fetch posts (status ${response.statusCode})');
  }
}
javascript
// lib/features/posts/data/repositories/post_repository_impl.dart
import '../../domain/entities/post.dart';
import '../../domain/repositories/post_repository.dart';
import '../datasources/post_remote_datasource.dart';

class PostRepositoryImpl implements PostRepository {
  final PostRemoteDataSource remoteDataSource;

  PostRepositoryImpl({required this.remoteDataSource});

  @override
  Future<List<Post>> getPosts() async {
    return await remoteDataSource.fetchPosts();
  }
}

Step 3: The BLoC

javascript
// lib/features/posts/presentation/bloc/post_event.dart

sealed class PostEvent {}

class PostsFetchRequested extends PostEvent {}
javascript
// lib/features/posts/presentation/bloc/post_state.dart
import 'package:equatable/equatable.dart';
import '../../domain/entities/post.dart';

sealed class PostState extends Equatable {
  const PostState();

  @override
  List<Object?> get props => [];
}

class PostInitial extends PostState {}

class PostLoading extends PostState {}

class PostLoaded extends PostState {
  final List<Post> posts;

  const PostLoaded(this.posts);

  @override
  List<Object?> get props => [posts];
}

class PostError extends PostState {
  final String message;

  const PostError(this.message);

  @override
  List<Object?> get props => [message];
}
javascript
// lib/features/posts/presentation/bloc/post_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_posts_usecase.dart';
import 'post_event.dart';
import 'post_state.dart';

class PostBloc extends Bloc<PostEvent, PostState> {
  final GetPostsUseCase _getPosts;

  PostBloc({required GetPostsUseCase getPostsUseCase})
      : _getPosts = getPostsUseCase,
        super(PostInitial()) {
    on<PostsFetchRequested>(_onFetchRequested);
  }

  Future<void> _onFetchRequested(
    PostsFetchRequested event,
    Emitter<PostState> emit,
  ) async {
    emit(PostLoading());
    try {
      final posts = await _getPosts();
      emit(PostLoaded(posts));
    } catch (e) {
      emit(PostError('Could not load posts. Please try again.'));
    }
  }
}

Notice the handler is a separate private method (_onFetchRequested) rather than an inline lambda. This keeps BLoCs readable as they grow — once you have four or five event handlers, inline lambdas become a mess.

Step 4: Wire Everything with GetIt

javascript
// lib/injection_container.dart
import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;

import 'features/posts/data/datasources/post_remote_datasource.dart';
import 'features/posts/data/repositories/post_repository_impl.dart';
import 'features/posts/domain/repositories/post_repository.dart';
import 'features/posts/domain/usecases/get_posts_usecase.dart';
import 'features/posts/presentation/bloc/post_bloc.dart';

final sl = GetIt.instance;

Future<void> init() async {
  // ── Presentation ─────────────────────────────────────────────────
  // registerFactory: every BlocProvider gets a fresh PostBloc
  sl.registerFactory<PostBloc>(
    () => PostBloc(getPostsUseCase: sl()),
  );

  // ── Domain ───────────────────────────────────────────────────────
  sl.registerLazySingleton<GetPostsUseCase>(
    () => GetPostsUseCase(sl()),
  );

  // ── Data ─────────────────────────────────────────────────────────
  // Note: we register PostRepository (the abstract type), returning the impl
  sl.registerLazySingleton<PostRepository>(
    () => PostRepositoryImpl(remoteDataSource: sl()),
  );

  sl.registerLazySingleton<PostRemoteDataSource>(
    () => PostRemoteDataSourceImpl(client: sl()),
  );

  // ── External ─────────────────────────────────────────────────────
  sl.registerLazySingleton<http.Client>(() => http.Client());
}

Notice how the abstract type (PostRepository) is what's registered, not the concrete implementation (PostRepositoryImpl). When anything calls sl<PostRepository>(), it gets the implementation — but nobody in the app knows or cares which implementation it is. This is what makes swapping implementations for testing so effortless.

javascript
// lib/main.dart
import 'package:flutter/material.dart';
import 'injection_container.dart' as di;
import 'features/posts/presentation/pages/posts_page.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await di.init();
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'BLoC + GetIt Demo',
      home: PostsPage(),
    );
  }
}

Step 5: The UI

javascript
//lib/features/posts/presentation/pages/posts_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../injection_container.dart';
import '../bloc/post_bloc.dart';
import '../bloc/post_event.dart';
import '../views/posts_view.dart';

/// PostsPage is responsible for providing the BLoC to the subtree.
/// 
/// This widget handles BLoC instantiation and initialization,
/// triggering the first data fetch immediately.
class PostsPage extends StatelessWidget {
  const PostsPage({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      // GetIt creates the BLoC; the cascade triggers the first fetch immediately
      create: (_) => sl<PostBloc>()..add(PostsFetchRequested()),
      child: const PostsView(),
    );
  }
}

That's a complete, runnable clean architecture Flutter app. Three layers, proper dependency injection, reactive state management, loading/error/success states. You can run it as-is against jsonplaceholder.typicode.com.

javascript
//lib/features/posts/presentation/views/posts_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/post_bloc.dart';
import '../bloc/post_state.dart';
import '../widgets/posts_loaded_widget.dart';
import '../widgets/posts_error_widget.dart';
import '../widgets/posts_app_bar.dart';
import '../widgets/posts_refresh_button.dart';

/// PostsView is responsible for reacting to the BLoC's state
/// and orchestrating the overall layout.
class PostsView extends StatelessWidget {
  const PostsView({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: const PostsAppBar(),
      body: BlocBuilder<PostBloc, PostState>(
        builder: (context, state) {
          return switch (state) {
            PostInitial() => const SizedBox.shrink(),
            PostLoading() => const Center(child: CircularProgressIndicator()),
            PostLoaded(:final posts) => PostsLoadedWidget(posts: posts),
            PostError(:final message) => PostsErrorWidget(message: message),
          };
        },
      ),
      floatingActionButton: const PostsRefreshButton(),
    );
  }
}
javascript
//lib/features/posts/presentation/widgets/posts_loaded_widget.dart
import 'package:flutter/material.dart';
import '../../domain/entities/post.dart';

/// PostsLoadedWidget displays a list of posts in a ListView.
class PostsLoadedWidget extends StatelessWidget {
  final List<Post> posts;

  const PostsLoadedWidget({
    super.key,
    required this.posts,
  });

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: posts.length,
      itemBuilder: (_, i) => ListTile(
        title: Text(
          posts[i].title,
          style: const TextStyle(fontWeight: FontWeight.w600),
        ),
        subtitle: Text(
          posts[i].body,
          maxLines: 2,
          overflow: TextOverflow.ellipsis,
        ),
      ),
    );
  }
}
javascript
//lib/features/posts/presentation/widgets/posts_error_widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/post_bloc.dart';
import '../bloc/post_event.dart';

/// PostsErrorWidget displays an error message with a retry button.
class PostsErrorWidget extends StatelessWidget {
  final String message;

  const PostsErrorWidget({
    super.key,
    required this.message,
  });

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Icon(Icons.error_outline, size: 48, color: Colors.red),
          const SizedBox(height: 12),
          Text(message),
          const SizedBox(height: 16),
          ElevatedButton(
            onPressed: () =>
                context.read<PostBloc>().add(PostsFetchRequested()),
            child: const Text('Try again'),
          ),
        ],
      ),
    );
  }
}
javascript
//lib/features/posts/presentation/widgets/posts_app_bar.dart
import 'package:flutter/material.dart';

/// PostsAppBar displays the app bar for the posts page.
class PostsAppBar extends StatelessWidget implements PreferredSizeWidget {
  const PostsAppBar({super.key});

  @override
  Widget build(BuildContext context) {
    return AppBar(title: const Text('Posts'));
  }

  @override
  Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
javascript
//lib/features/posts/presentation/widgets/posts_refresh_button.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../bloc/post_bloc.dart';
import '../bloc/post_event.dart';

/// PostsRefreshButton is a floating action button that triggers a post refresh.
class PostsRefreshButton extends StatelessWidget {
  const PostsRefreshButton({super.key});

  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: () => context.read<PostBloc>().add(PostsFetchRequested()),
      child: const Icon(Icons.refresh),
    );
  }
}
Pro Tip
This structure makes each widget single-responsibility, highly reusable, and easy to test. You can now test, modify, or reuse individual components independently.

Related Topics

flutter blocflutter getitflutter state managementflutter clean architectureflutter dependency injectionflutter bloc common mistakes

Ready to build your app?

Turn your ideas into reality with our expert mobile app development services.