Design: #noFramework

Is it as hard as you think?

Do you need the framework layer?

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 now 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.

Holovaty’s talk at dotJS 2017
  • 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.
Choose between migrating to the new API or be stuck to an old, unsupported version of the whole framework.
  • 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.
Once you put your money inside a framework, it’s hard to get it out.

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.

  • 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.
Frameworks come and go. Their fate is a decline in interest, replaced by 1 to 3 new ones per year, since 2018.
Rare image of developers trying to escape a framework silo.

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.

  • 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.

The alternative

So, what is it to build apps without framework tax and silo?

Developing an app without a framework doesn’t mean to re-implement the framework.
Developing an app without a framework doesn’t mean to re-implement the framework.
  • 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)
  • 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.)

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).
  • 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.

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.

  • 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.

Patterns

As Holovaty says, the option to just applying patterns to structure your software (instead of using frameworks) is not considered enough.

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:

  1. The “views” are just DOM elements. You can abstract them of course (and you should) but in the end they are just that.
  2. 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.
  3. “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.

  • 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 like html`<ul>${items.map(item => `<li>${item.title}</li>}</ul> .

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.
  • 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.

  • 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.

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.

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.

{WelcomeModule} = await import("./welcome/ModuleImpl")
module = new WelcomeModule()

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.

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.

  • 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 like Node, 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.

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.

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.`
}
  • 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.

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).

  • 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.
  • “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.
With no surprise, the most performant frameworks are the ones that adds less layers above vanilla code. Frameworks “optimizations” are more about compensating frameworks 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).

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store