Discover the Benefits of Using the Smart and Presentation Components Pattern in Your Angular App

Discover the Benefits of Using the Smart and Presentation Components Pattern in Your Angular App

A good component finds a balance of constraints while remaining reusable and integrating cleanly with the rest of our application

The concept of smart and presentation components (or we can also call them dumb) is not new. But it is still a concept that I see little applied daily in the front-end development of many digital products. After all, are there advantages to applying this design to components? We will talk about some points in this post and respond with some practical examples seeing the advantages of applying it and the challenges that may arise. Let's go!

πŸ“ Is it possible to write elegant components?

When requirements change and new features arrive in an application, whether web or mobile, more components are developed. Some are simple, others more complex. The main goal of software engineers is to ensure that the application survives the development cost, it must be sustainable, testable, and extensible.

There are principles that we can apply daily, for example, DRY, KISS, and YAGNI among others that help programmers to write good code design. It all boils down to seeking ease and agility in the future and avoiding delays in development because of coupled code. So now we can ask ourselves: How to create cohesive components?

Let's take a quick example:

@Component({
  selector: 'some',
  template: 'template',
})
export class SomeComponent implements OnInit, OnDestroy {
  @ViewChild('btn') buttonRef: ElementRef<HTMLButtonElement>;
  form = this.formBuilder.group({
    firstName: [''],
    lastName: [''],
    age: [''],
    occupation: [''],
  })
  destroy$ = new Subject<void>();

  constructor(
    private readonly service: Service,
    private readonly anotherService: AnotherService
    private formBuilder: FormBuilder,
  ) {}

  ngOnInit() {
    this.service.getSomeData().subscribe(res => {
      // handle response
    });
    this.anotherService.getSomeOtherData().subscribe(res => {
      // LOTS of logic may go here
    });
    this.form.get('age').valueChanges.pipe(
      map(age => +age),
      takeUntil(this.destroy$),
    ).subscribe(age => {
      if (age >= 18) {
        // do some stuff 
      } else {
        // do other stuff
      }
    });

    this.form.get('occupation').valueChanges.pipe(
      filter(occupation => ['engineer', 'doctor', 'actor'].indexOf(occupation) > -1),
      takeUntil(this.destroy$),
    ).subscribe(occupation => {
      // Do some heavy lifting here
    });

    combineLatest(
      this.form.get('firstName').valueChanges,
      this.form.get('lastName').valueChanges,
    ).pipe(
      debounceTime(300),
      map(([firstName, lastName]) => `${firstName} ${lastName}`),
      switchMap(fullName => this.service.getUser(fullName)),
      takeUntil(this.destroy$),
    ).subscribe(user => {
      // Do some stuff
    });

    fromEvent(this.buttonRef.nativeElement, 'click').pipe(
      takeUntil(this.destroy$),
    ).subscribe(event => {
      // handle event
    })
  }

  ngOnDestroy() {
    this.destroy$.next();
  }
}

The component has only two life cycles, but if you look at its ngOnInit you will be a bit startled by all that goes on inside. This is not that hard to see in everyday life, especially in large corporations. It is obvious that this component besides taking care of complex logic, also takes care of presentation, so in a single component, we deal with logic and presentation. The component tends to get more and more polluted as new requirements come in. And the consequence is that soon testing and maintenance will become more and more difficult.

A good component finds a balance of constraints while remaining reusable and integrating cleanly with the rest of our application.

So we need to understand well what each component should handle and when we can break it into other components. The idea of having a presentation and intelligent components seeks to solve this very common problem. But how does this pattern work?

πŸ“ Think of your components as pure and impure functions

It may not make sense to think of our components as functions, they are composed of them, but in fact, a component represents a class. But the idea behind having components for presentation and logic manipulation is quite similar to pure and impure functions. Why? Take a look at this function:

function add(a,b) { 
  return a + b
}
console.log(add(10,5))

The same input parameters will provide the same output. It is a very predictable output and does not depend on any external code. This makes the add() function a pure function.

We can compare a Dumb Component to a pure function because for given function arguments, it will always produce the same return value. The dumb Component is exactly like this, for incoming data (inputs), it will always look and behave the same, possibly also producing other data (events, via outputs). In this case, a dumb component does not manipulate any logic, it does not change the state of anything internally, it just presents the final result.

