Organizing front-end projects to minimize cross-cutting concerns
For the past couple of months, I’ve been working on two side projects. I was going through a little experimentation spree and played with various techniques, tools, and concepts. Finally, in the clear, I’ve decided to summarize my thoughts and conclusions thus far, and also share the way I currently organize my code base.
Those of you who have read my original The basic vanilla JavaScript project setup will probably find this quite a big deviation from the ideas put forward in that series, but there’s also a lot that’s kept. Many of the ideas I wrote about in that article survived the test of time and the latest experiments.
Make of it what you will. This is not a proclamation of a be-all-end-all design that will wow your peers and save you from a myriad of nasty issues that you do or may run into. If my deviation from the original article is any indication, a better idea is always out there, waiting to be discovered. As usual, I’m not here to sell you something. It’s just something I landed on while looking for ways to make sense of my own code.
Background
One thing I’ve learned since I wrote the last article on organizing vanilla code is that the way code is organized can, in some teams, make the difference between complete failure and smooth operation. This is not something I’ve encountered on any team I’ve been on — or lead — in the past, but I’ve seen a number of remarks online that lead me to believe not all teams (are able to) operate the way those teams have.
At the back of my mind, I was continuously thinking about how I would organize the code base to support the notion that teams operate without having the big picture in their minds. Once I had some theories, I was eager to try them out.
One logical organization that eventually made it to the top of my list was grouping code by features. Each feature is — or should be — a well-defined and relatively self-contained subset of the application domain (i.e., subdomain), so it is conceivable that a team or a teammate would be able to completely own the feature and become a subdomain expert without knowing all of the domain.
The Initial Vue.js Experiment
I’m going to talk a bit about the Vue.js experiment because it may be interesting to those of you using it. If you don’t use Vue.js or you just want to get straight to the frameworkless part, skip ahead!
A few weeks back, I was re-learning Vue.js after having been away from it for a few years. With the 3.0 release, it’s still as good as I remember it from the 2.x era. Meaning, in many ways better than any other framework I’ve used or tried (i.e., React, NextJS, Angular, Backbone, Solid) but still has the same issues typical of the component-based MVVM designs.
Let’s say we are working on a book collection app.
We have a feature like “add a book (to a library)”. In the UI, this may be represented by a button and a modal dialog. The button opens the dialog, the user enters the book details, and then submits the details. These details are then inserted into the library and, somewhere else in the app, the listing of books is updated. The button and the dialog facilitate the “add a book” feature, and they are thus closely related.
The listing is a separate feature called “see a list of books”, and it is not required in order for the “add a book” feature to work as expected. So these two features can, in theory, be developed separately.
Now, let’s suppose the UI calls for a design that looks like this:
The list of books contains both the books and an “Add” button. Now we have two features occupying the same area of the UI. One feature is the “see a list of books”, represented by the outer container and the “Book” boxes, and “add a book” feature represented by the “Add” button and an off-screen dialog.
Thus, we have four components in the same area:
- List of books
- Book
- Add book button
- Add book dialog (off-screen)
The “list of books” and “book” are more closely related, and then the “add book button” and “add book dialog” are also closely related to each other. As we said before, there is functionally no relationship between the two groups other than the location within the UI.
If we use the standard component-based organization of the code, we inevitably end up having a direct dependency between the “see a list of books” and “add a book” features. This is not ideal if we want people to be able to work on features independently.
Long story short, I ended up with something along the following lines:
data/
library-store.js
features/
list-books/
book-list.vue
book.vue
viewmodel.js
add-book/
add-book-dialog.vue
add-book-button.vue
viewmodel.js
app.vue
Each feature has its own set of components, and a viewmodel module that houses the UI business logic of the feature. The common data store is in the library-store.js, which contains reactive data for the book collection as well as methods for fetching from, and saving to, some storage backend.
The view model code may look something like this:
// features/list-books/viewmodel.js
import {inject, provide, reactive} from 'vue'
export function init() {
let data = inject('library-store-data')
let store = reactive({
books: [],
error: '',
})
provide('list-books-vm', {
get bookList() {
return store.books
},
loadBooks() {
data.getAll('books')
.then(
books => store.books = books,
error => store.error = error.message,
)
},
// ...
})
}
The init() function is called in the App component and this in turn provides the view model to all of the components under the specified name — list-books-vm. The name is specified by the init() function, because it’s a feature-specific piece of information. (To improve upon this design, I could probably use a Symbol rather than a string to label the provided view model to avoid potential conflicts in larger apps.)
Although this looks neat and well-organized, there’s still a little detail that needs to be solved, which is related to the view.
<script setup>
// features/list-books/book-list.vue
import {inject, onMounted} from 'vue'
import Book from './book.vue'
import AddBookButton from '@/features/add-book/add-book-button.vue'
let VM = inject('list-books-vm')
onMounted(VM.loadBooks)
</script>
<template>
<ul>
<Book v-for="book of VM.bookList" :key="book.id" :book="book" />
<li>
<AddBookButton/>
</li>
</ul>
</template>
We are physically importing the AddBookButton component. To avoid this, we need to globally register the AddBookButton component with the app, so that we do not need to import it. It’s still there in the template, but at least we no longer care where it comes from or who provides it. The downside is that this involves the registration step when the application object is created, so it’s not exactly “free”.
After implementing all this, I started seeing how this would be a lot simpler to do without frameworks. Don’t get me wrong. I rather like this setup and I’m not saying there are some glaring issues with it. I was merely curious about what this would look like without components, dependency injection frameworks, and the whole shebang.
The Frameworkless Version
Before I start describing the frameworkless alternative, let me first point out several things that come into play later.
- I do not render DOM nodes in the JavaScript code. I use <template> elements instead. This is intentional.
- Elements that are guaranteed to be needed later (i.e., anything that isn’t dynamically generated based on data) I always include in the HTML. This also means that they are guaranteed to be on the page and in the DOM when the script executes.
- I use ES modules for my scripts, so they are loaded using <script type=”module”> and are deferred by default (meaning they load in parallel with the parsing of the markup, but are executed after the markup is parsed). This also means that an extension is required in the imports and there is no support for directory imports.
In the frameworkless version, I am using a similar pattern to what I used in the Vue.js version:
data/
library-data.js
features/
list-books.js
add-book.js
lib/
...
index.app.js
index.html
One notable difference is that there are no components here, so I don’t need to group files into folders. I include the features as individual modules in the same folder. The view itself is in the HTML file. Therefore, the features folder only contains what would technically be the viewmodel.js in the Vue.js example.
One thing to note is that I chose to name the feature modules after what the user is doing rather than the associated UI. This is why the list-books.js is not called book-list.js. I find that this nomenclature reminds me to stay focused on the use cases rather than the appearance of the UI.
Edit: After a few more trials, I conclude that any organization that does not separate behavior from UI elements ultimately only goes so far before you fail to achieve clear feature-based separation. This technique therefore only works well with frameworkless code.
Shared Context
The application has some shared state that I call “context”, and it is initialized the first thing when the app starts:
// index.app.js
let context = {}
The reason I call it “context” is because it gives the rest of the code some context about what the app is currently doing. This is essentially the same thing as inject() and provide() in Vue.js or createContext() in React, but implemented as a simple object that gets shared between the components as described in the next section.
The context may contain information that is known at initialization (e.g., URL search parameters, flags from localStorage, etc.), and will usually contain information added to it by data and feature modules. For instance, I had something like this in one of the projects:
// index.app.js
let context = {
projectId: new URLSearchParams(location.search).get('id'),
}
Initialization
The data and feature modules expose an init() function that is used for initialization. Although these modules are in separate folders, they all work the same way and the init() takes the shared context as its input, and is then free to do whatever it wants.
Typically the init() function looks like this:
let context
export function init(appContext) {
context = appContext
}
The init() function is called by the application module.
// index.app.js
import * as libraryData from './data/library-data.js'
import * as listBooks from './features/list-books.js'
import * as addBook from './features/add-book.js'
let context = {}
libraryData.init(context)
listBooks.init(context)
addBook.init(context)
The reason the data and feature modules are organized into separate folders is that data modules are basically “headless” features, that operate on remote services, in-browser storages, and/or in-memory application-wide state. In contrast, features operate on the DOM and use the data layer to obtain data that has been appropriately processed for the views.
The data modules may publish their data through the context like so:
// data/library-data.js
let context
export function init(appContext) {
context = appContext
context.library = {
books: [],
error: '',
}
}
Communication Between Layers
The data and feature layers are implemented separately so we need some way to send requests between them. We could technically also use the context for that, but I decided to use an event bus instead. The concrete implementation of the bus doesn’t really matter for this article. I’ll just mention it uses the built-in EventTarget object.
Although event streams (e.g., RxJS) can be implemented on top of or used instead of this, I’m specifically talking about a simple event bus like your normal DOM node. Event streams are unnecessarily complex for this use case.
The main advantage of using an event bus is that features can be developed without the matching event listeners on the other end. They will fire an event and then nothing would happen if the event listener is not set up. To verify that the feature works, we can simply add a temporary listener on the bus and capture the incoming events, for instance, or manually feed events into the bus to trigger a response in the feature module.
The second advantage is that events can be fired at any place in my code, and it does not require me to think and plan ahead for differences between synchronous and asynchronous code. This has probably simplified things on more occasions than I can think of.
The third advantage is that event buses are broadcast channels. For any event, we could have multiple parts of the application subscribed to and processing it.
The disadvantage of using an event bus is that the a misfire could happen if we misspell an event name, for instance. To me personally this is not a deal-breaker, but I can imagine wanting to use a central event name registry to avoid using invalid names in a larger application with lots of event types with a runtime check for invalid names on dispatch and subscription.
The bus gets added to the context when the app starts so that it is available across data and feature modules:
// index.app.js
import {createBus} from './lib/event-bus.js'
let context {
eventBus: createBus(),
}
Features send requests to modify the context data by dispatching events on the shared event bus. They never modify the data directly. Initially I was protecting the data from accidental modification using getters, and frozen objects, but eventually I figured out that “accidental modification” isn’t a common issue (happened only zero times), so I reverted to publishing plain objects to simplify the code. It’s the same reason I only ever use let, if you were wondering about the absence of const in the examples.
Interaction Between Data and Features
Here’s an example of what it might look like to fetch a list of books when the app is loaded:
// data/library-data.js
let context
export function init(appContext) {
context = appContext
context.library = {
books: [],
error: '',
}
loadBooks()
.then(
books => context.library.books = books,
error => context.library.error = error.message,
)
.then(() => context.eventBus.dispatch('booksLoaded'))
}
// features/list-books.js
let context
export function init(appContext) {
context = appContext
context.eventBus.on('booksLoaded', () => {
if (context.library.error) showLoadError()
else updateBookList()
})
}
The feature module waits for the booksLoaded event, and then updates the list. The data module goes right ahead and starts fetching the books as soon as it is initialized. As this is an asynchronous operation, it doesn’t prevent the other modules from initializing and attaching event listeners. This, in turn, means that we are able to initialize modules in any other.
In this implementation, the context information is updated prior to dispatching an event, so the data is guaranteed to be populated when the listener(s) finally receive it.
Interaction Between Features and the DOM
Features interact with the DOM the same way any code would interact with the DOM, so there’s nothing special about it.
The way I go about it is I normally create references to the nodes that are already in the DOM before the init() gets called. I then have functions that manipulate those nodes later in the code. If events need to be handled on those nodes, I will add listeners within the init().
// features/add-book.js
let context
let $trigger = document.getElementById('add-book-trigger')
let $dialog = document.getElementById('add-book-dialog')
let $form = $dialog.querySelector('form')
export function init(appContext) {
context = appContext
context.eventBus.on('booksLoaded', enableAddBook)
$trigger.onclick = startAddingBook
$form.onsubmit = ev => {
ev.preventDefault()
addBook(new FormData($form))
}
}
function enableAddBook({error}) {
$trigger.disabled = !!error
}
function startAddingBook() {
$form.reset()
$dialog.showModal()
}
function addBook(data) {
let book = {
title: data.get('title'),
author: data.get('author'),
year: Number(data.get('year')),
isbn: data.get('isbn'),
}
context.eventBus.dispatch('addBook', book)
}
Note the inversion in the naming between the feature module and the id attributes used in the HTML: add-book-trigger instead of trigger-add-book. The UI elements are named after what they are, while the feature modules are named after what the user is doing. This may require getting used to if you’re coming from the typical component-based framework where things are usually named after what they are despite also including the behavior.
Please also make a note about the assumptions this module makes about the underlying UI:
- There is an element on the page with an id of add-book-trigger
- There is an element on the page with an id of add-book-dialog
- The #add-book-dialog element must have a showModal() method
- The #add-book-dialog must contain at least one form element
- The form element must have a reset() method
It specifically does not say what kind of element any of these must be except for the form, nor where these elements must be located on the page or relative to each other except that the form element should be within the #add-book-dialog element.
Sharing Features Across Pages
In some cases, we may share a feature across two or more pages in a multi-page application. Ideally, we would want to do this without modification.
As I’ve noted before, the feature modules do not prescribe much about the UI: they don’t say a whole lot about what the markup should be, nor do they say anything about the appearance. They make assumptions about the UI, along the lines of what you could see in the previous section, but far from defining every minute detail. When I do it right it keeps things flexible and portable.
Making a small digression here, I’ll share a little trick that I’ve taken to using a lot more often. Normally when I want to do something with an element, I used to do it like so:
$someElement.querySelector('.selector').textContent = 'New text'
However, I now sometimes do the above like this:
$someElement.querySelectorAll('.selector')
.forEach($el => $el.textContent = 'New text')
The reason for this is that the latter will never fail if the element is not present. In other words, this code makes less assumptions about the elements. The result of calling querySelectorAll() is never null. It’s a list of nodes that contains zero or more elements, so the callback is only executed if there are any matching elements in the list.
The fact that the code is a bit verbose is not the biggest issue. You can always abstract it away. In real life, the code I use looks like this:
ui.populateNodes($someElement, {
'.selector': ui.text('new text'),
})
Where this becomes useful is when we are applying the same feature to different pages or templates, where some elements may be missing. We can safely attempt to update some element and if that element is not present on the page / in the template, the operation fails silently, and that’s all good for us. This allows us to share the features verbatim across pages and have the flexibility to provide different templates and CSS for the same feature. (Incidentally, this is how jQuery works and it is one of its much-appreciated features.)
To use our book list as an example, we could have something like this in our feature module:
ui.populateNodes($book, {
'.cover': ui.style('--cover-image', bookData.cover),
'.title': ui.text(bookData.title),
'.author': ui.text(bookData.author),
'.isbn': ui.text('ISBN ' + bookData.isbn),
a: ui.href(`book-details.html?id=${encodeURIComopnent(bookData.id)}`),
})
Now the template for this feature may look like this in the main book listing page:
<template id="book-list-item">
<article class="book-list-item">
<a>
<header>
<h3 class="title"></h3>
<span class="author"></span>
</header>
<div class="cover"></div>
<span class="isbn"></span>
</a>
</article>
</template>
On the book details page, we might have a drop-down list of books that lets the user quickly jump to another book. The template for the items in that list may look like:
<template id="book-list-item">
<li class="book-list-item">
<a>
<span class="title"></span>
(<span class="author"></span>)
</a>
</li>
</template>
In the first template, we are populating all the information about the book. In the other template, we are missing the .cover and .author elements so those are ignored. We do not have to modify the feature module to achieve this.
Feature Groups
In larger apps, a flat list of feature modules may become a bit harder to scan and make sense of. This is just the curse of splitting things into files. Fortunately, this is easily rectified.
Because the only assumption about init() is that it should take the application context, we can simply group features under a common init() function.
Suppose we have the following folder structure:
features/
list-books/
list.js
search.js
index.js
The feature group list-books has an index module which will bundle all the related features together:
// features/list-books/index.js
import * as list from './list.js'
import * as search from './search.js'
export function init(appContext) {
list.init(appContext)
search.init(appContext)
}
And this group is then initialized in the application module:
// index.app.js
import * as bookList from './features/list-books/index.js'
let context = {}
bookList.init(context)
The neat thing about feature groups is that we can create them at any time. We are not required to decide beforehand whether we would like to organize features as a group or not. Now I could technically keep nesting these indefinitely as long as I can be bothered to pass the context along, but I don’t think it’s realistic to need more than one or two levels.
Feature Templates
Sometimes, two or more features on the same page share most of the logic, only differing in some markup or events that they dispatch and/or listen to. I address this case using feature templates.
Feature templates (or feature factories) are functions that take some options and return the init() function.
In the following example, we have an author filter and a publisher filter. They are both lists of names with a button to enable filtering of the book list according to their respective criteria. They use different templates, however, and they listen to different events.
features/
list-books/
_metadata-filter.js
filter-by-author.js
filter-by-publisher.js
The template module is prefixed with a leading underscore so that we can easily differentiate it from normal feature modules.
The template function may look like this:
// features/list-books/_metadata-filter.js
export function createFeature({selectors, events}) {
let context
let $template = document.getElementById(selectors.template)
.content.cloneNode(true).firstElementChild
return function init(appContext) {
context = appContext
context.eventBus.on(events.metadataUpdated, updateList)
}
function updateList() { ... }
}
If you look carefully, you’ll see that the code inside the createFeature() function is the same code you’d find in any feature module. The only difference is that instead of export, we return the init() function, and that the code has access to the arguments passed to createFature(). The same intuition I have about the normal feature modules still applies. This makes extracting feature modules into feature templates quite easy.
We use it in our feature modules like so:
// features/list-books/filter-by-author.js
import {createFeature} from './_metadata-list.js'
export let init = createFeature({
selectors: {
list: 'author-list',
template: 'author-list-item',
},
events: {
metadataUpdated: 'authorsUpdated',
},
})
// features/list-books/filter-by-publisher.js
import {createFeature} from './_metadata-list.js'
export let init = createFeature({
selectors: {
list: 'publisher-list',
template: 'publisher-list-item',
},
events: {
metadataUpdated: 'publishersUpdated',
},
})
Conclusion
UI is not always a straightforward tree of components. Sometimes components belonging to different features intermingle with each other in the same area of the UI. Or they may appear in completely unrelated sections.
The feature-based organization of the code is a result of the desire to eliminate (or at least minimize) the cross-cutting concerns, while keeping the related code closer together, and allowing developers to focus on smaller parts of the overall domain. By using a use-case-based naming convention, I am able to keep focus on the purpose of the features and not fall into the trap of splitting the code along the UI boundaries.
The result is a relatively simple and flexible design that (so far) scales quite well with the increase in the number of features. It works especially well when combined with the natural separation in traditional web application design that keeps HTML, CSS, and JavaScript separate.
Feature-based code organization in frameworkless projects was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.