In this article, you’ll find recommendations on how to make your Angular components and services more reusable, maintainable, and scalable.
Pure Functions
The most effective way to make your code reusable is to move as much code as possible to pure functions.
Such functions have no side effects, have no state, and don’t mutate their arguments. Every time we call a pure function with the same arguments, it will return the same result.
Let’s refactor a component’s method into a pure function:
export class ExampleComponent {
@Input() items: Item[];
@Input() users: User[];
@Output() connected = new EventEmitter<Item>();
private readonly errEmptyItemMsg = 'Item data can not be empty';
//...
protected addConnection(toItem: Item) {
if (!toItem.data) {
this.displayError(this.errEmptyItemMsg);
return;
}
let hasNewConnection = false;
for (const user of this.users) {
if (!user.itemRefs.includes(toItem.id)) {
user.itemRefs.push(toItem.id);
hasNewConnection = true;
}
}
if (hasNewConnection) {
this.connected.emit(toItem);
}
}
private displayError(errMsg: string) {
// ...
}
}
Method addConnection()
validates toItem, then adds a new item ID to every user. It can display an error and emit an event.
Let’s imagine that this method has not 18 lines, but 180, with complex validation rules. And suddenly, we need exactly this logic, with exactly the same validation (or a new data construction) in a completely different module of our application. Do not look at the “Copy” and “Paste” menu items in your editor — if validation logic is changed in one place, it should be modified in every other place.
Even if we were to swear to update this code in every place on every change, it’s still tightly coupled to our component. So, we simply cannot copy this code to any other place without also copying dependent parts of the component. You can easily spot this by looking for the usages of this.:
this.displayError()
this.errEmptyItemMsg
this.users
this.connected
We can split addConnection()
into two methods: validation and linking.
export class ExampleComponent {
//...
@Output() usersChange = new EventEmitter<User[]>();
protected addConnection(toItem: Item) {
if (!isItemConnectable(toItem)) {
this.displayError(this.errEmptyItemMsg);
return;
}
const { users, hasNewConnection } = addItemToUsers(toItem, this.users);
if (hasNewConnection) {
this.connected.emit(toItem);
this.usersChange.emit(users);
}
}
// ...
}
export function isItemConnectable(item: Item): boolean {
// here we have 100 lines of validation...
// ...
// ... very complex logic here ...
// ...
// ... not like this :)
return !!item.data;
}
export function addItemToUsers(item: Item, users: User[]): {
hasNewConnection: boolean,
users: User[],
} {
let hasNewConnection = false;
const updatedUsers = [] as User[];
for (const user of users) {
if (!user.itemRefs.includes(item.id)) {
updatedUsers.push({ ...user, itemRefs: [...user.itemRefs, item.id] });
hasNewConnection = true;
} else {
updatedUsers.push(user);
}
}
return {
users: updatedUsers,
hasNewConnection
};
}
Because the new functions should be pure, they cannot modify their arguments. So, in addItemToUsers()
, we had to create a new array of users and make a shallow copy for the users we want to connect, instead of mutating them directly.
// before
if (!user.itemRefs.includes(toItem.id)) {
user.itemRefs.push(toItem.id);
}
// after
if (!user.itemRefs.includes(item.id)) {
updatedUsers.push({ ...user, itemRefs: [...user.itemRefs, item.id] });
} else {
updatedUsers.push(user);
}
this.usersChange.emit(users);
This way, we actually fixed quite a nasty bug of data de-synchronization, described in more detail in this article. Now we will emit a new array of users every time a new connection is created, so the parent component will have the correct data. Bingo!
Now we can reuse our super complex logic in other places — the new functions have no dependencies, all they need are their arguments.
When refactoring your code into pure functions, require as little information in arguments as possible. If your function requires product: HugeProductDataStructure
and in its code it uses product
just for product.id
and product.price
, then it is much better to define arguments like productId
and productPrice
, rather than requiring a structure that might be difficult to fetch in some places of your code. But don’t over-explode your list of arguments ;)
Component State Management
Some methods just can not avoid using this because they have to modify the state of your component. You can still make such code reusable, by moving this logic into a separate file, called “local store”.
A local store might be represented as a class or as a function (see examples in NgRx SignalStore docs).
Here is an example of how we could move logic, responsible for saving users, to a local store:
@Injectable()
export class ExampleStore {
private readonly api = inject(ApiService);
readonly saveUsers = sideEffect<User[]>(_ => _.pipe(
exhaustMap((users) => this.api.saveUsers(users)),
catchError((err) => {
this.displayError(err.msg);
return EMPTY;
})
));
}
@Component({
selector: 'app-example',
// ...
providers: [ExampleStore]
})
export class ExampleComponent {
// ...
private readonly store = inject(ExampleStore);
protected saveUsers() {
this.store.saveUsers(this.users);
}
}
Because of this refactoring, we now can reuse logic, responsible for saving users' data (which often is not as primitive as this example) in other places of our code, simply by injecting the ExampleStore.
You can read more about the benefits that this refactoring brings, and about different kinds of stores, in this article.
In the previous code example, you might notice that saveUsers()
has just one line, and some of you may ask, “Maybe we could just call this method directly from the template? Why the extra wrapping?”
The answer here is: it is up to you to decide. Some developers trust their local stores and IDEs, so if the store’s method name or signature changes, the IDE will automatically update it in the template. Other developers say that templates calling only component’s methods look cleaner and more reliable to them. This topic is very subjective, and there cannot be only one correct answer.
Miscellaneous
Prefer composition to inheritance. Never extend components (yes, never). In some very rare cases, when you see that only inheritance can be used, it is okay to extend stores. However, never extend components. We might think that we increase code reusability this way, but there are other ways to achieve the same result without inheritance. What we sacrifice when we extend components is maintainability, and that is a very important aspect of the code.
Even if
::ng-deep
is un-deprecated, try to avoid it. When it’s not possible (and there are such cases), use it with extreme care. To help your future self, use very specific selectors inside::ng-deep
. When you reuse components that use::ng-deep
, and these components have multiple levels of sub-components, things might quickly go out of control if the selectors inside::ng-deep
are not specific enough.When using Observables, avoid nested subscriptions. Additionally, methods of your services that return observables are much more reusable than methods that subscribe. You don’t always need that level of reusability, but sometimes you might want to reuse code that is inside a method that subscribes and unsubscribes. In such cases, just refactor that method into two: one that returns an observable, and the second one that uses that observable.
🪽 Do you like this article? Share it and let it fly! 🛸
💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.
Top comments (1)
Hi Evgeniy OZ,
Your tips are very useful
Thanks for sharing