The presentation component is intended to display information and interact with the user. It should not have any dependencies on services or other contexts. Presentation components should use only local state and logic when needed, such as controlling a checkbox or managing the opening and closing of a drop-down list. They are somewhat reduced to the core of their functionality, displaying data and interacting with a user. However, they can receive data from other components and send data to other components. See an example of a Dumb Component:

import {Component, OnInit, Input, EventEmitter, Output} from '@angular/core';
import {Lesson} from "../shared/model/lesson";

@Component({
  selector: 'lessons-list',
  template: `
      <table class="table lessons-list card card-strong">
          <tbody>
          <tr *ngFor="let lesson of lessons" (click)="selectLesson(lesson)">
              <td class="lesson-title"> {{lesson.description}} </td>
              <td class="duration">
                  <i class="md-icon duration-icon">access_time</i>
                  <span>{{lesson.duration}}</span>
              </td>
          </tr>
          </tbody>
      </table>  
  `,
  styleUrls: ['./lessons-list.component.css']
})
export class LessonsListComponent {

  @Input()
  lessons: Lesson[];

  @Output('lesson')
  lessonEmitter = new EventEmitter<Lesson>();

    selectLesson(lesson:Lesson) {
        this.lessonEmitter.emit(lesson);
    }

}

Why do smart components remember unclean functions?

Unclean functions contain one or more side effects. Here is an example of an impure function:

const myNames = ["Bill", "Steve"];

function updateMyName(newName) {
  myNames.push(newName);
  return myNames;
}

In the snippet above, updateMyName() is an impure function because it contains code that changes an external myNames state - causing the function to have some side effects. So we can never predict the return of updateMyName(). So an impure function is a function that touches "the outside world": either by getting data from external services or by producing side effects.

Smart Components depend not only on their inputs but also on external data ("calls to the outside world"), which is not directly transmitted via Input(). It can also produce some side effects that are not output by Output(). For example, a component that gets a list of products by accessing a singleton service instantiated elsewhere; from an external API. So we can compare an intelligent component to an impure function.

In resumes, smart components care about the application's state and general logic. They connect different parts, get dependency injection and handle all the data manipulations. We can map, filter, subscribe to events and perform other logic.

import { Component, OnInit } from '@angular/core';
import {LessonsService} from "../shared/model/lessons.service";
import {Lesson} from "../shared/model/lesson";

