Design: #noFramework
Is it as hard as you think?
Trendy ones used to be Angular, then React, now Vue.js… others like Ember, Backbone or Knockout have nearly disappeared. Standard ones like Web Components are seldom used, “yet another framework” seem to ship every year, like Svelte, Aurelia, Quik or Fresh and each one is now featuring its server side counterpart (NestJS+Angular Universal, NextJS or Nuxt for the first mentioned ones, Sapper for Svelte, etc.). Not speaking about non-Javascript web frameworks (Django, Spring, Laravel, Rails, Blazor, etc.). There’s even frameworks over frameworks (Quasar, SolidJS), frameworks to generate components code for frameworks (Stencil, Mitosis) and, at the end of that spectrum, NCDPs.
This multiplicity is confusing for both developers who want to know which technology is worth learning, and deciders who have to make strategic choices.
Pretending to help, comparison articles are published or updated as often. However most of them are usually biased as “I worked with this one” versus “I tried others a bit”. Less biased authors will conclude with a “it depends” (on performance, tooling, support, community, etc.), which is just another way of being inconclusive.
Even benchmarks, by comparing the same application on every framework, can hardly provide a realistic comparison, as limited by the scope of dummy app (such as a todo list).
In the end, frameworks look like religions (or politics): each of them pretend to have the solution, but each of them is different. Each of them claim to provide the best vision of ̶t̶h̶e̶ ̶w̶o̶r̶l̶d̶ an app, but there are heated debates about which one holds the truth. Each of them requires you to follow specific rules and, while there may be similarities, it is always hard to convert from one to another.
Let’s look at an “atheist” approach of frameworks: use none.
Where am I talking from?
Aside from a 25+ years of professional experience in software development, this article is based on experience in building a real-world vanilla JS web app (front and back).
Why not using frameworks?
Actually the idea is not new. As back as 2017, Adrian Holovaty, co-creator of the Django web framework, spoke about his own frameworks fatigue and why he left Django to build his own vanilla JS project.
A year later, a “frameworkless” movement was launched by Francesco Strazzullo and others, who ended up publishing a book entitled Frameworkless Front-End Development.
One may wonder why would someone want to dive into such a (supposedly) hard task of building a web app without a framework. Why not building an app on top of previous work, using robust frameworks with hundreds of years of combined engineering hours? Is it a NIH syndrome that will eventually lead to building a custom framework?
No, that’s not the reason. Developers are not more inclined to masochism than the general population. Actually, they are probably more lazy than anyone: they want to write less code (so they get less bugs), they want to automate processes (to avoid human mistakes)…
…but they also want to be agile, that is, to be able to handle any problem easily and so, quickly.
While “quick” sounds akin to one promise frameworks make (to save you time by doing plumbing for you, while increasing reliability), this doesn’t come for free: they want you to:
- sign a contract to agree with a tax
- put your code into a silo*.
The Framework Tax
Framework services come with a cost. They require you to:
- comply with their API so that they can provide your their services. This is just the way frameworks work: your code need to adhere to some rules, including more or less boilerplate code. So it’s the framework way, or the highway. Your daily challenges will be less about “how to do this” than “how to make the framework (not) do this”. Try to dodge those constraints, and you’re at risk: bypassing a framework by directly calling low-level APIs means that you cannot expect to be understood, and so you cannot expect the framework to be consistent with it. So it’s a false promise frameworks make that you’ll be “focusing on your business”: in reality you have to care on the framework too, and a lot. Sometimes, you will spend more time handling a framework-specific problem than a business problem.
- upgrades are effectively forced if you:
1) want a new feature (even if you didn’t wanted all those of the next release, you need to upgrade the whole thing) or
2) want a bugfix, or
3) want to avoid loosing support (as new versions are shipped, the one on which you have based your app will get deprecated).
Upgrades can also be lacking and let you frustrated (and possibly with a project at risk) with an identified bug but no planned date for a fix. Third-party framework-specific libraries (such as widgets) or plugins are no exception to that rule and will be less and less compatible with your app if you keep using old versions. Maintaining backward compatibility has became such a hassle for frameworks authors that they now find more profitable to work on tools that automate upgrades of your code as much as possible (Angular’s ng-update, React native Upgrade helper, Facebook’s jscodeshift, etc.). Because they know that, without such automation, you would have dumped them already.
- train to learn about their “magic” (what they can/cannot do, what are their concepts, APIs, ecosystem, tools… but never how they do it), including about changes that may occur in upcoming versions. Should you pick the most popular framework of the day, this might be easier, but it’s unlikely that you’ll ever know about every aspects of a given framework. Also, hype comes and goes: should you decide to use another framework for a new app (or even worse, to migrate from one to another), your investment in such proprietary knowledge will be lost (because this is mostly about proprietary magic, not patterns you could reuse elsewhere). This explains a lot of inertia in enterprise projects, even if each project is different than the previous one. “Compatibility means deliberately repeating other people’s mistakes,” said the late David Wheeler.
- compromise with the drawbacks implied by delegating control: you may not be able to do whatever you want (or to prevent the framework from doing things you do not want) or you may not achieve the performance you want (because of additional layering, too-generic code, bigger code size or backward compatibility requirements).
- loose skills. A number of developers either don’t know much about the lower-level APIs (because they always used the framework layer instead) or live in the past (i.e. are stick on an outdated knowledge of it, not being aware of the latest improvements and new capabilities). The law of the instrument then leads too often to build overkill solutions to simple problems, and loose (if even once acquired) knowledge to build simpler ones. Being guided by blueprints and recipes, they loose (or not gain) a culture of good software design (principles, patterns) and barely build a significant engineering experience. Just like users of CSS frameworks (Bootstrap, Tailwind, etc.) lack CSS skills, users of web frameworks are doomed to lack experience in both modern web APIs and software design in general.
The Framework Silo
Aside the “tax” that you have to pay to get their benefits, frameworks can also induce an additional major issue when they are not standard.
As they enforce rules — but each one of them is different — this implies binding your app with a proprietary ecosystem. That means locking your app code with a proprietary API (and its upgrade process). That’s a risky bet for your project, as it implies:
- no portability: migrating your code toward another framework (or a new version of it with breaking changes, or even vanilla code) will be very costly, including the cost of possible re-training ;
- no interoperability of your code with other frameworks runtime or other framework’s components libraries that you’d like to use: as their rules are different, most frameworks do not interoperate easily one with each other.
Of course you can get reassured by selecting the most popular framework… at the time of your project starts. That may be acceptable for an app that is quite short lived, but not for a long term investment.
Standard frameworks, however, don’t imply this silo effect. On the web platform (i.e. the browser framework), using standard web APIs makes your investment less risky are they are expected to work on most browsers. If not all (or not all the ones you target), support can still be provided through polyfills.
For instance, Web components are today both portable (they can be used in nearly all browsers) and interoperable (can be used by any code, including proprietary frameworks) as they are encapsulated as any HTML element. Even better for performance, their runtime (Custom Elements, Shadow DOM, HTML Templates) is executed as part of the browser, so it’s both already there (i.e. not downloaded) and native.
So are frameworks bad by nature?
No, if only because coding an app almost always results in creating your own framework: any app implement its own business rules to be enforced on its own business components.
So frameworks are a good thing if either they:
- are standard or end up with a standard. For instance the web platform is a standard web framework, and Web Components frameworks (lit, stencil, skatejs, etc.) end up building components that comply with the standard.
- are product-specific, so you have no choice but to comply with it if you want to ship a product-related component. This may be an Azure or Chrome extension, a Webpack or Parcel plugin, an OS-specific app, etc. Locking is not an issue here as product-specificity is the very goal (even if upgrades will remain a pain).
- are used to build non-critical apps (short lived, with lower quality expectations) where tax and silo effect are acceptable. For instance it makes sense to use Bootstrap to build some prototype, MVP or internal tool, but not professional websites.
- are app-specific: they make sense and are useful only in the context of a specific app. This is the case we’re going to discuss.
The goal
So, in a nutshell, avoiding general-purpose frameworks to build an app aims to:
- maximize flexibility by avoiding “one size fits all” constraints from frameworks. Also, not having blueprints avoids the law of the instrument to increase the creativity for ambitious applications. Most web apps using Bootstrap can be recognized as such, because they’re having a hard time getting out of the predefined components and styles. In the end, they’ll have a hard time thinking another way.
- minimize dependency to any of the currently hyped frameworks. Not being locked with a framework avoids issues with portability and interoperability.
- maximize performance by allowing the most fine-grained operations only when required (no framework-dependent refresh cycle for instance) and reducing dependencies to a selection of precise, required-only, set of lightweight libraries.
And, of course, the goal is neither to “reinvent the wheel”. Let’s see how we can do that.
The alternative
So, what is it to build apps without framework tax and silo?
First, we must clarify the anti-goal: “building an app without a framework” is NOT to be confused with “replacing the framework”. This is not the challenge at stake: a framework is a general purpose technical solution to host virtually any app, so it is less about your app than all apps. On the opposite, going vanilla is an opportunity to focus on your app’s needs only.
This is an important scope narrowing to make to assess the (non-)difficulty of building your app without a framework: it is not as hard as building a framework, because you do NOT aim to build:
- a proprietary component model (a container implementing a specific components lifecycle)
- a proprietary plugins/extension system :
- a fancy template syntax (JSX, Angular HTML, etc.)
- optimizations that make sense for general-purpose (change detection, virtual DOM)
- framework-specific tools (debugging extensions, UI builders, version migration tools)
So building a vanilla app is not an enormous task of “reinventing the wheel” as often caricatured, because the major part of this “wheel” is actually about the APIs/contracts, their implementations, the general-purpose engine and associated optimizations, the debugging capabilities, etc.. Leaving the general-purpose goal and focusing on your app’s goals means that you can rid of most of it. Ironically, this is the real “focus on your app” approach.
Now, how to design and implement a vanilla app? When most of apps are built using a framework, it may indeed be hard to devise a way to achieve similar results without that familiar instrument. You’ll have to:
- change your state of mind: don’t look for the framework-specific services mentioned above. As a vanilla app, you will probably don’t need it. Don’t think change detection, just update the DOM, etc.
- use technical alternatives for the common tasks you performed with frameworks (updating the DOM — including reactively — , loading lazily, etc.)
This latter topic has been addressed by authors like Jeremy Likness (who has devised some bits of JS to provide common framework facilities to a vanillaJS app), Chris Ferdinandi (a.k.a. “the vanilla JS guy”) and compiled by the frameworkless movement but, by definition, any vanilla app may choose to use one of those techniques or not, depending on its needs. For instance, the authors of MeetSpace didn’t need much more than the standard APIs. Jack Franklin (a Google engineer working on Chrome dev tools, Puppeteer and other tools) also emphasized a small set of recommendations and shared his feedback on going vanilla:
After working with React for over five years, I was nervous about moving on and not having its power and features available to me. It’s ended up being a pleasant experience.
Let’s look at a number of common recipes, though.
Standards
As we have seen above, standards APIs are among the “good frameworks” as they:
- allow portability: they are expected to be available everywhere. When not yet available, they can be polyfilled.
- allow interoperability: they can interact with other standards and be used by proprietary code.
- are long lived: as devised by multiple industry actors rather than only one, they are well designed and here to stay once released. So investing in them is less risky.
- are immediately available in the browser most of the time, which avoids downloading them. In some case you may have to download polyfills instead but, contrary to proprietary frameworks (which are doomed to be less and less trendy), their fate is to be more and more available (thus reducing download probability).
One could also consider that the choice of the programing language should focus on standards. JavaScript have evolved over the years, and now contains features usually provided by other languages like the class
keyword, or limited type checking support through JSDoc comments such as @type
.
A number of languages transpile to JavaScript: TypeScript, CoffeeScript, Elm, Kotlin, Scala.js, Haxe, Dart, Rust, Flow, etc. Each of them adds different values to your source code (type checking, additional abstractions, syntax sugar); should a vanilla app use them? To answer that question, let’s look if they imply the same drawbacks as frameworks:
- comply with their syntax: by definition, most languages enforce this (CoffeeScript, Elm, Kotlin, etc.) with the notable exception of those which are supersets of JavaScript (TypeScript, Flow) which allows you to write some the parts of your choice using pure, lower-level JavaScript.
- upgrade can be required if you use very old versions of any language (including JavaScript itself) but at a very lower pace than frameworks.
- train, by definition again, is required to use their syntax. However superset languages allow you learn progressively, since you can stick on traditional JS in some parts of your code.
- loosing skills about the target language (JavaScript) is indeed a risk for non-superset languages, as the transpilation/compilation is general-purpose, can be non-optimal, and you may be unaware of it. Maybe you could have performed the same operation with more simple and efficient JS code.
- compromise with the drawbacks is indeed required, as you cannot change the transpilation to JS (or just customize it a bit, using
tsconfig.json
for instance) nor the compilation to WebAssembly. Some languages may also omit some JS languages concepts. - portability is achieved, as transpilation can usually target ES5 (but sometimes you will have to compromise with that target even if you’d wanted to target ES6). WebAssembly is more recent but supported by all modern browsers.
- interoperability with other JS code is provided or not. Typescript can be configured to allow JS for instance.
As you can see, you should we wary about the source language to use in a vanilla app, as all of them imply more or less constraints. Superset languages (TypeScript, Flow) allow to minimize those constraints by avoiding an “all or nothing choice” and can be used only where it adds value.
In any case, keep in mind that adding a language layer above JavaScript implies a layer of complexity in your tooling chain that may fail for some reason (see below). Also, development-time benefits are lost after compilation/transpilation (type checking or restricted visibility might not be enforced at runtime typically).
Libraries
As for the “rewrite a framework” false assumption, it is often considered that vanilla JS apps are NOT supposed to use libraries. This is utterly false. Once again, “reinventing the wheel”, i.e. rewriting everything from scratch cannot be a sensible goal. The vanilla goal of removing constraints implied by frameworks and not libraries, must not be confused with a “write everything by yourself” dogma.
So there is nothing wrong in using libraries as code that you cannot write by yourself (because you don’t have time to do it, or because this requires too much expertise). All you have to care about:
- modularity: avoid using a big lib if you use only a small percentage of it;
- avoid redundancy: only use a lib if there is no standard (or polyfill of it), and prefer libs that implement a standard;
- avoid locking: don’t use the lib API directly, wrap it in your own app API.
Oh, and don’t be fooled by frameworks documentation or articles that would claim that they are not a framework (because they would be “unopinionated”, or not defining a “complete application“, etc.): as soon as they imply a contract, they are.
Patterns
As Holovaty says, the option to just applying patterns to structure your software (instead of using frameworks) is not considered enough.
Those patterns are well-known and are not specific to vanilla development. They are themselves self-documenting since they are quickly recognized by experienced developers (providing you name them correctly).
To name a few:
- splitting Model, View and Controller (MVC);
- factories to create objects depending on configuration;
- observers to ease reactive programming;
- iterators to virtualize collections (lazy load their elements);
- proxies for lazy loading, security checks, etc. but also reactive programming using dynamic proxies (AOP);
- commands to encapsulate operations that might be triggered from various contexts.
This list is not exhaustive, nor required: you are free to use what fits your needs, but when one pattern provides a typical solution to a typical problem of your app, you should definitely apply it. More generally, anything that fulfills SOLID principles and a good cohesion is good for your app flexibility and maintainability.
Updating
When interviewing developers about what would be their primary concerns when trying to build a vanilla application, most of them reply that it would be complicated to implement model change detection and subsequent updates in the relevant “views” of the app. This is a typical law of the instrument effect, which makes you think in a framework way, whereas not being a framework actually implies much more simple needs:
- The “views” are just DOM elements. You can abstract them of course (and you should) but in the end they are just that.
- Updating them is just a matter of
viewElement.replaceChild(newContent)
. That’s it. No unnecessary update of a larger DOM scope, no unwanted redraw or scrolling. There are several ways to update the DOM, from inserting text to manipulating real DOM objects. Just pick the one that fits your need. - “Detecting” when updating is required is usually not necessary in a vanilla app, since most often you just know what is to be updated following an event as you can just do it imperatively. You grab your DOM target and update it, period. In some cases of course you might want to do a more generic update by reversing the dependency and notifying observers (see below) that will update themselves.
Templates
Another feature that developers fear to miss is the ability to write HTML snippets with dynamics parts, even listeners, etc.
First of all the DOM API (document.createElement("button")
, etc.) is not that hard, and actually more powerful than any template language since this allows you full access to the API. It can be tedious to build long HTML fragments but, hey, if they are that long, it’s probably that you need to split it in more fine grained components.
It is true, however, that viewing those elements as a template improves readability. So how to have them? There are actually multiple ways:
- HTML Templates are now available in browsers (since 2017 at worse). They provide the ability to build a reusable, off-screen, HTML
<template>
snippet. This is this actually a part of Web Components and yes, they can support transclusion through<slot>
. - Template literals are available in JavaScript since ES6 (2015). They allow you to easily embed values inside a string. That be enough to embed primitives (numbers, strings, including other HTML code, etc.) but not more sophisticated elements like DOM elements on which you registered listeners for instance.
- A tagged template literal function can help embedding complex values like DOM Nodes into such a template that would result in a Node itself. ObservableHQ has devised a pretty handy one that allows you to write things like
html`<header>${stringOrNode}</header>
or to do more complex templating likehtml`<ul>${items.map(item => `<li>${item.title}</li>}</ul>
.
What about conditionals or loops in a template? Aside the fact that this might have never been a good idea (UIs should be dumb and not contain logic), you can (and should) just do it JS, then insert the result in your template, using one of the techniques above.
Events
Ok now we have basic templates, but how to bind events to DOM Nodes inside them? There are also several alternatives:
- HTML event handlers (
<button onclick="myClickHandler(event)“>
) can be inserted in any HTML source, but they not very practicable, since they require the specified handlers to be available on the specified scope. - Event handlers API (
button.addEventListener("click", myClickHandler)
) can be used on any node created through the DOM API or an HTML tagged template literal function.
Now what about custom/business events? What if I need to react to some event triggered by a component of my app. There are also multiple options for that:
- Custom events: You can create you own events classes extending
EventTarget
and dispatch or listen to them, just like any “standard” event. - EventEmitter is theoretically an option (exists in Node and as libs in the browser), but is rarely used.
- Observer pattern: You can build your own but RxJs is the de facto-standard for doing such reactive programming: build a
Subject
then notify all subscribers of a new value so they can react to it.
Components?
Whereas vanilla development is not about writing any complex infrastructure to host components (a.k.a. containers), it is still a good idea design software items as reusable (i.e. context-independent) if they can occur multiple times in your system. Whatever the technology you use, well-grained abstractions remain useful, whether they are business or technical: it’s always a good idea to group data and rules pertaining to the same business concept into a reusable object or to build a widget that can be instantiated in multiple places in your app.
There are a number of ways to create components, depending on what you need. As soon as 2017, Mev-Rael proposed a number of technical tips to handle state, custom properties and views for vanilla JavaScript components. Again, don’t feel constrained to stick to a given recommended technique but think about your need first then pick the ones that fits with it.
Aside standard widget components (which would typically be implemented as standard Web Components), any component should typically be able to:
- split its logic and its view (through a MVC typically). Mixing the two usually makes the code less maintainable, and less flexible (for instance, should you want to display a record in both detailed or tabular form, your RecordComponent would just need to use either a DetailRecordView or a RowRecordView).
- read inputs to parameterize its behavior or its view.
- trigger events to notify parties that something occurred in the component (following a user interaction typically).
- synchronize: your component should be able to redraw if some event occurs. This can be achieved quite easily using reactive libraries such as RxJS.
In any case, whatever the design strategy you choose, you component (or more specifically its associated “view”) must be able to provide some HTML rendering result. A string containing HTML code could be used, but an actual HTMLElement
(or just Element
) is usually a better choice (easier to read/update rather than parsing, allowing to bind event handlers on it) and a more performant one (no parsing required).
Also, you might want to use external components from third-party libraries. For sure, proprietary frameworks, because of their popularity, benefit from a larger number of libraries and components developed by their community. Although most of them are actually not so different from what they would be if they’d had been implemented in pure Javascript (like this was the case at JQuery times) they do suffer from lack of interoperability, and you find yourself looking for either vanilla or Web components.
Hopefully such libraries exist, such as the Vanilla JS Toolkit, even if less common. Regarding standards ones, WebComponents.org list 2000+ elements. There’s even vanilla web components, but the vanilla aspect is less relevant here (more about implementation lightweightness than interoperability).
Routing
Managing routes in a SPA today requires using the web History API. Whereas this is less complex that you imagine, you might want to delegate this to a simple router library such as Navigo.
All you have to do, then, is to replace an DOM element by another (using replaceChildren()
or replaceWith()
) when a route is reached.
Lazy loading
Loading JavaScript code on demand is a legit concern for any web app. You don’t want to load the full code of your app to display a login form.
As soon as 2009, before the advent of web frameworks, James Burke (a Dojo developer) shipped RequireJS (initially “RunJS”) to tackle that problem. Since then, more modern techniques have emerged, with the advent of modules. Since ES6 (2015) those can be loaded dynamically. Yes, at runtime. This works in Node, but in browsers too, since at least 4 years now:
{WelcomeModule} = await import("./welcome/ModuleImpl")
module = new WelcomeModule()
How to insulate the modules in dedicated files? Bundlers like Webpack can do the chunks for you.
Beware that you should only use constants in the import path, though: otherwise, the bundler cannot guess what you will load and will package the whole set of possible files in a single chunk. For instance await import(`./welcome/${moduleName}`)
will bundle everything in the specified directory, given that your bundler doesn’t know what the moduleName variable will hold at runtime.
Native apps
More and more frameworks provide a way to run or migrate/compile their apps for native platforms (such as React native which satisfied developers who wanted to cross the native border more than the ones wanting to get native quality) in order to deploy them as standalone apps on Android or iOS mobile systems.
Aside the fact that you should also consider writing a real native app, note that more general solutions also existed to embed webapps in native containers before the advent of frameworks. This used to be PhoneGap (now discontinued)/Apache Cordova ; this is now NativeScript (which supports frameworks like Angular, but also vanilla apps) or native webapp wrappers like Electron or its lightweight successors like Tauri.
Server-Side Rendering
A number of frameworks have the benefit of being isomorphic: you run a similar code both on front and back end, thus making SEO-friendly server-side rendering (SSR) easy to implement.
This can be a cool and (albeit complex) convenient feature, but you should also keep in mind that its also about extending your framework locking up to the server. So you should think twice before inserting such another level of framework locking in your app: think about the impact on your project, infrastructure, required clients technologies, etc.
Hopefully you can also do this without a framework.
Rendering from the server
Using a vanilla implementation strategy, it seems pretty easy at first: isn’t it about just returning HTML? You already have the components to do that. Yes, but:
- You need a server-side DOM API, as there is no DOM API on the server side by default (JSDOM — maintained, among others, by Domenic Denicola — or the optimized Happy DOM are good choices for that).
- Your rendering components must take care to never assume if the DOM is client side or server side, that is, never assume working on a global DOM because on server side you need a DOM per request. To do so, you’ll want to always pick your DOM objects (
window
,document
and types likeNode
,HTMLElement
,NodeFilter
) from a (client or server) app context, not directly. - share your rendering components in a shared package between client and server apps. There are multiple strategies to do so, like publishing it in a package repository, but the most flexible one is probably to have your apps packages reference its module in a monorepo.
Adding interactivity
However, once your HTML elements are translated to string, you loose all event handlers that you might have set up on those. To restore the missed interactivity of your web page you need some “hydratation” step, that is, inject scripts will execute once on the client side. Frameworks have a hard time doing this well because of their very general-purpose nature. Like for shadow DOM, they constantly try to improve their algorithms to support this in the most clever way while the problem become far more simpler if narrowed to your app’s case.
For sure, doing the same in a vanilla server app also means injecting JS script(s) into your response (either referenced or inlined, depending on how much you want your hydratation to be “progressive”, like including the code required for the Web Components embedded in your HTML response to work on client side).
However the vanilla approach gives you here full control on what do attach, where and when: you can start delivering pure HTML only, then load basic interactivity JavaScript, then load more (depending on user actions or not), etc.
As for any matter discussed in this article, it’s simpler because it’s app code, not generic code.
I18N
Internationalization have been handled by libraries for many years now (and finally integrated within frameworks). You can easily integrate one of those libs but this could also be a good candidate for an in-house implementation, which would allow more simple and efficient messages types than a general-purpose lib can.
It’s just as simple as that:
interface WelcomeMessages {
title: string
greetings(user: string, unreadCount: number): string
}class WelcomeMessage_en implements WelcomeMessage {
title = "Welcome !",
greetings = (user, unreadCount) => `Welcome ${user}, you have ${unreadCount} unread messages.`
}class WelcomeMessage_fr implements WelcomeMessage {
title = "Bienvenue !",
greetings = (user, unreadCount) => `Bienvenue ${user}, vous avez ${unreadCount} nouveaux messages.`
}
Note that this provides you:
- type checking: every message has a static type (and several implementations/translations), so your IDE can check if you’re using a valid message property or not, and provide you auto-completion.
- translation exhaustivity check: you can’t compile until all interface keys are implemented in all languages.
All you have is to (load and) instantiate the message class that is relevant to you user’s locale. General purpose libs can’t provide such business-specific types.
Tools
If you want to free yourself from dependency on a too constraining software stack, it is likely that you’d want to do the same with your tools: you don’t want to depend on them (their limitations, their performance, their bugs, their versions) to be able to move forward. You don’t want tot get stuck with a build problem that you cannot solve (or need hours or days to solve) because you do not own it (especially when using trendy but recent build tools which are not fully battle-tested yet).
That said, you will hardy avoid those tools. Most often your production code will have to be bundled in a clever way, involving minification, obfuscation, code splitting, tree shaking, lazy loading, style inclusion, etc. and there is little doubt that existing packagers such as Webpack, Parcel, ESBuild or Vite will do it better that you could.
All you can do about it is:
- Use less transformations as possible. For instance, using TypeScript might a good thing but implies an additional level of complexity that has to be handled by your tooling chain. Maybe your CSS also, especially with the latest modern versions, is not worth using preprocessors like Sass.
- Use less tools as possible. The more you add, the more one can fail / not support your needs.
- When using one, use the most popular tool so what you need is more likely to be supported by such a battle-tested software (and so you won’t be stuck in a position of “change need or change tool”). Moving to the latest hyped bundler too early might save you a few build seconds that might be compensated by the time devoted understanding the new beta documentation, handling bugs or lack of support.
The biggest challenge
In the end, the biggest difficulty here is not a technical one. It is about people:
- Yourself, to accept to step out of your comfort zone. Hopefully you’ll see that, after all, going vanilla is not so hard and the framework stuff that you used to leverage is more over-complex than magic. Also, you will probably discover new APIs (WebComponents, ES6 Modules, Proxies,
MutationObserver
…), and that the web is more modern and powerful that you thought. - Others to convince them. They will be reluctant to do so, because anybody is reluctant to change for a journey they never attempted.
Regarding that latter challenge, you will be told** that:
- “you’re going to write your own framework”: no, you’re going to write an app instead of a framework.
- “you’re going to write more code”: maybe, maybe not so much (depending on your use of libraries) as this has to be compared with the boilerplate code required by frameworks. In any case, the overall loaded code size will be smaller,
- “you’re going to reinvent the wheel constantly”: of course not: not using a framework is just choosing to not comply with its predefined rules (configuration, lifecycle, refresh mechanism, etc.) but it’s not forgetting DRY principles and, as stated above, you can still (and should) use battle-tested third-party libraries.
- “you will write more code for every feature”: no, you’ll just use your own rules instead of the framework boilerplate.
- “there will be no documentation”: no documentation about the framework for sure (since there’s isn’t any) but, as for any software development, you are still expected to document your app. Notably, the use of patterns will help to auto-document your software design. Your app’s code documentation is the one you care about ; having an additional framework’s documentation is just the consequence of having a framework.
- “there will be no constraints or patterns to guide the developer”: no, nothing prevents you to enforce constraints if you need it (you just have to define contracts). The difference is that they will be the constraints of your choice, tailored for your app.
- “you will miss performance improvements” such as the once-hyped Virtual Dom (which is today challenged, including by subsequent frameworks like Svelte or Aurelia): no, as those “performance improvements” are actually more need by the general-purpose nature of frameworks, not by custom apps. On the opposite, general purpose framework will more likely miss a number of performance improvements that a custom code can implement.
- “You get this problem because you didn’t use a framework” Every difficulty (bug, delay, recruitment, etc) will be blamed on that unorthodox choice. Because most developers’ experience is that everything that has worked was using a framework, not using them will be assumed risky by default. This assumption will be considered as confirmed as soon as a problem will arise, whether it is related to not using a framework or not. They will forget all the similar issues they had when using framework.
- “We can’t find developers”: You’ll be told that it’s difficult to find developers who can develop vanilla code. That’s both true and false. True because a lot of developers (not speaking about managers) will find themselves more comfortable using known recipes such as frameworks. Candidates might feel a bit scared about building a webapp from scratch if they never did it once, or if they don’t know the basic web APIs well. False because, if you want to build quality apps, you should not look for this kind of developers. For sure it is currently easier to find any React developer, but what you need is not a React developer, but a good developer.
- “You won’t get the code quality of frameworks”. For sure frameworks or libraries are usually written by major industry players or experienced developers. However, as we saw above, the code of frameworks is mostly related to framework-specific activities (components lifecycle, general-purpose refresh and optimizations, tooling, etc.), not your app. Furthermore, you can still make bad design choices and write poor code using frameworks. The quality of your application always depends more on the quality of your team, than the lack of blueprints.
- “You won’t get same performance as frameworks”: No, we’ll get better performance. The marketed argument that framework include sophisticated technologies that can “improve performance” such as the “virtual DOM” (which allowed to bufferize DOM changes in order to limit their changes on it) is off-topic, as it relates to solving the performance drawbacks of a general-purpose solution. When the developer updates the DOM by him/herself, (s)he can get the best performance because (s)he will now if it’s worth bufferizing/caching it or if it’s pure overhead.
Conclusion
The vanilla approach to build a web app is not about building your own framework. It is about building an app without a general purpose engine, to:
- avoid loose of control and the implied constraints (locking, upgrade cost, etc.)
- allow optimizations (performance, size, design).
This implies writing only app-specific code (business and technical), including with the use of libraries. The only framework you should really focus on is your framework, the one which is application specific. This is the true “focus on your business” approach, and the most efficient one.
The good news is that it isn’t as hard as you may think, especially when using modern standards (evergreen browsers leveled up when necessary by polyfills).
*The “tax and silo” drawbacks of frameworks are not coined by me. I found them mentioned by Akira Sud, the lead developer of IBM’s Carbon Design System.
This article has been translated by InfoQ China and discussed on HackerNews and Reddit.