Clean Architecture in Frontend Applications. Controller and Presenter

This article shares the concept and implementation of the presenter and controller units in frontend applications within the Clean Architecture. Repository with example: https://github.com/harunou/react-tanstack-react-query-clean-architecture Presenter and controller are adapter units that facilitate the connection between the view and the application core. The presenter is responsible for mapping data from the application core format to the format required by the view. The controller is responsible for mapping data from the view format to the format understood by the application core. Controllers and presenters are not shared between views and components. Controller and Presenter Implementation Controller and presenter implement interfaces defined by the view and represent the view's contract with the application core. The sole responsibility of these units is transformation logic. The development context is limited to the data transformation logic and the type of the unit implementation. Controller and Presenter Implementation Types Typically, the unit has three possible implementation types: null, inline, and extracted. In practice, the unit evolves in the following way (example based on the presenter unit): ------------------ --------------------- ----------------------- | null presenter | ---> | inline presenter | ---> | extracted presenter | ------------------ --------------------- ----------------------- Null Presenter and Null Controller Implementation When the view is simple and doesn't has explicit presenter and/or controller interfaces defined, the controller and presenter can be implemented directly with constants inside a component. This implementation is called "null presenter" or "null controller". In the example below, the component contains presenter and controller implementations that use mock data. The itemIds constant serves as null presenter property, while the deleteOrderButtonClicked function serves as null controller method. interface OrderProps { orderId: string; } export const Order: FC = (props) => { // null presenter implementation const itemIds = ["1", "2"]; // null controller implementation const deleteOrderButtonClicked = () => {}; // view implementation return ( Delete Order {itemIds.map((itemId) => ( ))} ); }; Next example demonstrates a complete component implementation with null presenter and null controller when the component is connected to the application core. interface OrderProps { orderId: string; } export const Order: FC = (props) => { const visibleItemsIds = useOrdersPresentationStore((state) => state.itemsFilterById); const order = useOrderByIdSelector(props.orderId); const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase(); const itemIds = order?.itemEntities .map((itemEntity) => itemEntity.id) .filter((itemId) => visibleItemsIds.includes(itemId)) ?? []; const deleteOrderButtonClicked = async () => { await executeDeleteOrderUseCase(props.orderId); }; return ( Delete Order {itemIds.map((itemId) => ( ))} ); }; Inline Controller and Inline Presenter Implementation In some cases, it is possible to implement the presenter and/or the controller inline in the component. This approach is suitable when the transformation logic is simple and the view has presenter and/or controller interfaces declared. Additionally, it can serve as an intermediate step before fully extracting the presenter and/or controller. The example below shows inline controller and presenter implementations that use mock data. interface OrderProps { orderId: string; } interface Presenter { itemIds: string[]; } interface Controller { deleteOrderButtonClicked: () => void; } export const Order: FC = (props) => { // inline presenter implementation const presenter: Presenter = { itemIds: ["1", "2"], }; // inline controller implementation const controller: Controller = { deleteOrderButtonClicked: () => {}, }; // view implementation return ( Delete Order {presenter.itemIds.map((itemId) => ( ))} ); }; Next example demonstrates a complete component implementation with inline presenter and controller when the component is connected to the application core. interface OrderProps { orderId: string; } interface Presenter { itemIds: string[]; } interface Controller { deleteOrderButtonClicked: () => void; } export const Order: FC = (props) => { const visibleItemsIds = useOrdersPresentationStore((state) => state.itemsFilterById); const order = useOrderByIdSelector(props.orderId); const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase(); const presenter: Presenter = { itemIds: order?.itemEntities

Mar 25, 2025 - 10:19
 0
Clean Architecture in Frontend Applications. Controller and Presenter

This article shares the concept and implementation of the presenter and controller units in frontend applications within the Clean Architecture.

Repository with example:
https://github.com/harunou/react-tanstack-react-query-clean-architecture

Presenter and controller are adapter units that facilitate the connection between the view and the application core. The presenter is responsible for mapping data from the application core format to the format required by the view. The controller is responsible for mapping data from the view format to the format understood by the application core.

Controllers and presenters are not shared between views and components.

Controller and Presenter Implementation

Controller and presenter implement interfaces defined by the view and represent the view's contract with the application core. The sole responsibility of these units is transformation logic.

The development context is limited to the data transformation logic and the type of the unit implementation.

Controller and Presenter Implementation Types

Typically, the unit has three possible implementation types: null, inline, and extracted. In practice, the unit evolves in the following way (example based on the presenter unit):

------------------      ---------------------      -----------------------
| null presenter | ---> | inline  presenter | ---> | extracted presenter |
------------------      ---------------------      -----------------------

Null Presenter and Null Controller Implementation

When the view is simple and doesn't has explicit presenter and/or controller interfaces defined, the controller and presenter can be implemented directly with constants inside a component. This implementation is called "null presenter" or "null controller".

