In this post we'll be exploring why AlpineJs is an ideal JavaScript library for integrating server-side HTML rendering and client-side JavaScript interactivity.
๐งญ Starting Our Journey: Choosing Our Tools
For many ASP.NET developers, JavaScript on the web is synonymous with jQuery, and while jQuery certainly is ubiquitous, it's not the only option out there.
The primary benefits of jQuery in 2021 are its ease of use (just add a <script>
element to the page!) and its vast plugin ecosystem. However, there are also some drawbacks that come with this library ๐ฎ.
๐ jQuery Spaghetti
jQuery is largely concerned with providing a consistent (and flexible) API for manipulating the DOM and using browser features through JavaScript. It takes an imperative and procedural approach to these concerns, because jQuery is a low-level library.
The benefits of a consistent DOM API across browsers doesn't really apply to us anymore in the era of modern, evergreen browsers ๐๐ป.
Likewise, browsers have adopted the jQuery DOM selector syntax (Sizzle) in the document.querySelector()
and document.querySelectorAll()
functions ๐.
With these no-longer-benefits out of the way, what do we typically end up with when using jQuery in 2021?
Unfortunately, sometimes it's not pretty ๐.
The pattern of building something with jQuery typically involves these steps:
- Find some DOM elements (often by
id
orclass
) - Register some event handlers with those DOM elements so we can react to user interactions on the page
- Write logic in JavaScript that is specific to our application
- Repeat
Steps 1 and 2 are the ones that become more problematic as the complexity of our JavaScript grows.
Since jQuery is a low-level library, we are responsible for all the plumbing ๐ฉ๐ฝโ๐ง work. Plumbing is all the code we have to write to 'hook things up', and this plumbing gets spread throughout our business logic.
Not only does this make the important part of our code (the business logic) more confusing, but it's also something we need to maintain over the life of the application.
The term 'jQuery spaghetti' is meant to describe the kind of code we end up being forced to write when trying to build complex UIs with jQuery because the business logic code and plumbing code are all mixed together, and often tightly coupled.
Here's an example of jQuery spaghetti (maybe not a full plate ๐):
<form id="myForm">
<input id="email" type="email" name="email" />
<span class="error" style="display: none"></span>
<button type="submit">Submit</button>
</form>
$(function () {
// Find our form
const formEl = $('#myForm');
if (!formEl) {
console.error('Could not find form');
return;
}
// Register an event listener
$('#myForm').on('click', function (e) {
e.preventDefault();
// Find our form field
const emailEl = $('form [name="email"]');
if (!emailEl) {
console.error('Could not email form field');
return;
}
// Get the email value
const email = emailEl.val();
// find the error element
const errorEl = $('form .error');
if (!errorEl) {
console.error('Could not find error message element');
return;
}
if (!email) {
// set the error message
errorEl.text('An email address is required');
errorEl.show();
} else {
errorEl.hide();
}
});
});
The code above is almost entirely focused on plumbing ๐ฆ, with only a few lines (checking for an email address and showing the error message) of business logic.
If we change the location of our error element and move it out of the form, our code stops working. If we change the class (error
) on our error element, our code stops working.
Yes, there are best practices to help avoid these problems, but the fact remains that building something in jQuery requires engineering vigilance, careful decision making, a bit of work to just 'hook' things up ๐.
It does not lead to developers walking near the pit of success.
So what are our alternatives ๐ค?
๐ป A Modern Component Framework
Modern JavaScript frameworks like Svelte React, Vue, and Angular were designed to help us solve the jQuery spaghetti problem.
These frameworks take care of all the plumbing and provide developers with APIs and patterns to ensure their business logic is not littered with finding DOM elements, hooking up event listeners, and explicitly updating the UI.
By taking on the responsibility of plumbing, these frameworks allow developers to grow their JavaScript code in both size and complexity in maintainable ways that result in readable code ๐.
The same functionality that we wrote in jQuery would look like this in Vuejs (including the HTML template for rendering):
<template>
<form @submit.prevent="onSubmit">
<input id="email" v-model="email" type="email" name="email" />
<span v-show="error">{{ error }}</span>
<button type="submit">Submit</button>
</form>
</template>
<script>
export default {
data() {
return { email: '', error: '', };
},
methods: {
onSubmit(e) {
this.error = !this.email
? 'An email address is required'
: '';
}
},
},
};
</script>
Notice how there's no plumbing here ๐ง! The connection between the HTML and our JavaScript is declarative. We indicate we want the onSubmit
function to handle submission of the form by adding the @submit="onSubmit"
attribute (directive) to the <form>
element.
We also get access to the email input value and the error message by using the v-model="email"
binding syntax and {{ error }}
interpolation syntax, respectively.
This leaves us with some HTML enhanced by special syntax, which fortunately doesn't rely on HTML id
or class
attributes, and some business logic (the contents of our onSubmit
function).
We are free to re-structure our HTML, change styles, and modify our business logic - all independently ๐๐ฝ.
I'm a huge fan of browser based client-side HTML rendering frameworks like these, but they unfortunately can pose another problem ๐ค!
Before it's starts to sound like I'm advising against using any of the above frameworks, I want to clarify that I regularly use Vue in my Kentico Xperience applications and these sites have benefited significantly from our team's adoption of it ๐ช๐ฟ.
If you want to learn more, read my post Kentico 12: Design Patterns Part 16 - Integrating Vue.js with MVC
These frameworks enable the functionality of jQuery without having to write the plumbing code, but unfortunately at the cost of losing control over the rendering of the DOM.
While jQuery can be used to create new DOM elements, it is most often used to change the state of elements already in the page.
Modern JavaScript frameworks like Vue, on the other hand, need to render all their DOM from scratch when they are loaded on the page ๐คจ.
If we were to look at the HTML send from the server for a traditional Single Page Application (SPA), we would see something like this:
<!DOCTYPE html>
<html>
<head>
<!-- references to our JavaScript app and libraries -->
</head>
<body>
<div id="app"></div>
</body>
</html>
All the UI of the page is created by the framework as children of the <div id="app"></div>
element, and this is what is meant by the phrase 'client-side rendering'.
This means that search engine crawlers would need to execute the JavaScript to see the final HTML and content of the page, and even if the search engine is able to run our JavaScript, it might penalize us for taking too long to render everything ๐คฆโโ๏ธ.
This is in stark contrast to server-rendered HTML where the data sent from the server to the browser is going to include everything displayed to the user, so there are no requirements to execute JavaScript or delays to see what it renders on the page.
We ideally would like a library that exists somewhere in between the plumbing free coding of modern JavaScript frameworks, and jQuery's ability to manipulate and create DOM without owning it... ๐
๐ AlpineJs Enters the Chat
AlpineJs fits our requirements exceptionally, and is described as offering us
the reactive and declarative nature of big frameworks like Vue or React at a much lower cost.
and
You get to keep your DOM, and sprinkle in behavior as you see fit.
Well, this sounds great ๐๐พ. So, how do we use it?
๐บ Our Destination: Using AlpineJs
Let's look at our HTML form example again, but this time with AlpineJs!
First we need to add a <script>
element within the document's <head>
:
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js" defer></script>
Then, we define a createFormComponent()
function, which is where our component state and methods are initialized:
<script>
(function () {
'use strict';
window.createFormComponent = function () {
return {
email: '',
error: '',
onSubmit($event) {
this.error = !this.email
? 'You must enter an email address'
: '';
},
};
};
})();
</script>
Finally, we annotate our server-rendered HTML with some Alpine specific syntax:
<form id="myForm"
x-data="createFormComponent()"
@submit.prevent="onSubmit">
<input id="email" type="text" name="email"
x-model="email" />
<span class="error" style="display: none"
x-show="error"
x-text="error"
></span>
<button type="submit">Submit</button>
</form>
Let's look at each part in detail!
The AlpineJs script works like most JavaScript libraries that we load into the browser without a build process - as soon as the script executes, it looks for "Alpine" stuff and initializes everything it can find on the page.
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.8.0/dist/alpine.min.js" defer></script>
This means that we can have a page full of existing HTML, rendered on the server and Alpine can hook into it and enable all of its powerful features ๐!
Alpine will look for initialization code (defined by x-data
directives on our HTML), which can be an inline expression or a function defined the window
object.
Speaking of initialization functions, let's look at ours next:
<script>
(function () {
'use strict';
window.createFormComponent = function () {
return {
email: '',
error: '',
onSubmit($event) {
this.error = !this.email
? 'You must enter an email address'
: '';
},
};
};
})();
</script>
This block defines an IIFE ๐ค (an immediately invoked function expression), which assigns a function (createFormComponent
) to the window
object so that it's accessible to Alpine (functions and variables defined in an IIFE are not accessible outside of it).
The function we defined, createFormComponent
, returns an object that includes the 'state' (email
, and error
) of our component. These are the values that Alpine ensures stay updated based on user interactions, and also ensures result in an update of the HTML when they change. This is the kind of plumbing we want to avoid, and thankfully Alpine takes care of it for us ๐คฉ.
Our initialization function also defines a method, onSubmit
, that can be called when the user interacts with the component in a specific way.
Note how it sets the value of this.error
, which is the error: ''
value in our component state.
It also has access to this.email
which is the email: ''
value in our component state.
Now we can look at our enhanced HTML form:
<form id="myForm"
x-data="createFormComponent()"
@submit.prevent="onSubmit">
<input id="email" type="text" name="email"
x-model="email" />
<span class="error" style="display: none"
x-show="error"
x-text="error"
></span>
<button type="submit">Submit</button>
</form>
Alpine connects data and interactivity to our HTML with directives, so let's go through each directive we are using, step-by-step.
<form id="myForm"
x-data="createFormComponent()"
@submit.prevent="onSubmit">
<!-- ... -->
</form>
The x-data="createFormComponent()"
tells Alpine to initialize this <form>
element and all of its children elements into a component, and set the state and methods they can access to whatever was returned by createFormComponent()
(in our case this is email
, error
, and onSubmit
).
The @submit.prevent="onSubmit"
connects our component's onSubmit()
function to the submit
event of the form (and also ensures $event.preventDefault()
is called automatically with the .prevent
event modifier ๐!)
<input id="email" type="text" name="email"
x-model="email" />
We make sure the value of the <input>
always stays up to date with our component's email: ''
value by using the x-model="email"
binding syntax. If our JavaScript changes email
, the <input>
element will immediately reflect that change - if the user types a new value into to <input>
our JavaScript will have access to that new value.
<span
class="error"
style="display: none"
x-show="error"
x-text="error"
></span>
We do something similar with the <span class="error">
by conditionally showing it with x-show="error"
which will show the element when our component's error: ''
value is truthy and hide it when it is falsy.
The x-text
directive sets the innerText
of our <span>
to whatever the value of error
is.
Notice how none of our HTML is connected to our JavaScript through HTML id
or class
attribute values, which means it's not brittle to updating design or styles ๐ง .
We also don't imperatively connect interactions with our HTML, or the values of our HTML. Instead, Alpine does all the plumbing ๐ฟ for us and we get to use our ๐ฉ๐ฝโ๐ป developer skills to focus on business logic.
Here's a live demo of our AlpineJs solution:
Integration With Xperience
If we wanted to populate the error message for our form from Xperience, we could use Razor to set the value, since everything on the page is rendered on the Server:
const errorMessage = '@Model.FormErrorMessage';
window.createFormComponent = function () {
return {
email: '',
error: '',
onSubmit($event) {
this.error = !this.email
? errorMessage
: '';
},
};
};
We can also make requests to our ASP.NET Core API, use the response to set our error message, and our form will be asynchronously validated:
window.createFormComponent = function () {
return {
email: '',
error: '',
async onSubmit($event) {
const result = await fetch(
'/api/form',
{
method: 'POST',
body: JSON.stringify({ email: this.email })
})
.then(resp => resp.json());
this.error = result.errorMessage;
},
};
};
Or, imagine a scenario where we have a <table>
of data and we want to filter the results based on what a user types into an <input>
:
<div x-data="initializeFilterComponent()">
<label for="filter">Filter:</label>
<input id="filter" type="text" x-model="filter">
<table>
@foreach (var row in Model.Rows)
{
<tr x-show="isRowVisible('@row.Title')">
<td>@row.Title</td>
</tr>
}
</table>
</div>
<script>
(function () {
'use strict';
window.initializeFilterComponent = function () {
return {
filter: '',
isRowVisible(title) {
return title
.toLowerCase()
.includes(this.filter.toLowerCase());
}
}
};
}());
</script>
In the example above, all of the table rows are initially displayed (great for SEO!) and are only filtered when the user starts typing in the <input>
๐!
โ Heading Home: Which Option Is the Best Choice?
Now that we've seen several different options for enhancing our HTML with JavaScript, which one makes the most sense for our use-case?
jQuery
- If we only need a few lines of code
- Existing jQuery plugins handle most of the complexity
- No state management in our 'components'
Vue/React/Svelte/Angular
- Complex state management
- Many components interacting together
- Client-side rendering is ok (SEO is not important)
AlpineJs
- More than a few lines of code, business logic
- SEO is important
- Custom UX (not from a jQuery plugin)
At WiredViews, we've been using AlpineJs in our Kentico Xperience projects, alongside Vuejs, and jQuery.
I recommend using the right tool ๐ฉ๐ผโ๐ง for the right job, and fortunately AlpineJs fits in great with modern web development and Kentico Xperience.
As always, thanks for reading ๐!
References
We've put together a list over on Kentico's GitHub account of developer resources. Go check it out!
If you are looking for additional Kentico content, checkout the Kentico or Xperience tags here on DEV.
Or my Kentico Xperience blog series, like:
Top comments (4)
Rather than use inline styles like this:
โฆan alternative is to use x-cloak, like this:
x-cloak
will be removed once Alpine has run, so we can team it up with a CSS rule like this one:โฆto hide content until Alpine has had a chance to execute and for
x-show
to have done its thing.Oooh, that's an awesome tip! Thanks @philw_ !
Although I haven't had a chance to use it yet, I'm very excited about Alpine v3 now that Spruce has been replaced with a built-in store.
Read the entire article, but I really don't see what this brings to the table that Vue doesn't already do. The way you did this Alpine example is exactly how I do things in Vue and Xperience right now. And now I'm not having to learn yet another frontend framework.
Hey Steve!
The main benefit of Alpine is being able to rely on server-side rendering for the page's content. This reduces Cumulative Layout Shift, improves crawlability for search engines (and potentially First Contentful Paint depending on your architecture), and improves the Time to Interactive because the DOM is already there.
If these aren't goals that your projects need to focus on, then Vue is probably good enough.
In regards to learning 'yet another framework', Alpine's API surface is extremely small, and it actually uses Vue's reactivity system internally! It's HTML directives also share a lot of the same conventions as Vue's, and the store that Alpine exposes for sharing state across components can be used just like Vuex (and with less ceremony).
Our team takes the following approach:
The reason I chose a simple example that could be done with Vue isn't to make the point that Vue can do everything Alpine can, it's instead to show that adopting Alpine isn't that complex, especially if a dev is already familiar with Vue (or the other popular frameworks).
Also, I wrote this when Alpine 2.x was the latest version. Alpine 3.x is out now and didn't make any breaking changes (that we've experienced) with the previous version, but it did improve a lot of the boilerplate for creating components and shared state. In my opinion, it's even better now.