Original cover photo by Issy Bailey on Unsplash.
What this article is about
When developing complex software, we often resort to using front-end frameworks, like React, Vue, Angular, and so on. Then we often also adopt UI libraries, testing tools, small libraries that solve specific problems, and more, and more, and more. These tools provide easy, out-of-the-box solutions to common problems, often so in spectacular fashion. Of course, there is a raging debate on whether one should adopt third-party solutions, whether is it costly, does it make the developer experience better, does it make the user experience better, the size of node_modules
, etc.
But this article is not about that debate
So what is it about?
This article is about when we have already adopted those tools, but want to use them in as much harmony as possible. See, after the initial excitement of lots of our problems just going away in a pulp of smoke, we discover a simple material reality: those frameworks and libraries (all of them!) have limitations and rules too. So sometimes we will end up in situations like the following:
- We want to implement a feature
- To do it, we want to do something with a component/service/function coming from a library
- Oops, the library either does not support it, or has a bug, or requires lots of code to actually handle this particular problem
- Now starts a discussion: should we abandon the feature, should we abandon the framework (is it even possible?), or should we adapt in a way?
Of course, this is only one scenario, there are plenty of others: the library just has a bug, the feature we want to implement requires us to do something that is actively discouraged by the core maintainer of the library, and so on.
So what is the solution?
Let's discuss several options that we have when it comes to this, specifically in the context of Angular and its ecosystem.
- Plan ahead, but
- Don't overengineer
- Stay aware of future possible scenarios and leave doors to be able to implement them when necessary
- Do not implement features (components, methods, and so on) only because you think they will be useful in the future - only do that when you are absolutely sure they are needed
- Prefer to extend functionality instead of wrapping it
- If you can't extend, and absolutely have to wrap, do it in a way that is as transparent as possible, but does not monkey-patch
- If you absolutely have to monkey patch something, document it extensively. We will talk about it in a separate section
So let's now start going over these options in more detail.
1. Planning ahead
Imagine you are just starting a new project, and you are thinking about choosing an Angular UI library. There are plenty of options - Angular Material, PrimeNG, NgBoostrap, and so on - we are not going to discuss which one is "better" - it is a pointless debate - but instead, we will focus on some ground rules that will help you make a decision.
- Choose a UI library closest to the design at hand - in your mind, in a Figma file, or another. It is virtually impossible to have a UI library that will 100% be like the design you want - you will need some changes in the components that the library provides, so pick carefully there
- If you know you are going to change a bunch of stuff, choose a library that is easier to change, but be careful not to end up with a
styles.scss
file that is thousands of lines of code long. - If you want customization, look into tools that the library itself (or the community) provides for generating custom themes. For example, there is a tool for Angular Material that allows you to generate a custom theme, and then use it in your project.
- When customizing, use the code-level tools that the library gives. As an example, PrimeNG always puts an
Input
property on its components where you can pass a CSS class to it and then use it
Of course, this example is only about UI libraries, but you can apply it to other tools you struggle to choose from too; be sure that the tool you end up with fits best, is extensible, and is easy to customize.
2. Avoiding overengineering
This one is straightforward: don't overengineer. If you are building a small app, sometimes reinventing a relatively small wheel can be a more cost-effective approach than trying to integrate a library that might come with other requirements and even maybe a paradigm shift. If you have a large app, don't immediately assume you need a huge tool that solves multiple problems and has a myriad of blows and whistles. Sometimes a simple, -solves-exactly-one-problem library is the best choice.
3. Leaving doors open
The following scenario happens too often not to think there is a pattern to it:
- Developer builds a component/directive/service for a specific feature
- Provides the most basic functionality
- Couples it too tightly with the feature
- When something like that is needed elsewhere, it is almost impossible to reuse; future developers either copy-paste the solution and make slight changes, or introduce huge configuration objects that are hard to understand (and even document)
Or, on the other hand, to avoid this scenario, someone might just start off with a huge configuration object and go on to add more and more complexity to it. So how to avoid it, specifically working with Angular?
- Think about whether the functionality can be useful in the future. If you think "yes", then go on to the next step
- Is it possible to make that functionality with a directive, rather than a component? Directives are often underutilized, and have way more powers than one might expect - see this article by Tim Deschryver for more details. Directives are also simpler than components (in general), and are easier to reuse.
- Avoid using dependency injection, and when I say avoid, I mean just avoid, do not absolutely prohibit it. If we use a business logic-specific service in a component/directive/anything else that is supposed to be reusable, then it is tightly coupled with only certain components/pages in our app. So using a generic service like the
localStorage
wrapper is fine, but theUserService
might not be.
4. Avoiding feature creep
As mentioned in the very first bullet point of this article, we should generally plan for the future; however, we also need to keep in mind that more features = more complexity = more bugs = more maintenance. So we need to be careful not to overdo it. How do we differentiate?
- As Angular has dependency injection, we can be tempted to wrap third-party library code, especially services, into our own services with an API more suitable for our needs. We will talk more about this in the next section, for now, remember to write methods in such wrappers that you actually need, and not to just wrap every single method from a library.
- When we notice that a certain component/directive is behaving very differently based on an input (usually a
boolean
named in lines ofisSomething
), we should think about whether it is possible to split it into two components/directives. This is a very common scenario, and it is usually a sign that the component/directive is doing too much. Remember the single responsibility principle. - Avoid methods that combine functionality when writing your own services; encourage the same methods when wrapping third-party libraries; for example, if a third-party library you use provides methods for both setting some data and setting a loading state, and you overwhelmingly use those methods together, just wrap them as one in tour wrapper; but if you are the one providing such functionality, provide separate methods, as in the future it might be unclear how it is going to be used.
5. Extending functionality
Notice that when we say extend
functionality, we do not literally mean extend
as in OOP or anything to do with classes. For example, starting from Angular v15, it is possible to extend existing directives using hostDirectives
properties (read more about it here), so make use of a mechanism such as that one. Use dependency injection to compose functionality, and not to couple components/directives/services to each other.
Also, when we work with third-party components, especially UI components, there is often a temptation to wrap them inside our own components and maybe provide default values for some of their inputs. Imagine this piece of code:
<div>
<third-party-dialog
[open]="isOpen"
[modal]="true"
[closable]="true"
[closeOnEsc]="true">
<app-other-component/>
</third-party-dialog>
</div>
So it might be tempting to wrap it in our own component:
@Component({
selector: 'app-dialog',
template: `
<div>
<third-party-dialog
[open]="isOpen"
[modal]="modal"
[closable]="closable"
[closeOnEsc]="closeOnEsc">
<ng-content></ng-content>
</third-party-dialog>
</div>
`
})
export class DialogComponent {
@Input() isOpen: boolean;
@Input() modal: boolean = true;
@Input() closable: boolean = true;
@Input() closeOnEsc: boolean = true;
}
And use it like this:
<div>
<app-dialog [isOpen]="true">
<app-other-component/>
</app-dialog>
</div>
Surely this works and is kind of an improvement over what we had previously; for instance, we do not need to provide all of the input values constantly, and there is less boilerplate code.
However, there is a couple of problems:
- We need to kind of, to be future-proof, write and pass down all the third-party component inputs in the wrapper component, and a third-party UI library component is probably going to have a bunch of inputs
- Outputs! Events from the third-party component are not passed up to the end user, so we need to catch and re-emit them manually
- If we forget to do something from the previous two points; if we want to implement something that uses a "forgotten" input/output, now our feature will also involve updating the wrapper component
So to avoid it, it is better to extend the component using a directive as described in the above-mentioned article by Tim Deschryver, like this:
@Directive({
selector: 'third-party-dialog'
})
export class DialogDirective {
constructor(
private dialog: ThirdPartyDialogComponent,
) {
if (dialog.modal === undefined) {
dialog.modal = true;
}
if (dialog.closable === undefined) {
dialog.closable = true;
}
if (dialog.closeOnEsc === undefined) {
dialog.closeOnEsc = true;
}
}
}
Here we are sort of hijacking the ThirdPartyDialogComponent
and setting default values for its inputs if the values are not provided. This way, we do not need to pass down all of the inputs, and we do not need to re-emit events at all:
<div>
<third-party-dialog
[open]="isOpen"
(someEventNotHandledInTheDirective)="handleEvent($event)">
<app-other-component/>
</third-party-dialog>
</div>
Another popular example of this is HTTP Interceptors in Angular. Sometimes Angular developers would wrap the HttpClient
service inside their own implementation in the hope to add new functionality, for example, adding headers, dealing with cookies, checkings, and so on. But instead, intercepting requests/responses and adding functionality "on-the-flight" is on 99% of occasions way more than enough. Here is an interceptor that sends the client's timezone offset with each request in a header:
@Injectable()
export class TimezoneInterceptor implements HttpInterceptor {
intercept(
request: HttpRequest<any>,
next: HttpHandler,
): Observable<HttpEvent<any>> {
const timezoneOffset = new Date().getTimezoneOffset();
const modifiedRequest = request.clone({
headers: request.headers.set(
'X-Timezone-Offset',
timezoneOffset.toString(),
),
});
return next.handle(modifiedRequest);
}
}
So requests can be fashioned in this way in multiple scenarios, once again proving that wrapping even services in Angular is rarely the best solution.
6. Wrapping? Wrapping!
So if we are past the scenario where the framework allows for an extension instead of wrapping, say, it is not possible, do we wrap, or do we just use the service as is? I argue we must wrap.
Here are a couple of reasons for this:
- We can provide a more suitable API for our needs - we might not like method names, for example, they might be inconsistent with our project's naming conventions or ideas, so wrapping would allow overcoming this (albeit minor) obstacle
- If the third-party service does not have some functionality that we want, or we want to modify the behavior we want, we can do it in the wrapper
- This approach is future-proof: if the library goes out of maintenance, or we decide to switch to a different library, we can do it without changing the code that uses the wrapper - just change the wrapper with the new library's service
- No need to mock third-party dependencies in unit tests - we can just mock the wrapper
So if we are comfortable with the above-mentioned reasons, we now need some ground rules for the wrapper
- The wrapper should be as thin as possible, mainly just a thin layer that calls the third-party service and returns the result (of course with some minor modifications if needed)
- No business logic here - the wrapper is just a gateway to the third-party service, it should not do anything else, thus keeping it reusable in multiple places
- Unit test extensively - this will save lots of trouble down the road
- Never monkey patch the third-party service - functionality provided by the third-party service should not be modified, only extended, as already mentioned a couple of times. This will help avoid confusion by future developments and also prevent unintended behavior/bugs from being added to the dependency
Here is an example of a service that wraps the localStorage
API:
@Injectable()
export class LocalStorageService {
get(key: string): string | null {
return localStorage.getItem(key);
}
set(key: string, data: string) {
localStorage.setItem(key, data);
}
remove(key: string) {
localStorage.removeItem(key);
}
clear() {
localStorage.clear();
}
has(key: string): boolean {
return this.get(key) !== null;
}
get length(): number {
return localStorage.length;
}
}
So as you can see here, we made a very thin wrapper around localStorage
, mostly just calling the methods, but changed a name to shorter versions in our liking, and added a has
method for convenience. Most API erappers are going to look like this.
Thus, after all those rules have been kept, we can finally approach the hardest problem of them all.
7. What if we really want to monkey-patch something?
Sometimes though, we encounter a really nasty issue - the third-party library has a bug that cannot be worked around and is impeding a vital feature. So what do we do in this scenario? Here is a list of steps that need to be done before considering just monkey-patching the problem.
- Check if the issue exists for other environments or users/machines
- Check if the issue still persists on the later versions of the library
- Check if the issue has been submitted to GitHub and if there is a discussion on it
- Check if your requirement cannot be changed in order to avoid dealing with the "buggy?" part of the third-party library
- Submit an issue on GitHub and see how it is being discussed
- Consider submitting a PR to fix the issue - that would be great for the community and yourself
- Consider if extending, wrapping, or otherwise interfering with the library might fix the issue
If all those steps didn't yield the necessary results (the library is no longer maintained, the team won't accept your PR, you are low on time, and so on), go for monkey patching the issue. After that, follow these steps:
-
Document extensively the logic and reasoning behind the monkey-patching:
- Describe the problem
- If there is a GitHub issue, put a link to it
- If a future version is promised to fix the issue, put a link to that statements
- Put a
// TODO
comment - Describe how exactly the monkey-patching is done
Write unit tests for the monkey-patching - again, very extensive, while mocking the other behavior of the library
Put the idea of one day removing this patch somewhere in your backlog
DO NOT under any circumstance add business logic in the monkey-patching code - this would be almost impossible to remove in the future and is probably the most tightest coupling that one can imagine.
Here is an example of monkey-patching a bug in the PrimeNG UI library:
Note - this is a real-life example that is harder to comprehend, so the specifics of the fix are omitted, main focus is on the documentation and overall approach around it:
/**
* @description
* This directive is for fixing a bug on PrimeNG
* (github link to issue)
* InputNumber component to alow add -(minus)
* sign with prefix attribute. <br>
* This works by monkey-patching the InputNumber component's
* insert and update methods.
*
* TODO: Remove this directive when the bug is fixed in PrimeNG
* <b>Author:</b> Maybe put your name so
* people know who to address if they have questions
*/
@Directive({
selector: 'p-inputNumber', // find all the p-inputNumber elements
})
export class InputNumberDirective implements AfterContentInit {
constructor(private inputNumber: InputNumber) {}
ngAfterContentInit() {
// first we keep the native methods
// we want to monkey-patch bound to the instance
const updateInput = this.inputNumber.updateInput.bind(
this.inputNumber,
);
const insert = this.inputNumber.insert.bind(this.inputNumber);
// now we monkey patch it
this.inputNumber.insert = (
event,
text,
sign = {
isDecimalSign: false,
isMinusSign: false
}
) => {
// we do some monkey patching logic
// Prevent the prefix sign from being deleted
// some heavy lifting
// we call the native method then
insert(event, text, { ...sign, isMinusSign: false });
}
this.inputNumber.updateInput = (
value,
insertedValueStr,
operation,
) => {
// perform the native method first
updateInput(value, insertedValueStr, operation);
// preventing navigation of cursor
// to the end of input after entering negative value
// a bunch of other heavy lifting
};
}
}
And that is about it.
In Conclusion
Working with libraries and frameworks can be both fun and challenging, so in this article, we tried to approach some problems when doing so in Angular. One last piece of advice - always remember no advice is 100% perfect and think about the problem at hand.
Top comments (4)
Great article. I actually avoid frameworks for the exact reasons you cite: bugs and inflexibility but also the learning curve. When I only need a fraction of the functionality itβs just easier to cobble it together without a framework.
Thanks!
What is the issue with dependency injection? It allows more loose coupling as you can swap out the dependency with a different implementation as long as it fulfills the contract.
No no, the issues isn't with DI, but with using it somehwere where you better pass an event to somewhere else where it can be handled. Makes a component reusable as opposed to coupling it with a specific business logic heavy service