-
-
Notifications
You must be signed in to change notification settings - Fork 1
View
This article shares the concept and implementation of the view unit in frontend applications within the Clean Architecture.
Repository with example: https://github.com/harunou/react-tanstack-react-query-clean-architecture
View unit is typically a layout. For example, in a React component, the layout is represented with JSX. The role of the view is to present the application's data to the user and handle user input. The view is tightly coupled with the presenter and controller interfaces, which summarize the data and event handlers required for the view.
The image demonstrates a component diagram and its elements and dependencies.
As mentioned in the overview, the view depends on presenter and controller through provided interfaces. The controller and presenter implement these interfaces and depend on the application core. Additionally, the controller and presenter could depend on props or/and the component's local state whenever needed.
The control flow and data flow are straightforward:
Data adapted for presentation flows from the presenter into the view, while the controller handles user events captured by the view.
View implementation is pretty formal and starts from a visual design. The design is translated into a layout. The development context is limited to everything related to visual representation: layout structure, styles, animations, design system, component library, accessibility, etc. There is no need to guess about a store or external resources - this will be addressed later. The end result consists of three elements: the component layout, the controller interface and the presenter interface where it is necessary.
Let's look at an example.
At the very beginning, the layout is empty; the component returns null
, not
JSX. The presenter and controller interfaces are empty as well.
interface Presenter {
}
interface Controller {
}
export const Order: FC = () => {
const presenter: Presenter = {};
const controller: Controller = {};
return null;
};
Step by step, the layout is built, and the interfaces are gradually filled with properties. The naming convention for properties in the presenter and controller interfaces follows a simple pattern: presenter property names reflect the data that needs to be presented, while controller property names are event-based and clearly reflect the actions that need to be handled by the component.
interface Presenter {
hasOrder: boolean;
orderDate: string;
userName: string;
}
interface Controller {
deleteOrderButtonClicked: () => void;
}
export const Order: FC = () => {
// presenter implementation with mock data
const presenter: Presenter = {
hasOrder: true,
orderDate: "09-03-2025",
userName: "John Doe",
};
// controller implementation with mock data
const controller: Controller = {
deleteOrderButtonClicked: () => {},
};
if (!presenter.hasOrder) {
return null;
}
return (
<div style={{ border: "1px solid red" }}>
<button onClick={controller.deleteOrderButtonClicked}>
Delete Order
</button>
<div>Date: {presenter.orderDate}</div>
<div>User name: {presenter.userName}</div>
</div>
);
};
At the final step we could try to define how the view receives data and event handlers. There are two approaches:
- Through props: a component receives all data and handlers as props from a parent component.
- Through presenter and controller implementations: a component receives all data and handler from presenter and controller implementations.
The selected approach may require restructuring the view and interfaces. Examples can be found in the How to organize props section. Usually, small components receive data and handlers through props. When the component grows, props evolve into presenter and controller. Later, the component can be split into smaller components, which receive data and handlers through props, and the evolution cycle repeats.
Any further view improvements or updates should also remain within the boundaries of layout and interfaces.
At the point, when the view meets all visual requirements and has controller and presenter interfaces defined, the view implementation can be considered complete. Next possible step is to create controller and presenter.
A view can be tested manually because it can be rendered in a browser with mock data. Additionally, the view can be tested by creating unit tests with mocked controller and presenter interfaces. An example can be found here: Orders.spec.tsx
No, it depends on the complexity of the view. If the view is simple, where values and handlers can be easily observed, then it's not necessary. The interfaces summarize the data and actions that the view needs to present and handle. If you can build such a summary in your head without an interface declaration, the declaration can be omitted.
If a view is simple and has data to present or events to handle, but the view does not have interfaces declared (declaration is omitted), then such interfaces are called "null presenter" or "null controller" interfaces.
The example below demonstrates a view with null presenter interface. The
constants hasOrder
, orderDate
, and userName
represent properties of the
null presenter interface implementation, the properties are assigned mock data
export const Order: FC = () => {
// constants represent null presenter interface implementation
const hasOrder = true;
const orderDate = "09-03-2025";
const userName = "John Doe";
if (!hasOrder) {
return null;
}
return (
<div style={{ padding: "5px" }}>
<div>Date: {orderDate}</div>
<div>User name: {userName}</div>
</div>
);
};
Next example demonstrates same view with presenter interface. The constant
presenter
represents presenter assigned with mock data.
interface Presenter {
hasOrder: boolean;
orderDate: string;
userName: string;
}
export const Order: FC = () => {
const presenter: Presenter = {
hasOrder: true,
orderDate: "09-03-2025",
userName: "John Doe",
};
if (!presenter.hasOrder) {
return null;
}
return (
<div style={{ padding: "5px" }}>
<div>Date: {presenter.orderDate}</div>
<div>User name: {presenter.userName}</div>
</div>
);
};
Once declared, the interfaces can be placed either in the same file as the component if they are implemented within that file:
// file:./Order.tsx
interface Presenter {
hasOrder: boolean;
orderDate: string;
userName: string;
}
export const Order: FC = () => {
const presenter: Presenter = {
hasOrder: true,
orderDate: "09-03-2025",
userName: "John Doe",
};
if (!presenter.hasOrder) {
return null;
}
return (
<div style={{ border: "1px solid red" }}>
<div>Date: {presenter.orderDate}</div>
<div>User name: {presenter.userName}</div>
</div>
);
};
Alternatively, they can be placed in a separate file if the interfaces are implemented in a different file as well:
// file:./Order.types.ts
export interface Presenter {
hasOrder: boolean;
orderDate: string;
userName: string;
}
// file:./presenter.ts
import type { Presenter } from "./Order.types.ts"
export const presenter: Presenter = {
hasOrder: true,
orderDate: "09-03-2025",
userName: "John Doe",
}
// file:./Order.tsx
import { presenter } from "./presenter";
export const Order: FC = () => {
if (!presenter.hasOrder) {
return null;
}
return (
<div style={{ border: "1px solid red" }}>
<div>Date: {presenter.orderDate}</div>
<div>User name: {presenter.userName}</div>
</div>
);
};
Following the Interface Segregation Principle (ISP), large interfaces indicate that the layout might be too big and can be split into smaller, more specific ones. Interface declarations can help identify ways/parts the component can be split.
Function arguments can be split into two groups: operands and options. Operands represent the data that the function needs to do its job, while options represent the configuration (operational mode) of the function. The same concept applies to component props. All properties declared in the presenter and controller interfaces, when received through props, serve as operands. All other props that define special behavior of a component in a specific context are options.
The way a component receives data and event handlers (through props or presenter/controller implementations) can affect the number of props and properties in interfaces and the view structure. This should be considered when deciding how the component will be consumed or when switching from one consumption approach to another. Normally, the changes are not big and limited.
The example below demonstrates a component with both props type and presenter
interface. The component is designed to get data through presenter
implementation. For that purpose, the component accepts configuration options
orderId
and presentationType
. The orderId
affects how the presenter
implementation operates, while the presentationType
affects how the view
operates.
// file:./Order.tsx
type OrderProps = {
orderId: string;
presentationType: "full" | "limited";
};
interface Presenter {
hasOrder: boolean;
orderDate: string;
userName: string;
}
export const Order: FC<OrderProps> = (props) => {
const presenter: Presenter = {
hasOrder: true,
orderDate: "09-03-2025",
userName: "John Doe",
};
if (!presenter.hasOrder) {
return null;
}
if (props.presentationType === "limited") {
return (
<div style={{ border: "1px solid red" }}>
<div>Date: {presenter.orderDate}</div>
</div>
);
}
return (
<div style={{ border: "1px solid red" }}>
<div>Date: {presenter.orderDate}</div>
<div>User name: {presenter.userName}</div>
</div>
);
};
// Parent of Order component
// file:./Orders.tsx
import { Order } from "./Order.tsx"
...
{presenter.orderIds.map((orderId) => (
<Order key={orderId} orderId={orderId} presentationType={'full'} />
))}
...
The next example demonstrates the same component refactored to receive data
through props from a parent component. For this purpose, the presenter interface
is merged with the props type. In this case, the orderId
prop is not needed as
the component will not use the presenter to select data by the id. Similarly,
hasOrder
is not needed as it is always true
.
// file:./Order.tsx
type OrderProps = {
orderDate: string;
userName: string;
presentationType: "full" | "limited";
};
export const Order: FC<OrderProps> = (props) => {
if (props.presentationType === "limited") {
return (
<div style={{ border: "1px solid red" }}>
<div>Date: {props.orderDate}</div>
</div>
);
}
return (
<div style={{ border: "1px solid red" }}>
<div>Date: {props.orderDate}</div>
<div>User name: {props.userName}</div>
</div>
);
};
// Parent of Order component
// file:./Orders.tsx
import { Order } from "./Order.tsx"
...
{presenter.orders.map((order) => (
<Order key={order.orderId} {...order} presentationType={'full'} />
))}
...
View unit serves as the boundary between users and the system. When working with the view, the development context is limited to component layout and interfaces, reflecting the view's actual needs. The decision of whether to declare explicit presenter/controller interfaces should be guided by the component's complexity. The chosen consumption approach (props or presenter/controller implementations) can affect the number of props, properties in interfaces, and the view structure, but switching between approaches is not a big deal.