Hey everyone!
I am Aiyush, one of the Engineering Managers over here at Fyle.
At Fyle, one of the things that we pay a lot of priority to is implementing clean, reusable code that makes development easy and blazing fast.
To do this, we do a round of planning before any solution we build for our customers. For building things in Angular, we needed a clear and clean-cut way to model our components in a way that favours reusability and decomposes the UI into composable chunks.
Components are a very good way to do this but over the course of the last many years, we have discovered a few patterns which make life much easier during planning, implementation and maintenance.
This article will aim to highlight my learnings in the area of building clean components using presentational components.
Before I start off with what I mean by clean components, let me put a picture of a pure function in your head.
Pure Function
What is a pure function?
A pure function is a function that has the following properties -
Always returns the same output for the same input arguments
Doesn't mutate(modify) the arguments which are getting passed in
Doesn't update the global state
Doesn't read from global state
A good example of a pure function -
/*
* This function takes in a list of filters and adds a new filter to it,
* based on an update argument which is passed. If the passed argument is a sort
* we just skip it.
*/
function updateFilters(filters: string[], update: { property: string; value: string; }): string[] {
const filtersCopy = [...filters];
if (update.property !== 'sort') {
filtersCopy.push(`${property}:${value}`);
}
return filtersCopy;
}
Here’s are a few examples of impure functions of the same implementation
// updating global state
function updateFilters(update: { property: string; value: string; }): string[] {
if (update.property !== 'sort') {
this.filters.push(`${property}:${value}`);
}
}
// updating arguments
function updateFilters(filters: string[], update: { property: string; value: string; }): string[] {
if (update.property !== 'sort') {
filters.push(`${property}:${value}`);
}
}
// making an API call
function updateFilters(filters: string[], update: { property: string; value: string; }): Observable<string[]> {
if (update.property !== 'sort') {
filters.push(`${property}:${value}`);
}
return this.apiService.updateFilters(filters)
}
What benefits do you get from writing a pure function?
Since the function doesn't read from the global state or write to the global state, this method will always have predictable behaviour and you wouldn't need to read the contents of the method as long as the naming is correct and you are aware of the data types of the arguments and response.
The functions are very easily unit testable and very easy to get code coverage on since you won't deal with any kind of external state/dependency messing up the internals.
Since the return statement will always give you the values that you require, you don't have to be worried about the arguments getting incorrectly updated or unintentional changes happening.
One more thing to keep in mind, pure functions can use other pure functions and it will still remain pure. But even if one of its dependencies is on an impure function, it will become impure.
Presentational Component
Now, visualize this -
What if you had the equivalent of a pure function in components?
A component that would be devoid of side effects and would only depend on the inputs being passed into it.
A component you can trust - because you can test it infinitely and very easily get coverage of each and every statement inside it.
That is what a presentational component is. (Ref - this is an in-depth guide on presentational and smart components)
A presentational component is a component which is responsible just for displaying information and taking and delegating input from the user through its outputs.
There are a lot of opinions in the industry on what constitutes a presentational component. Here are the things which I have seen work well.
Presentational components should contain a minimal internal state. Most of its state should come from its inputs.
If certain inputs need to be modified inside the presentational component, do it inside the
ngOnInit
andngOnChanges
hooks using pure functions you define inside services.Use only pure functions from services inside the presentational component.
If the presentational component is getting too large, break it down into further presentational components.
Let's look at an example and understand how to do this.
You have an object called transaction and you want to display a list of them to the user.
Assume transactions have the following data structure - We use a similar structure (a list of it) for fetching transactions from the API and showing it in the UI.
{
id: string,
amount: number,
current: string,
category_id: string,
image_url: string,
isDataExtractionOngoing: boolean
}
Remember, before passing it to the presentational components, you cannot make an API call from inside the presentational component since it impacts the rule You can only use pure functions inside a presentational component
So you mutate the data and create a new object, by making a call at the parent.
{
id: string,
amount: number,
currency: string,
category_id: string,
category_name: string,
image_url: string,
isDataExtractionOngoing: boolean
}
Now, your requirement is to show the user a list of these expenses.
So we create a presentational component - app-expenses-list
@Component({
selector: 'app-expenses-list',
templateUrl: './expenses-list.component.html',
styleUrls: ['./expenses-list.component.scss'],
})
class ExpensesList {
@Input() expensesList: Expense[];
@Output() expenseClicked = EventEmitter<Expense>;
@Output() expenseRightSwiped = EventEmitter<Expense>;
@Output() expenseLeftSwiped = EventEmitter<Expense>;
clicked(e: Expense) {
this.expenseClicked.emit(e);
}
rightSwiped(e: Expense) {
this.expenseRightSwiped.emit(e);
}
leftSwiped(e: Expense) {
this.expenseLeftSwiped.emit(e);
}
}
expenses-list.component.html
<ng-container *ngFor="let expense of expensesList">
<app-expense [expense]="expense"
(expenseClicked)="clicked($event)"
(expenseRightSwiped)="rightSwiped($event)"
(expenseLeftSwiped)="leftSwiped($event)">
</app-expense>
</ng-container>
Now for the expense component
@Component({
selector: 'app-expense',
templateUrl: './expense.component.html',
styleUrls: ['./expense.component.scss'],
})
class ExpensesList {
@Input() expense: Expense;
@Output() expenseClicked = EventEmitter<Expense>;
@Output() expenseRightSwiped = EventEmitter<Expense>;
@Output() expenseLeftSwiped = EventEmitter<Expense>;
clicked() {
this.expenseClicked.emit(this.expense);
}
rightSwiped() {
this.expenseRightSwiped.emit(this.expense);
}
leftSwiped(e: Expense) {
this.expenseLeftSwiped.emit(this.expense);
}
onSwipe(event) {
const x = Math.abs(
event.deltaX) > 40 ? (event.deltaX > 0 ? 'Right' : 'Left') : '';
if (x === 'Right') {
this.rightSwiped();
} else (x === 'Left') {
this.leftSwiped();
}
}
}
<div (click)="clicked()" (swipe)="onSwipe($event)">
<div *ngIf="expense.amount; else emptyAmount">
{{expense.amount | currency: expense.currency }}
<div>
<ng-template #emptyAmount>
-
</ng-template>
<div *ngIf="expense.category; else emptyCategory">
{{expense.category | toTitleCase}}
<div>
<ng-template #emptyAmount>
category not assigned
</ng-template>
</div>
The place where these would be used
<ng-container *ngIf="expenses$|async as expenses">
<app-expenses-list [expensesList]="expenses"
(expenseClicked)="clicked($event)"
(expenseRightSwiped)="rightSwiped($event)"
(expenseLeftSwiped)="leftSwiped($event)">
</ng-container>
Now, let's look at these components we just made.
They are very easily unit testable since you will no longer have to deal with any async code inside the portions which deal with rendering. So writing component tests will be a breeze
Proper abstraction is being maintained which allows you to use the components without reading the code inside and you don't have to worry about these components making API calls or causing router changes or any state changes because all of this will be handled by the parent
You can plan these components very quickly just by chalking out the inputs and outputs of each component and their data types. This will allow you to get a high-level overview of the data flow in whatever you are building with a birds-eye view and architect big changes much more easily and predictably.
By keeping the same mental model for pure function for a component, we can decisively break down our application into much more reusable parts and also improve reusability and testability.