As a React application grows, a single component can easily take on too many responsibilities: collecting input, running a search, handling errors, and rendering results. That may be quick to build initially, but it couples concerns that should be able to change independently.
This small product-search example uses three techniques to keep those dependencies under control:
- Give React components focused responsibilities.
- Organize application logic around hexagonal architecture boundaries.
- Use events to communicate outcomes without wiring components directly to one another.
You do not need a large framework to establish these boundaries. Explicit interfaces and injected dependencies are enough.
Project structure
The search feature is organized like this:
src/
├── main.tsx
├── search/
│ ├── domain/
│ │ └── productSearchRepository.ts
│ ├── service/
│ │ └── search.ts
│ ├── infrastructure/
│ │ ├── repository/
│ │ │ └── inMemoryProductSearchRepository.ts
│ │ └── ui/
│ │ ├── searchInput.tsx
│ │ └── searchResults.tsx
│ └── factory.tsx
└── shared/
├── eventBus.ts
└── useSubscription.ts
The folder structure is not the goal. Its value is that it makes each module's role easier to see: the domain defines the core concepts and repository contract; the service coordinates the use case; and infrastructure supplies concrete implementations, including the UI.
React components with clear responsibilities
The page has two main components:
-
SearchInputowns the input value and loading state. Once the trimmed query reaches four characters, it calls the search service. -
SearchResultssubscribes to search events and renders the corresponding state: results, an empty state, or an error.
Separating these components does not mean they never collaborate. It means they do not need direct knowledge of each other. SearchInput neither imports nor updates SearchResults' internal state, and the component that starts the search does not need to call the results component.
They share one dependency: the Search service, passed in as a prop. The input asks the service to run the operation; the results component listens for what happens.
The hexagonal boundary: depend on a contract
The domain defines the contract the application expects from a product search repository:
export interface ProductSearchRepository {
search(query: string): Promise<Products>;
}
The application service, Search, receives an implementation of that contract through its constructor:
export class Search {
constructor(
private readonly searchRepository: ProductSearchRepository,
private readonly eventBus: EventBus<AppEvents>,
) {}
async execute(query: string): Promise<Products> {
// runs the search and publishes its outcome
}
}
The service does not know how data is stored or retrieved. Today, the factory creates an InMemoryProductSearchRepository. That adapter could later call an API or query a database without requiring the React components to know about the change.
That is the core idea behind hexagonal architecture: application logic depends on ports (contracts), while concrete implementation details live in adapters connected at the application boundary. In this example:
-
ProductSearchRepositoryis the outbound port for product data. -
InMemoryProductSearchRepositoryis an in-memory adapter for that port. - The React components form the presentation adapter: they invoke the use case and render its outcomes.
-
createSearchwires the concrete implementations together and injects them into the service.
The direction of dependencies matters more than the folder names: the service depends on the interface, not on the in-memory class.
Events decouple the components
After the repository responds, the service publishes an event for either outcome:
async execute(query: string): Promise<Products> {
return this.searchRepository.search(query)
.then((results) => {
this.eventBus.emit("searchExecuted", { query, results });
return results;
})
.catch((error) => {
this.eventBus.emit("searchFailed", { query, error });
throw error;
});
}
SearchResults subscribes to the events it cares about:
useSubscription(() => {
return search.subscribe("searchExecuted", (event) => {
setState(
event.results.isEmpty()
? { status: "empty" }
: { status: "success", results: event.results },
);
});
}, [search]);
The flow looks like this:
the user types
→ SearchInput updates the query
→ SearchInput calls Search.execute(query)
→ Search calls ProductSearchRepository
→ Search publishes searchExecuted or searchFailed
→ SearchResults receives the event and updates its UI
Search does not need to know about SearchResults, and the results component does not need to know about the repository. They communicate through the service and an event contract. That makes it easier to replace the UI, add another subscriber, or test each part independently.
A typed event bus
The event bus maps each event name to its payload type:
export interface AppEvents {
messageSent: { message: string };
searchExecuted: { query: string; results: any };
searchFailed: { query: string; error: Error };
}
The generic EventBus ties the types of on and emit to this map. The on method also returns an unsubscribe function. The useSubscription hook calls it from the useEffect cleanup, removing the subscription when the component unmounts or its dependency changes.
That cleanup matters in React: without it, callbacks can remain registered after the component's lifecycle has ended.
Why not pass a callback between components?
Passing a callback from SearchInput to SearchResults would work in an example this small. But it would directly connect the two components: the component starting the operation would need to know who handles the result.
An event describes something that has happened (searchExecuted or searchFailed). Any interested consumer can subscribe without the producer knowing who those consumers are. This is especially useful when several parts of the interface react to the same operation—for example, results, a result count, analytics, or a notification.
Events are not automatically the right choice for every piece of state. For a direct, local parent-to-child relationship, props and callbacks are simpler. An event bus is useful when producers and consumers should not depend directly on one another.
Testing: verify contracts, not coupling
Hexagonal architecture improves testability by making dependencies explicit. If a use case needs a repository and an event bus, tests can replace them with controlled implementations. There is no need to start a server, connect to a database, or render the UI to test the application logic.
For Search, a hand-written repository mock can record the query and return a known result—or reject with a predetermined error. A hand-written event bus mock can record the events that were published. The tests can then verify the observable contract: the query is forwarded, the result is returned, and searchExecuted is emitted; if the repository fails, searchFailed is emitted and the error still propagates.
The same boundary makes it straightforward to test each component in isolation:
- For
SearchInput, a service mock that records queries is enough to verify that a short query does not start a search and a valid query invokes the use case. - For
SearchResults, a mock that exposessubscribeand lets the test emit events is enough to verify the results, empty, and error states without running a real search.
These tests verify both the input crossing a boundary and the component's reaction to an event. They do not need to mount all the components together or inspect one another's internal state. A lightweight DOM environment is still useful for checking React rendering, while the application dependencies remain deterministic, hand-written mocks.
Test public contracts and visible behavior, not implementation details such as how many times setState is called or the components' internal structure. When a contract changes, updating the relevant tests makes the impact on producers and consumers explicit. And, as with any mocking strategy, keep some integration tests: a mock can confirm that a component follows a contract, but it cannot prove that all the real modules are wired together correctly.
Limitations to keep in mind
This project demonstrates architectural boundaries; it is not a complete search engine:
- The in-memory repository waits two seconds and returns the full product collection; it does not currently filter by query.
-
resultsis typed asanyin thesearchExecutedevent. UsingProductsinstead would make the contract safer and catch invalid consumer assumptions at compile time. - The event bus is a shared singleton. That is convenient in a small app, but a global bus can make feature and test isolation harder. As the application grows, consider a bus with an explicit scope that is injected through the factory.
- The input starts a search on every change once the query is long enough. A real remote integration should consider debouncing, request cancellation, and out-of-order responses.
These limitations do not undermine the approach. They show how clear boundaries let you improve an adapter or a contract without rewriting the entire interface.
Takeaways
Separating React components keeps each part of the UI focused. Hexagonal architecture protects application logic from concrete implementation details. Events let independent components react to the same use case without holding direct references to one another.
The result is more than a tidy folder structure. It is a set of explicit contracts that lets you change the repository implementation, add consumers, and test behavior with less coupling.
When building a feature, ask:
- What is the use case, and what contract does it need?
- Which component is responsible for starting the operation?
- Who needs to react to the outcome, and do they really need to know about one another directly?
The answers help you decide where props are enough, where to define a port, and where an event makes collaboration simpler.
Top comments (0)