@Component({
  selector: 'app-home',
  template: `
      <h2>All Lessons</h2>
      <h4>Total Lessons: {{lessons?.length}}</h4>
      <div class="lessons-list-container v-h-center-block-parent">
          <lessons-list [lessons]="lessons" (lesson)="selectLesson($event)"></lessons-list>
      </div>
`,
  styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {

    lessons: Lesson[];

  constructor(private lessonsService: LessonsService) {
  }

  ngOnInit() {
     ...
  }

  selectLesson(lesson) {
    // Logic here ...
  }

// More functions...

}

Below is a simple illustration of the default behavior of these two types of components:

Captura de Tela 2022-10-10 aΜ€s 21.39.20.png

πŸ“How do you know if a component should be dumb or smart?

Before you put your hand in the code think about the functionality you are working on. I suggest you take a piece of paper and start drawing the component tree for each page of that feature. This gives you the ability to start thinking about which state belongs to which component. For example: Is it a multi-step form? You can break it into several small, presentation components that take care of just displaying each step. As for how many dumb components can be directly linked to an intelligent component we will talk about this in a moment. The simpler the requirements and contracts between the frontend and backend, the easier it is to identify who is who in this pattern. With complex requirements and a large application, it can be more difficult. Once again I reinforce the importance of trying to visualize and understand the requirements before writing the code. Also, think about how the unit tests will be written, we don't want to make our tests difficult.

6wgi6v.jpeg

πŸ“One intelligent component serves multiple presentation components

I looked for other developers' opinions before writing this post, some said they don't like the idea of having multiple dumb components depending on just one smart one. But others said that the context is what matters, so if it was necessary to break this functionality into other small presentation components there is no problem. I believe that there should be exactly this balance. If a feature is big and has many requirements on the frontend, breaking it into small components can help a lot. Your context may be different from others so it is important not to create rules, but to understand and talk to everyone on the team to get as many different points of view as possible on how to handle these situations. The bigger the application, the more pairs of small, dumb components you will find.

An analogy to make it clearer is that of a chef cooking in a restaurant. The cook is the intelligent component, who has the skills and knowledge necessary to prepare many different dishes. The dishes would be the presentation components, which receive the food prepared by the cook and present it to the customers. While the cook may specialize in various dishes and be responsible for preparing the meals, there can also be an overload of responsibility placed on the cook.

Bringing this into the context of the article, it is important to be careful when deciding whether an intelligent component should serve multiple presentation components. Some care that we as software engineers need to take can be listed below:

  • Avoid over-coupling: It is important to be alert and not allow an intelligent component to be too coupled to the presentation components it uses. It should focus on providing the data necessary for the presentation components to do their job and should not interfere with the presentation logic of the components.

  • Maintain cohesion: A smart component must maintain clear cohesion with the responsibilities he has for each of the presentation components he uses. If it is doing very different things for each presentation component, it may need to be split into different intelligent components.

  • Maintain reuse: If presentation components are very specific to a use case, it can be more difficult to reuse them in other parts of the application. Therefore, the intelligent component must be focused on providing data that can be reused in different contexts. And sometimes this is not an easy task, the code is constantly changing and the rules can change. So it is important to understand what makes sense for the context at the moment and be as pragmatic as possible.

Captura de Tela 2022-10-11 aΜ€s 11.48.36.png

Another important thing to remember is when a dumb component starts getting responsibilities that should be from an intelligent component. When developing we have to be aware of the pros and cons of its solutions. Making a dumb component too smart creates a situation where you lose all the benefits of using the smart dumb pattern in the first place.

πŸ“ Principles that a dumb component should follow

It's easy to get lost in the requirements we have to take into account when deciding whether or not a component should be presentation-only, so the points below may help you:

  • Do not have business logic: Dumb components should not have any business logic. They should only focus on rendering data and displaying the user interface. This makes them easier to maintain and test.

  • Receive data through input bindings: Dumb components should receive data from their parent component through input bindings. This makes them more flexible and easier to reuse in different contexts.

  • Emit events through output bindings: Dumb components should emit events through output bindings when the user interacts with them. This allows the parent component to handle the event and update the application state if necessary.

  • Do not have dependencies on services or APIs: Dumb components should not have any dependencies on services or APIs. They should receive all data they need from the parent component through input bindings.

  • Do not mutate data: Dumb components should not mutate any data that they receive through input bindings. They should treat the data as read-only and not modify it.

  • Have a clear and specific purpose: Dumb components should have a clear and specific purpose, such as displaying a list of items or a form. This makes them easier to understand and reuse.

πŸ“ Too many smart components for the same functionality

Imagine if the component that displays the entire instagram home page is in only one smart component (I hope it is not). It would be hard work to do any work on top of this component. There would be too much logic and multiple calls on the same component, so it is important to know when to stop assigning excessive responsibilities on top of a smart component and break it into smaller smart components and these can have their presentation components. We could break it down into a few smart components that have their responsibilities.

6wgj1sss.jpeg

Below I will list some benefits and risks that we should always be aware of when thinking of many intelligent components, see that I am basing this on my experience in high-complexity and low-complexity projects:

Risks:

  • Code complexity: When there are many smart components for the same functionality, there can be unnecessary complexity in the code. This can make understanding the communication and integration of these components slower because it is necessary to be aware of all the relationships between Inputs and Outputs that each component performs.

  • Testing difficulty: With many smart components, you may need to test each one individually, this in itself is not a problem, but perhaps it can increase the complexity of testing a bit more or make it more time-consuming. The complexity increases if we need to manage the state of the application with libraries like Redux.

Benefits:

  • Modularity: With many intelligent components, it can be easier to break the code into smaller, more manageable modules. This can improve the maintainability and scalability of the application.

  • Reuse: When there are many smart components for the same functionality, this can allow code to be reused in different parts of the application. This can save time and reduce the amount of duplicate code.

It is important to carefully evaluate the application needs and choose the most appropriate approach.

πŸ“ Avoiding the incorrect use of Dumbs Components

This is a topic that can vary greatly depending on the circumstances of each project. You can choose to train, document, and create design patterns within the project. The approach below that I found from an article is very interesting, but should also be used with caution if developers do not know the basics of smart and dumb components:

export abstract class DumbComponent {

   private readonly subClassConstructor: Function;

   protected constructor() {
      this.subClassConstructor = this.constructor;

      if (this.isEmptyConstructor() || arguments.length !== 0) {
         this.throwError('it should not inject services');
      }
   }

   private isEmptyConstructor(): boolean {
      return this.subClassConstructor.toString().split('(')[1][0] !== ')';
   }

   private throwError(reason: string): void {
      throw new Error(`Component "${this.subClassConstructor.name}" is a DumbComponent, ${reason}.`);
   }
}

The above approach uses abstraction, in many cases, it can work, and in others, it can be a headache. The author's intention is good, in the methodisEmptyConstructor() checks the number of parameters in the subclass constructor. This method converts the constructor function into a string and then split checks to see if the function gets any parameters. If the number is greater than zero, this means that something has been injected into the component and we want to prevent that from happening.

@Component({...})
export class UserComponent extends DumbComponent {

  constructor(private readonly userService: UserService) {
    super();
  }

}

When we try to inject a service into a component derived from a DumbComponent we get the runtime error that it is not possible to inject a service into a dumb component. At the end of this post, I will leave the link to the article for those who wish to check out the full implementation that the author suggested.

πŸ“ Abstract Smart Component

Following the same reasoning of abstracting a dumb component, the same is possible for an intelligent component. In the article Luke (the author) shows this interesting implementation:

export abstract class SmartComponent implements OnDestroy {

   private readonly unsubscribe$ = new Subject<void>();

   private readonly subClassNgOnDestroy: Function;

   constructor() {
      this.subClassNgOnDestroy = this.ngOnDestroy;
      this.ngOnDestroy = () => {
         this.subClassNgOnDestroy();
         this.unsunscribe();
      };
   }

   ngOnDestroy() { }

   protected untilComponentDestroy() {
     return takeUntil(this.unsubscribe$);
   }

   private unsunscribe() {
     if (this.unsubscribe$.isStopped) {
       return;
     }
     this.unsubscribe$.next();
     this.unsubscribe$.complete();
   }
}

The author explains the implementation:

The abstract class SmartComponent gives us the takeUntil method that helps us handle unsubscribing. We also don't need to remember to implement the ngOnDestroy method, everything happens automatically.

Look how much simpler and cleaner the smart component has become:

@Component({...})
export class UserListComponent extends SmartComponent implements ngOnInit {

  users: Array<User>;

  constructor(private readonly userService: UserService) {
    super();
  }

  ngOnInit() {
    this.userService
        .selectAll()
        .pipe(this.untilComponentDestroy())
        .subscribe((users) => {
          this.users = users;
        });
  }

}

But be careful, when using the abstraction and avoid limiting other developers too much when using that abstraction. This goes for both cases, for smart and dumb.

πŸ“ Benefits of these concepts

  • Unit tests are simple and easy to perform.

  • Provides consistency and avoids duplication: As software grows, it is very easy to duplicate c others in a project, or even rebuild something that was not needed in a completely different way. By having a strong understanding of what smart and dumb components are, we can create an organization so that we always know where to look for our reusable components and establish standards for how those components are used.

  • Helps avoid bugs: Less coupling of your code. Avoiding side effects.

  • Separation of concerns: The smart and dumb component pattern separates the responsibilities of business logic and rendering, making it easier to maintain and test each component independently.

  • Reusability: Dumb components are reusable and can be used in multiple smart components, reducing the amount of code duplication in the application.

  • Scalability: This pattern allows the application to scale easily by dividing the code into smaller, more manageable components.

  • Improved performance: By reducing the amount of logic in dumb components, the rendering process is faster, leading to improved performance.

  • The less code you have and the more organized your code is, the easier it is not only for you to understand your code, but for others to dive in and understand what is going on.

πŸ“ Cons

  • In projects that grow a lot, the team needs to pay attention and maintain the standard so that it doesn't get out of hand.

  • Sometimes a smart component can have too much responsibility if you don't know the moment to break it into other small smart components.

  • You cannot change the data passed through the input/output. Instead, you use the local state or leave the data maintenance to smart parents.

πŸ“ Conclusion

This is a concept that can be applied to enterprise applications that always tend to grow a lot. The approach of having a presentation component that just receives the data and prepares it for display can help us to think better about how to separate the responsibilities of each component within the application. I hope this article has been helpful. If you have any questions, please write in the comments. Thanks for reading and see you next post!

References:

An enterprise approach to the Smart and Dumb components pattern in Angular

How to write good, composable and pure components in Angular 2+

Β