DEV Community

behnam rahimpour
behnam rahimpour

Posted on

Cracking the Code: How the MVVM with Bridge Pattern Saves a Messy Frontend UI (Part 2)

Note: This is the second part of our series on the MVVM architecture in the frontend. If you haven’t read the first part yet, we highly recommend starting here.

Bridge Pattern Coding Example

In the first part of this series, we discussed how the Bridge Pattern can enhance the reusability and maintainability of UI components in a large-scale frontend application. While we focused primarily on the high-level design with uml class diagrams, the real challenge lies in its implementation. After all, even the best designs are merely theoretical without effective implementation.

Example: A Reusable Button Component

Let’s walk through a simple example: creating a button component that is reusable across different contexts and logic in our application.

The Bridge Pattern in Action

To achieve this, we use an interface to establish a convention between the UI (view layer) and the UI logic (view model layer). This interface acts as a bridge, enabling the separation of concerns and improving flexibility.

interface ButtonUiLogic {
  props: {
    title: string;
    disabled: boolean;
  }
  onClick(e: MouseEvent): void 
}
Enter fullscreen mode Exit fullscreen mode

When building reusable components, it's important to distinguish between props (state-driven UI data) and events (interactions or behaviors triggered by the user).

In our example, the props define the visual and state attributes of the button, such as title and disabled. Meanwhile, the events, such as onClick, are passed separately to handle interactions outside the UI logic. This convention can help you to have a clear separation and also can help you in some memoization down the road.

interface ButtonProps {
  uiLogic: ButtonUiLogic;
  ... // rest of props comes from the parent
}
Enter fullscreen mode Exit fullscreen mode

Abstraction layer (UI)

Now its time to handle our abstraction layer of bridge pattern which is a button component.