In the example below, the component contains presenter and controller implementations that use mock data. The itemIds constant serves as null presenter property, while the deleteOrderButtonClicked function serves as null controller method.

interface OrderProps {
  orderId: string;
}

export const Order: FC<OrderProps> = (props) => {
  // null presenter implementation
  const itemIds = ["1", "2"];

  // null controller implementation
  const deleteOrderButtonClicked = () => {};

  // view implementation
  return (
    <div style={{ padding: "5px" }}>
      <button onClick={deleteOrderButtonClicked}>
        Delete Order
      button>
      {itemIds.map((itemId) => (
        <OrderItem key={itemId} itemId={itemId} />
      ))}
    div>
  );
};

Next example demonstrates a complete component implementation with null presenter and null controller when the component is connected to the application core.

interface OrderProps {
  orderId: string;
}

export const Order: FC<OrderProps> = (props) => {
  const visibleItemsIds = useOrdersPresentationStore((state) => state.itemsFilterById);
  const order = useOrderByIdSelector(props.orderId);
  const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase();

  const itemIds =
    order?.itemEntities
      .map((itemEntity) => itemEntity.id)
      .filter((itemId) => visibleItemsIds.includes(itemId)) ?? [];

  const deleteOrderButtonClicked = async () => {
    await executeDeleteOrderUseCase(props.orderId);
  };

  return (
    <div style={{ padding: "5px" }}>
      <button onClick={deleteOrderButtonClicked}>
        Delete Order
      button>
      {itemIds.map((itemId) => (
        <OrderItem key={itemId} itemId={itemId} />
      ))}
    div>
  );
};

Inline Controller and Inline Presenter Implementation

In some cases, it is possible to implement the presenter and/or the controller inline in the component. This approach is suitable when the transformation logic is simple and the view has presenter and/or controller interfaces declared. Additionally, it can serve as an intermediate step before fully extracting the presenter and/or controller. The example below shows inline controller and presenter implementations that use mock data.

interface OrderProps {
  orderId: string;
}

interface Presenter {
  itemIds: string[];
}

interface Controller {
  deleteOrderButtonClicked: () => void;
}

export const Order: FC<OrderProps> = (props) => {
  // inline presenter implementation
  const presenter: Presenter = {
    itemIds: ["1", "2"],
  };

  // inline controller implementation
  const controller: Controller = {
    deleteOrderButtonClicked: () => {},
  };

  // view implementation
  return (
    <div style={{ padding: "5px" }}>
      <button onClick={controller.deleteOrderButtonClicked}>
        Delete Order
      button>
      {presenter.itemIds.map((itemId) => (
        <OrderItem key={itemId} itemId={itemId} />
      ))}
    div>
  );
};

Next example demonstrates a complete component implementation with inline presenter and controller when the component is connected to the application core.

interface OrderProps {
  orderId: string;
}

interface Presenter {
  itemIds: string[];
}

interface Controller {
  deleteOrderButtonClicked: () => void;
}

export const Order: FC<OrderProps> = (props) => {
  const visibleItemsIds = useOrdersPresentationStore((state) => state.itemsFilterById);
  const order = useOrderByIdSelector(props.orderId);
  const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase();

  const presenter: Presenter = {
    itemIds:
      order?.itemEntities
        .map((itemEntity) => itemEntity.id)
        .filter((itemId) => visibleItemsIds.includes(itemId)) ?? [],
  };

  const controller: Controller = {
    deleteOrderButtonClicked: async () => {
      await executeDeleteOrderUseCase(props.orderId);
    },
  };

  return (
    <div style={{ padding: "5px" }}>
      <button onClick={controller.deleteOrderButtonClicked}>
        Delete Order
      button>
      {presenter.itemIds.map((itemId) => (
        <OrderItem key={itemId} itemId={itemId} />
      ))}
    div>
  );
};

Extracted Controller and Extracted Presenter Implementation

When the adapter logic is complex, it is better to extract the units from the component. For this, the view should have clearly defined controller and presenter interfaces. Extracted presenter and controller are ordinary objects created with class constructors or functions. The example below demonstrates presenter and controller created with React hooks. Both presenter and controller are using mock data.

interface OrderProps {
  orderId: string;
}

interface Controller {
  deleteOrderButtonClicked: () => void;
}

interface Presenter {
  itemIds: string[];
}

// extracted presenter implementation
const usePresenter = (): Presenter => {
  return {
    itemIds: ["1", "2"],
  };
};

// extracted controller implementation
const useController = (): Controller => {
  return {
    deleteOrderButtonClicked: () => {}
  };
};

export const Order: FC<OrderProps> = (props) => {
  // extracted presenter usage
  const presenter = usePresenter();
  // extracted controller usage
  const controller = useController();

  // view implementation
  return (
    <div style={{ padding: "5px" }}>
      <button onClick={controller.deleteOrderButtonClicked}>
        Delete Order
      button>
      {presenter.itemIds.map((itemId) => (
        <OrderItem key={itemId} itemId={itemId} />
      ))}
    div>
  );
};

