Introduction
In frontend projects with modern frameworks like Next.js/ReactJs, it is common that the business logic ends up mixed with the presentation. As the project grows, this makes maintenance, testing and code organization difficult.
The BloC pattern proposes a clear separation between the interface and the logic, helping to centralize state handling and to respond in a controlled way to user events. Although it was born in Flutter, it is an agnostic pattern, easy to apply in any JavaScript environment.
To make this decoupling real and sustainable, in this article we will combine BloC with hex architecture. This architecture allows the core of the application to be independent of external details such as UI libraries or infrastructure, facilitating extensibility, testing and scalability.
What problems does BloC help solve?
- Clearly separates business logic from the interface.
- Allows the definition of explicit and controllable state flows.
- It facilitates testing in isolation, without assembling components.
- It fits naturally in decoupled architectures such as hexagonal.
And what are the disadvantages?
- It increases the structure of the project, even in simple cases.
- Requires the team to have a good understanding of the pattern and its purpose.
- It can lead to over modeling if used unnecessarily.
Throughout the article we will see how to apply BloC, using a clean and practical architecture that facilitates teamwork and code growth without falling into unnecessary complexities.
Table of Contents
- What is the BloC pattern?
- How do you implement a BloC?
- What the code looks like
- Practical cases of using the BloC pattern in React
- Case study: Product search engine in e-commerce
1. What is the BloC pattern?
The BloC (Business Logic Component) pattern is a structured way of handling business logic and application states in response to events. Its main objective is to centralize logic outside the UI, promoting a cleaner and more predictable architecture.
In simple terms, a BloC acts as an intermediary between the UI and the state. The UI triggers events (e.g., the user clicks a button), and the BloC decides how to respond, updating the state of the application. When the state changes, the UI is automatically re-rendered. This cycle is always kept clear and controlled.
We can represent it visually like this:
- UI triggers an event.
- Event is received by the BloC.
- BloC processes the event and decides what new state to emit.
- Updated state notifies the UI, which reacts accordingly.
This approach allows to completely decouple the presentation from the logic, and is especially useful when the state flow can grow in complexity. It is also naturally suited to more robust architectures, such as hex, by allowing the BloC to rely on abstractions rather than directly on details such as networking or storage.
2. How do you implement a BloC?
Before the code: how to design with BloC in mind
Applying the BloC pattern is not simply about writing classes or moving logic out of components. It is a different way of thinking about the design of our interfaces and how they connect to business logic.
BloC is not a library or a framework. It is a pattern, a structured way of designing flows where events generated by the UI produce state transitions handled from an external component.
Step 1: identify user actions
Before writing a single line of code, you need to understand the expected behavior of the UI. Ask yourself:
- What can the user do on this screen?
- What actions can it trigger (e.g. press a button, fill out a form, navigate to a screen)?
- What things "happen on their own" (e.g. load data on login)?
These actions are the basis for defining BloC events.
Example: in a login form, the actions could be:
- Enter email and password
- Press the "Login" button
- See an error message if the credentials are invalid.
Step 2: define possible states
Once the events have been identified, it is necessary to think about the possible states in which the interface can be depending on how these events evolve.
In the login example, the states could be:
-
Idle(initial screen, no interaction) -
Submitting(form being submitted) -
Success(successful login) -
Error(invalid credentials)
You will notice that this already allows you to visualize the cycle: the user presses a button → the BloC issues a submitting status → receives response from the backend → the BloC issues success or error.
Step 3: define and build BloCs
With clear events and states, the next step is to decide how many BloCs you need and develop their logic.
- A single BloC for the entire screen or multiple BloCs for separate flows?
- Are events and states part of the same conceptual flow?
- Is it worth splitting to keep the code focused and decoupled?
One useful criterion: if events affect conceptually distinct states, they should be in separate BloCs.
Once defined, the BloC is implemented: each event is assigned a behavior that produces a new state. This is where the orchestration of the flow occurs: validations, business decisions, timers or side effects (such as API calls or event publication).
Step 4: connecting to the infrastructure (using hexagonal architecture)
So far we have kept everything in the core of the application. But many times an event triggers an effect that requires access to external resources: an HTTP call, a local save, an external event...
To maintain this decoupling, this is where the hexagonal architecture comes in: the BloC is connected with interfaces(ports), and the concrete implementations(adapters) are resolved outside the BloC.
This allows:
- Test the BloC without connecting to real services.
- Change implementations (e.g. from REST to GraphQL) without rewriting the logic.
- Keep the logic focused on the domain and not on technical details.
Step 5: make the UI component reactive to the BloC
Finally, you need to connect the BloC to the UI so that the UI observes and reacts to state changes.
In React, this is accomplished by subscribing the component to the BloC so that whenever the state changes, the UI is automatically updated. In this way, the view remains decoupled from the logic and is only responsible for displaying what the BloC indicates.
This step ensures that the UI is reactive and maintains a single source of truth: the state managed by the BloC.
3. What the code looks like: defining the state of the BloC
The centerpiece of any BloC is its state, as it represents what the UI should display at any given moment. Applying the pattern correctly requires you to explicitly model what types of state your component can have, and what data is available in each.
Instead of using a single object with optional fields({ loading: boolean; data?: T; error?: string }), in BloC we use a technique called discriminated union: each state is represented as a variant with a specific type, which improves flow control, readability and type safety.
Below is a base definition covering the most common states in frontend flows:
export type ErrorDetails = {
message: string;
code?: string;
};
export enum BaseStateKind {
IdleState,
LoadingState,
LoadedState,
ErrorState,
CreatingState,
EditingState,
SavingState,
SubmittedState,
ConfirmingDeleteState,
DeletingState,
DeletedState,
}
export interface IdleState {
kind: BaseBlocStateKind.IdleState;
}
export interface LoadingState {
kind: BaseBlocStateKind.LoadingState;
}
export interface LoadedState<T> {
kind: BaseBlocStateKind.LoadedState;
data: T;
}
export interface ErrorState {
kind: BaseBlocStateKind.ErrorState;
error: ErrorDetails;
}
export interface CreatingState<T, Draft = Partial<T>> {
kind: BaseBlocStateKind.CreatingState;
draft: Draft;
}
export interface EditingState<T, Draft = Partial<T>> {
kind: BaseBlocStateKind.EditingState;
original: T;
draft: Draft;
}
export interface SavingState<T, Draft = Partial<T>> {
kind: BaseBlocStateKind.SavingState;
draft: Draft;
isNew: boolean;
}
export interface SubmittedState<T> {
kind: BaseBlocStateKind.SubmittedState;
data: T;
}
export interface ConfirmingDeleteState<T> {
kind: BaseBlocStateKind.ConfirmingDeleteState;
data: T;
}
export interface DeletingState<T> {
kind: BaseBlocStateKind.DeletingState;
data: T;
}
export interface DeletedState {
kind: BaseBlocStateKind.DeletedState;
}
export type BaseState<T, Draft = Partial<T>> =
| IdleState
| LoadingState
| LoadedState<T>
| CreatingState<T, Draft>
| EditingState<T, Draft>
| SavingState<T, Draft>
| SubmittedState<T>
| ConfirmingDeleteState<T>
| DeletingState<T>
| DeletedState
| ErrorState;
Why use this approach?
- It is extensible: you can reuse these definitions in multiple BloCs or extend them according to the domain.
- It's descriptive: each state carries only the data it needs, which avoids ill-defined conditions or null values.
What if I need something more specific?
This set of states is neither an obligation nor a closed standard. You can create your own states for your needs (e.g. FilteringState, PaginatedState, UploadingState, etc.) or reduce this list if you are working on simpler flows.
Later, we will see how this state is used in conjunction with events to complete the BloC cycle.
Ploc base class: the core of our BloC
To implement BloC in an environment like React, we don't need a specific library or bring unnecessary complexity. The important thing is to have a common structure that allows us to react to events and propagate state changes, while maintaining decoupling.
In this case, we have defined a generic base class called Ploc (Presentation Logic Component), which encapsulates this reactive logic:
type Subscription<S> = (state: S) => void;
export default abstract class Ploc<S> {
private internalState: S;
private listeners: Subscription<S>[] = [];
constructor(initalState: S) {
this.internalState = initalState;
}
public get state(): S {
return this.internalState;
}
changeState(state: S) {
this.internalState = state;
if (this.listeners.length > 0) {
this.listeners.forEach(listener => listener(this.state));
}
}
subscribe(listener: Subscription<S>) {
this.listeners.push(listener);
}
unsubscribe(listener: Subscription<S>) {
const index = this.listeners.indexOf(listener);
if (index > -1) {
this.listeners.splice(index, 1);
}
}
}
What does this class do?
- It maintains an internal state
(internalState) of generic typeS. - It allows to subscribe to state changes
(subscribe), and also tounsubscribe (unsubscribe). - It exposes a
changeStatemethod, which updates the state and notifies all subscribers. - It provides a getter
stateto query the current state.
Why design it this way?
The idea is to isolate the presentation logic from the rest of the application. This class serves as the basis for each particular BloC to handle:
- a set of events (user or system actions),
- and an orchestration of states (what the UI should display).
The UI, instead of using useState directly, subscribes to the state of the Ploc. In this way, the component becomes passive: it simply renders what the BloC tells it to.
Where does it fit in the architecture?
In a hexagonal architecture, this class sits in the presentation layer and is fed with data from the application or domain layer. The UI observes the state of the BloC, which in turn can interact with use cases or application services.
BloC integration with React
So far, we have defined a BloC (or Ploc in our implementation) completely agnostic to any UI framework. This is one of its major advantages: we can test and reuse the logic without relying on the view. In this case, we are going to implement in React by leveraging the hooks system to achieve reactivity: React components can subscribe to changes in BloC and render automatically whenever their state changes.
Reactive view = decoupled UI
The BloC pattern encourages the view to observe BloC state changes. This implies that the UI should not contain logic, it should only react to what the BloC decides.
In React, this reactivity is easily achieved with useState and useEffect. Therefore, we use a custom hook that allows us to connect any Ploc to a React component:
import Ploc from "@modules/shared/presentation/Ploc";
import { useEffect, useState } from "react";
export function usePlocState<S>(ploc: Ploc<S>) {
const [state, setState] = useState(ploc.state);
useEffect(() => {
const stateSubscription = (state: S) => {
setState(state);
};
ploc.subscribe(stateSubscription);
return () => ploc.unsubscribe(stateSubscription);
}, [ploc]);
return state;
}
This hook does three things:
- It initializes the local state with the current state of the BloC.
- It subscribes to the BloC to receive notifications when it changes.
- Clears the subscription when unmounting the component.
Advantages
- Business logic does not live in the components.
- You can test BloC without rendering components.
- You can swap React for another framework and still use the same BloCs.
4. Practical cases of using the BloC pattern in React.
Although the BloC pattern may seem like a generic state management solution, not every case in a React application merits the use of a BloC. This pattern shines especially when certain conditions are met that make it a more suitable alternative to individual hooks or global solutions such as Redux or Zustand. Here we explore when it does, and when it doesn't.
✅ When to use BloC in React
-
Complex lifecycle management and state transitions.
- When the component goes through multiple clearly defined states (loading, displaying data, error, editing, saving, etc.).
- Example: a detail view that supports editing, validation, saving, and server response.
-
Need for decoupled component logic
- When you want to isolate the business logic outside the component, to facilitate unit testing or reuse.
- Example: the validation logic of a complex form or the handling of a multi-step wizard.
-
When multiple components must react to the same state
- Useful when multiple components are rendered based on the same state flow (without having to raise the state globally).
- Example: a progress bar that listens for changes in a loading or document generation process.
-
Implementing state machines or defined flows
- If you are modeling flows that can be represented as state machines, BloC allows you to encapsulate them and make them explicit.
- Example: an onboarding or approval flow that follows a sequence with intermediate steps and conditions.
-
Need to reuse logic in different views or contexts.
- BloC logic can be injected and used in different screens without duplicating code.
- Example: an
AuthBlocthat handles login, logout and authentication that can be used from different layouts.
❌ When not to use BloC in React.
-
Simple local states.
- If the component only needs to handle a few internal states (like opening/closing a modal or updating an input),
useStateanduseReducerare more than enough.
- If the component only needs to handle a few internal states (like opening/closing a modal or updating an input),
-
Flows with little behavior and a lot of presentation
- If the component is purely visual or the changes are trivial, using BloC may be unnecessary and even counterproductive.
-
If you already use an efficient global state solution.
- In some projects where Redux, Zustand or Jotai are well implemented and correctly centralize state, adding BloC may generate redundancy.
5. Case study: Product search engine in e-commerce
Let's imagine a search interface where the user can enter a term and get related products. This component should:
- Show a loading while searching.
- Show the results if everything goes well.
- Show errors if the search failed.
- Check for a possible empty state if there were no results.
This is perfect for BloC because there is a clear state management: idle, searching, success, empty, error, and we want to have the logic decoupled from the UI.
Step 1: Identify user actions.
Before writing code, the first thing is to understand how the user interacts with our component. In the case of a product finder, these are the main actions we want to capture:
🎯 Key user intents:
-
Typing a search in the text field.
- The user starts typing a keyword (for example:
"shoes"). - This action should trigger a search (possibly asynchronous).
- You may want to wait some time before executing the search (debounce).
- The user starts typing a keyword (for example:
-
Clear the search field
- The user deletes the text or presses a clear button.
- The interface should hide the results or display an appropriate message (such as "Start typing...").
-
Error handling
- For example, if there are problems with the internet connection or the server responds with error.
-
Empty status handling
- When there are no results for the search performed (for example:
"bluetooth moonball"), the system must communicate this clearly.
- When there are no results for the search performed (for example:
These user actions or events become the basis for the events that will be handled by the BloC.
Step 2: Define the BloC states.
In our product finder example, the states represent the "current moment" of the system, and guide what the interface should render. Based on the actions identified in the previous step, useful states could be:
📌 States we will use:
| State | Description |
|---|---|
Idle |
The user has not typed anything yet. |
Searching |
The user is typing or a search is being performed. |
ResultsLoaded |
Results were found for the search text. |
NoResults |
Search was performed but no results were found. |
Error |
An error occurred while searching. |
Now we move on to implement them in code. We have the base class BaseState as a discriminated union and a base type for states. Here, we make the implementation of ProductSearchState:
import { BaseState, BaseBlocStateKind } from "@modules/shared/presentation/BaseBloc.state";
import { Product } from "../domain/product.model";
export type ProductSearchState = BaseState<Product[]>;
export class ProductSearchStateFactory {
static idle(): ProductSearchState {
return { kind: BaseBlocStateKind.IdleState };
}
static loading(): ProductSearchState {
return { kind: BaseBlocStateKind.LoadingState };
}
static loaded(results: Product[]): ProductSearchState {
return { kind: BaseBlocStateKind.LoadedState, data: results };
}
static empty(query: string): ProductSearchState {
return { kind: BaseBlocStateKind.EmptyState, query };
}
static error(error: Error): ProductSearchState {
return { kind: BaseBlocStateKind.ErrorState, error };
}
}
// Default state
export const productSearchInitialState = ProductSearchStateFactory.idle();
This factory is a utility class that centralizes the creation of different states for the BloC. Instead of manually instantiating state objects throughout the code, the factory provides clear, descriptive methods to generate each state with the required data.
This approach offers several benefits:
- Consistency: All states are created in a uniform way, reducing the risk of missing or incorrect properties.
-
Readability: The method names clearly express the intent (e.g.,
loading(),loaded(data),error(error)), making the code easier to follow. - Maintainability: If the shape of a state changes, it only needs to be updated in one place — the factory — rather than scattered throughout the codebase.
- Type safety: By using TypeScript types, the factory ensures that the created states always conform to the expected structure.
In summary, the factory encapsulates the complexity of state creation, promoting cleaner and more maintainable BloC implementations.
Step 3: Define the BloC and its state logic
The BloC is responsible for:
- Receiving user actions (such as
onSearchQueryChanged). - Executing the associated logic
- Emitting the new states to which the view should react.
import Ploc from "@modules/shared/presentation/Ploc";
import {
ProductSearchState,
ProductSearchStateFactory
} from "./ProductSearch.state";
import { Product } from "../domain/Product.model";
import { ProductApplicationService } from "../application/ProductApplication.service";
export class ProductSearchPloc extends Ploc<ProductSearchState> {
constructor(private readonly service: ProductApplicationService) {
super(ProductSearchStateFactory.idle());
}
async onSearchQueryChanged(query: string) {
if (!query.trim()) {
this.setState(ProductSearchStateFactory.idle());
return;
}
this.setState(ProductSearchStateFactory.loading());
try {
const products: Product[] = await this.service.search(query);
if (products.length > 0) {
this.setState(ProductSearchStateFactory.loaded(products));
} else {
this.setState(ProductSearchStateFactory.empty(query));
}
} catch (error) {
this.setState(ProductSearchStateFactory.error(new Error("Unknown error")));
}
}
}
🔍 Key details.
-
Start in
Idle: This represents an initial screen with no search. -
Reaction to user input (
onSearchQueryChanged):- If the
queryis empty or blank, it reverts to the empty state. - If there is text,
LoadingStateis emitted while the search is performed. - If the search is successful, it is passed to
ResultsState. - If an error occurs (e.g., network failure), it is passed to
ErrorState.
- If the
🔁 This pattern encapsulates all state transition logic within the BloC, allowing the view to simply react to changes, keeping it completely decoupled from the business logic.
Step 4: connecting to the infrastructure
To keep a clean separation of concerns, this example follows a hexagonal architecture (also known as "ports and adapters"). The core idea is to isolate the domain and application logic from external concerns like APIs, databases, or frameworks.
I recommend exploring hexagonal architecture in more detail through dedicated resources, but for this practical case, we’ll use the following simplified structure:
→ presentation/ProductSearch.bloc
→ application/ProductApplication.service
→ domain/Product
domain/Product.repository
→ infrastructure/ApiProduct.repository
-
presentationcontains the Bloc along with its states and events. -
applicationorchestrates use cases like searching or creating products. -
domaindefines the core models and contracts, such asProductandProductRepository. -
infrastructureimplements concrete adapters likeApiProductRepository.
This structure ensures that the domain remains free of technical dependencies, making the codebase easier to test, evolve, and maintain
For the product search feature, the domain consists of two parts: the Product model and the ProductRepository interface.
// domain/Product.ts
export interface Product {
id: string;
name: string;
description: string;
price: number;
}
This interface defines the shape of a product as needed by the business — not the API or database.
Next, we define the repository contract. This allows our application layer to request products regardless of where they come from — an HTTP API, a local DB, or a mocked test service.
// domain/ProductRepository.ts
import { Product } from "./Product";
export interface ProductRepository {
search(query: string): Promise<Product[]>;
}
💡 This abstraction is the key to keeping the application decoupled from infrastructure. You can implement ProductRepository using REST, GraphQL, a mock or anything else — without changing the application or UI code.
We don’t need to worry about the concrete implementation of the repository just yet — that will live in the infrastructure layer. What matters now is how we orchestrate the interaction between the UI and the domain logic
In our case, we want to search for products based on a query string. This means we need a component that:
- Receives the query from the UI.
- Delegates the search to the domain via the repository.
- Returns the results (or errors) in a way the presentation layer can react to.
Let’s encapsulate that orchestration in an application service. This class will handle the product search use case by calling the repository and returning domain models:
// application/ProductApplicationService.ts
import { ProductRepository } from "../domain/ProductRepository";
import { Product } from "../domain/Product";
export class ProductApplicationService {
constructor(private readonly productRepository: ProductRepository) {}
async search(query: string): Promise<Product[]> {
return this.productRepository.search(query);
}
}
This service is not concerned with how the data is fetched — whether it's from an API, a database, or something else. It simply coordinates the use case using the domain contract. This also makes it easy to mock or replace the repository for testing.
Step 5: make the UI Component Reactive to the BloC
To connect our UI to the BloC, we need a way for React to "listen" to state changes. For that, previously, we created the simple custom hook called usePlocState.
In our component, we obtain the BloC from a dependency container (such as DependenciesContainer.ProductSearchPloc()). This container helps us keep things decoupled and makes testing easier, especially if we want to mock or swap implementations later.
Here’s the example of a basic search component that uses the BloC:
import { useMemo, useState } from "react";
import { usePlocState } from "@modules/shared/presentation/useBlocState";
import { DependenciesContainer } from "@/dependencies";
export function ProductSearchComponent() {
const ploc = useMemo(() => DependenciesContainer.ProductSearchPloc(), []);
const state = useBlocState(ploc);
const [query, setQuery] = useState("");
const onSearch = () => {
ploc.search(query);
};
return (
<div>
<inputvalue={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
<button onClick={onSearch}>Search</button>
{state.kind === "Searching" && <p>Loading...</p>}
{state.kind === "ResultsLoaded" &&
state.results.map((product) => <div key={product.id}>{product.name}</div>)}
{state.kind === "NoResults" && <p>No products found.</p>}
{state.kind === "Error" && <p>Error: {state.error.message}</p>}
</div>
);
}
We use useMemo to make sure the Bloc instance is created only once during the lifecycle of the component. This is a valid and safe pattern when the component owns the Bloc. However, if we want to share the Bloc across different parts of the UI (e.g., multiple components depending on the same state), we could use the React Context API instead to provide the instance at a higher level.
This completes the core of the BloC pattern applied to a real-world feature in React. Each layer — UI, application logic, domain, and infrastructure — stays clearly separated, making your app easier to scale and maintain.
Closing thoughts
Building frontends with the BloC pattern brings clarity, testability, and strong separation of concerns. By combining this with ideas from Hexagonal Architecture, we can structure our applications in a way that scales well both in complexity and maintainability.
This article focused on a practical walkthrough — guiding you through each step, from identifying BloCs to wiring up the UI in a reactive and decoupled way. We avoided overengineering and emphasized code organization over framework magic.
The goal isn't just to follow a pattern, but to empower teams to reason clearly about data flows, simplify testing, and keep the user experience robust.
If you're interested in going deeper, consider exploring topics like:
- Integration testing of BloCs
- Testing application services in isolation
- Composition of BloCs and nested flows
- State management alternatives
Thanks for reading — and happy coding!
This article was written with the support of AI assistance, which helped structure explanations and refine examples. AI is a powerful tool to enhance clarity and accelerate development, but the ideas and decisions remain fully human-driven.


Top comments (0)