function AppButton(props: ButtonProps) {
  const { uiLogic } = props;

  return (
    <button disabled={uiLogic.props.disabled} onClick={uiLogic.onClick}>
      {uiLogic.props.title}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

In this code you can see that we got our uiLogic from our props and binding events and props of ui logic to the UI part which is nothing but a react component. It’s responsible just to decide how to show the data and how to connect behaviors to the UI.

Implementation layer (UI logic)

The implementation layer in the Bridge Pattern defines the behavior and state logic of the component. For our button example, we’ll implement a counting button that increments a counter every time it’s clicked.

This example demonstrates how the UI logic handles the component's state and provides props for the UI, ensuring a clean separation of concerns.

class CounterUILogic implements IBaseUiLogic<ButtonUiLogic> {
  useUiLogic(): ButtonUiLogic {
    const [count, setCount] = useState(0);

    return {
      props: {
        disabled: false,
        title: `Current counter is: ${count}`,
      },
      onClick: () => setCount((prev) => prev + 1),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

You can see we used a class which implements IBaseUiLogc by passing correspond ButtonUiLogic as generic to it which is nothing but.

export default interface IBaseUiLogic<UI_LOGIC> {
  useUiLogic(): UI_LOGIC;
}
Enter fullscreen mode Exit fullscreen mode

You can see that this interface is just a convention for having one custom hook with a specific name to handle ui logic inside of it.

So you can see instead of having many implementation of one ui component for each logic, we can have thousands of UI logic for one UI for any purpose, like submit a form, open a modal, etc. Most of the hooks, connection to business logic to fetch the data, manipulating the state of the component, will happen in this layer.

Connection

Let’s review the design together one more time.

Ui logic and ui connected together through one parent component

The connection between the UI and UiLogic layers is critical for ensuring seamless interaction while maintaining isolation. This connection must ensure:

  • Encapsulation: The logic and state changes of one component don’t affect others.
  • Reactivity: The UI updates dynamically when the state in the UiLogic changes.
  • Reusability: A single component can serve as a reusable environment for various UiLogic implementations.

To achieve this, we’ll create a reusable LogicProvider component. It will encapsulate the UiLogic and provide the necessary bridge to the UI.

interface IUiLogicProvider<UI_LOGIC> extends PropsWithChildren {
  Ui: FC<any & { uiLogic: UI_LOGIC}>;
  UiLogic: IBaseUiLogic<UI_LOGIC>;
  restProps?: Record<string, any>;
}

function UiLogicProvider<UI_LOGIC>(props: IUiLogicProvider<UI_LOGIC>) {
  const { Ui, UiLogic, restProps, children } = props;
  const uiLogic = UiLogic.useUiLogic();

  const allProps = {
    ...restProps,
    uiLogic,
  };

  return <Ui {...allProps}>{children}</Ui>;
}
Enter fullscreen mode Exit fullscreen mode

In this basic component we’re getting UiLogic instance and based on the interface convention for our UI logic we call our ui logic to run and pass the result to the Ui.
Notes and Optimization Tips

  • Encapsulation: The UiLogicProvider encapsulates all the logic-specific state updates and re-renders, ensuring other components remain unaffected.
  • Automatic Memoization: By wrapping this provider with memo, you avoid the need to explicitly use React.memo or similar techniques for each child component as you know your UI component should just be rerendered by changes of the Uilogic not rerendering of the parents. This ensures optimal performance without extra effort in an automatic way.

For example:

export default memo(UiLogicProvider, (prevProps, currProps) => {
  if (
    prevProps.restProps !== currProps.restProps ||
    prevProps.children !== currProps.children
  )
    return false;
  return true;
});
Enter fullscreen mode Exit fullscreen mode
  • This component is the most basic one. You can get the base concept of it and use it with other ideas and techniques as well. The main concept is one layer will handle calling the Ui logic to avoid other components getting affected by this component.

Last part
The last part will be the easiest one. Just from required parent component connect Ui and Ui logic with our provider.

function App() {
  return (
    <div>
      <UiLogicProvider Ui={ButtonComponent} UiLogic={new CounterUILogic()}  />
      <UiLogicProvider Ui={ButtonComponent} Uilogic={new CancelButtonLogic()} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Transitioning to MVVM Architecture in Bridge Pattern

In the MVVM (Model-ViewModel-View) architecture:

  • Model: Handles the business logic and manipulates stored data.
  • ViewModel (VM): Focuses on UI logic and connects the Model to the View.
  • View: Manages the UI rendering and binds to the ViewModel via an interface, typically called IVM, which acts as the Bridge.

This architecture builds on the Bridge Pattern we discussed earlier, but introduces a Model layer to separate business logic, ensuring a clean and scalable foundation for large-scale applications.

Mapping Bridge Pattern to MVVM

Here’s how the Bridge Pattern concepts translate into MVVM:

Bridge Pattern MVVM Role
Abstraction Layer View Reusable UI components that bind to the ViewModel.
Bridge IVM Interface Connects the View to the ViewModel.
Implementation Layer ViewModel (VM) Handles UI logic and mediates between View and Model.
- Model Business logic and data manipulation.

Till now we had all our logic inside of the Implementation layer part if we separate business logic into separate classes or functions and we name it as a model. So the only remains in Implementation is just real UI logic and just with one connection with the model we can send all data to UI and handle UI logic as well.
In this case we’ll have a clean foundation architecture based on MVVM for our large scale application that is reusable, maintainable and testable.

Now in MVVM (Model-ViewModel-View) our Model is responsible for business logic, ViewModel is responsible for UI logic and connecting model to view and then we’ll have our reusable View part that connects to ViewModel with one interface as the IVM which plays our bridge.

Now lets review the diagram but this time in MVVM architecture.

Mapped bridge pattern architecture to mvvm in class diagram
Now you can see in the above diagram that View is replaced with an abstraction layer, IVM replaced with Bridge and VM replaced with an implementation layer. Also we add Model as a layer which is responsible for business logics and manipulation of stored data.

Now we can translate our code example to MVVM shape. For this reason we need to just replace the UI name with View and UiLogic with VM. That’s it now you have a base MVVM approach architecture in your react.

Rewriting the example in MVVM

View (UI)

function AppButton(props: ButtonProps) {
  const { vm } = props;

  return (
    <button disabled={vm.props.disabled} onClick={vm.onClick}>
      {vm.props.title}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

IVM and IProps (Bridge)

interface ButtonVM {
  props: {
    title: string;
    disabled: boolean;
  }
  onClick(e: MouseEvent): void 
}
Enter fullscreen mode Exit fullscreen mode
interface ButtonProps {
  vm: ButtonVM;
  ... // rest of props comes from the parent
}
Enter fullscreen mode Exit fullscreen mode

VM (UI Logic)

CounterVM implements IBaseVM<ButtonVM> {
  useVM(): ButtonVM {
    const [count, setCount] = useState(0);

    return {
      props: {
        disabled: false,
        title: `Current counter is: ${count}`,
      },
      onClick: () => setCount((prev) => prev + 1),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Final Conclusion

With this journey, we’ve wrapped up building a clean MVVM architecture in a React application, ensuring it aligns with the best practices of performance and software architecture.

  • Separation of Concerns: Each layer has a distinct responsibility with using Model, ViewModel (VM), View.
  • Bridge Pattern Integration: The IVM interface serves as the Bridge, providing a clean abstraction between the View and the ViewModel, making the architecture flexible and reusable.
  • Reusability and Testability: Models, ViewModels, and Views are independently reusable and testable, ensuring long-term maintainability and reducing the cost of changes.
  • Performance Optimization: With the use of a UiLogicProvider, we’ve ensured that re-renders are isolated to only the relevant components. By encapsulating the ViewModel logic, we’ve also enabled automatic memoization, avoiding unnecessary re-renders across the application.
  • Scalability: The modular design makes the architecture inherently scalable, suitable for large-scale applications with complex requirements.

Congratulations on completing these two articles with me! I hope you found them helpful. If you did, please let me know in the comments and feel free to like and share to help more people discover and benefit from these ideas. I’d also love to hear your suggestions or ideas about coding architecture and approaches that have worked well for you!

Top comments (0)