Next example demonstrates a complete component implementation with extracted presenter and controller when the component is connected to the application core. When needed, the unit could receive a constructor argument that defines its operational mode. In the example, the presenter and the controller receive an orderId, which determines which OrderEntity to operate with.

interface OrderProps {
  orderId: string;
}

interface Controller {
  deleteOrderButtonClicked: () => void;
}

interface Presenter {
  itemIds: string[];
}

const usePresenter = (params: { orderId: string }): Presenter => {
  const order = useOrderByIdSelector(params.orderId);

  return {
    itemIds: order.itemEntities.map((itemEntity) => itemEntity.id),
  };
};

const useController = (params: { orderId: string }): Controller => {
  const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase();

  return {
    deleteOrderButtonClicked: async () => {
      await executeDeleteOrderUseCase(params.orderId);
    }
  };
};

export const Order: FC<OrderProps> = (props) => {
  const presenter = usePresenter(props);
  const controller = useController(props);

  return (
    <div style={{ padding: "5px" }}>
      <button onClick={controller.deleteOrderButtonClicked}>
        Delete Order
      button>
      {presenter.itemIds.map((itemId) => (
        <OrderItem key={itemId} itemId={itemId} />
      ))}
    div>
  );
};

If transformation logic is extensive, it could be extracted to a separate mapper function, for example:

interface OrderProps {
  orderId: string;
}

interface Controller {
  deleteOrderButtonClicked: () => void;
}

interface Presenter {
  itemIds: string[];
}

const usePresenter = (params: { orderId: string }): Presenter => {
  const order = useOrderByIdSelector(params.orderId);

  return {
    itemIds: order.itemEntities.map(toItemId),
  };
};

// mapper function containing transformation logic
const toItemId = (itemEntity: OrderEntity) => itemEntity.id;

const useController = (params: { orderId: string }): Controller => {
  const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase();

  return {
    deleteOrderButtonClicked: async () => {
      await executeDeleteOrderUseCase(params.orderId);
    }
  };
};

export const Order: FC<OrderProps> = (props) => {
  const presenter = usePresenter(props);
  const controller = useController(props);

  return (
    <div style={{ padding: "5px" }}>
      <button onClick={controller.deleteOrderButtonClicked}>Delete Orderbutton>
      {presenter.itemIds.map((itemId) => (
        <OrderItem key={itemId} itemId={itemId} />
      ))}
    div>
  );
};

At this point, the controller and presenter are considered implemented. The next possible step is connecting them to the application core by implementing presenter and controller properties one by one with already existing use cases, entities, and selectors or by creating new ones.

Q&A

How to test the presenter and controller?

The presenter and controller units can be tested through unit integration tests with the application core. Mapper functions, being pure functions with no dependencies, can be easily tested in isolation.

When are presenter and controller implementations not needed?

When the view presenter and controller interfaces are merged with props, the presenter and controller are not required. All the data preparation logic and event handlers are implemented in the parent component.

Where should I place extracted controller and presenter?

The extracted controller and presenter are better placed in separate files along with consuming component. The example below demonstrates a possible file structure:

// file: ./Order.types.ts
interface OrderProps {
  orderId: string;
}

interface Controller {
  deleteOrderButtonClicked: () => void;
}

interface Presenter {
  itemIds: string[];
}

// file: ./hooks/usePresenter.ts
const usePresenter = (params: { orderId: string }): Presenter => {
  const order = useOrderByIdSelector(params.orderId);

  return {
    itemIds: order.itemEntities.map(toItemId),
  };
};

// file: ./hooks/mappers.ts
const toItemId = (itemEntity: OrderEntity) => itemEntity.id;

// file: ./hooks/useController.ts
const useController = (params: { orderId: string }): Controller => {
  const { execute: executeDeleteOrderUseCase } = useDeleteOrderUseCase();

  return {
    deleteOrderButtonClicked: async () => {
      await executeDeleteOrderUseCase(params.orderId);
    }
  };
};

// file: ./Order.tsx
export const Order: FC<OrderProps> = (props) => {
  const presenter = usePresenter(props);
  const controller = useController(props);

  return (
    <div style={{ padding: "5px" }}>
      <button onClick={controller.deleteOrderButtonClicked}>
        Delete Order
      button>
      {presenter.itemIds.map((itemId) => (
        <OrderItem key={itemId} itemId={itemId} />
      ))}
    div>
  );
};

Conclusion

Controllers and presenters serve as essential adapters, creating a clear boundary between the view and application core. By starting with null implementation and progressing to inline and extracted patterns as complexity grows, developers can maintain separation of concerns while adapting to changing requirements. This approach results in more maintainable components with well-defined responsibilities and interfaces.