Building dynamic JavaScript experiences has come a long in the 20 years since I first started software development, but updating the document object model (DOM) can still be a real pain in the butt. That’s why we’ve seen single-page application frameworks explode in use. While JavaScript provides a very capable API when interacting with the DOM, it can be verbose.
In this post, we’ll see how to use the Alpine.Js library’s declarative attribute approach to create a real-time updating UI with minimal JavaScript and no direct use of the DOM APIs.
The Strength of Alpine.js
Alpine.js is a lightweight JavaScript framework that allows you to compose behavior directly in your HTML markup. The library consists of 15 attributes, six properties, and two methods. While its surface API is small, what it offers is, as the site says, “powerful as hell.”
The strength of Alpine.js comes from its data model, which uses reactivity to detect changes to data and update the UI elements accordingly. Reactivity means you can update values naturally without keeping track of a dependency graph. Let’s take a look at a quick example.
<div x-data="{ count: 0 }">
<button x-on:click="count++">Increment</button>
<span x-text="count"></span>
</div>
In this example, the count
value is reactive. When you click the button, the value increments. Since the
count
value is also used in the span
element, it is updated at the time of the change. So easy!
Values can also be accessed from JavaScript. With a few modifications, we can use the
data
method to create a shared context for our counter.
<div x-data="count">
<button x-on:click="increment()">Increment</button>
<span x-text="value"></span>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('count', () => ({
increment() {
this.value++;
},
value: 0
}));
});
</script>
Note that we’ve encapsulated the counter logic in increment
and changed the
value
field on each click. There’s no need to do any special syntax to access the
value
field. We can use it as we would a plain-old JavaScript object.
Now that we understand more about reactivity related to Alpine.js, let’s write a more complex sample that polls our ASP.NET Core application for updated data.
ASP.NET Core and Alpine.Js
Let’s start by creating an API endpoint that will return weather data. First, let’s look at our data model.
public class Weather
{
public string Location { get; set; } = "";
public string Description { get; set; } = "";
public string Temperature { get; set; } = "";
public static ReadOnlySpan<string> Descriptions =>
new(["Sunny", "Cloudy", "Rainy", "Snowy"]);
public static ReadOnlySpan<string> Locations =>
new(["Mountain", "Valley", "Desert", "Forest"]);
}
Next, let’s add a new API endpoint to our application in Program
.
using MountainWeather.Models;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/weather", () =>
{
return Results.Json(
Enumerable
.Range(1, 10)
.Select(i => new Weather
{
Location = $"{Random.Shared.GetItems(Weather.Locations, 1)[0]} #{i}",
Description = $"{Random.Shared.GetItems(Weather.Descriptions, 1)[0]}",
Temperature = $"{Random.Shared.Next(32, 100)}℉"
})
.ToList()
);
});
app.UseDefaultFiles();
app.UseStaticFiles();
app.Run();
Next, let’s write our HTML and JavaScript. This is an index.html
file placed in the wwwroot
folder.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Weather</title>
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
</head>
<body>
<main class="container-fluid">
<table class="table table-striped" x-data="weather">
<thead>
<tr>
<th>Location</th>
<th>Description</th>
<th>Temperature</th>
</tr>
</thead>
<tbody>
<tr x-show="locations.length === 0">
<td colspan="3">
0 Locations Found.
</td>
</tr>
<template x-for="l in locations">
<tr>
<td x-text="l.location"></td>
<td x-text="l.description"></td>
<td x-text="l.temperature"></td>
</tr>
</template>
</tbody>
<tfoot>
<tr>
<td colspan="3" x-text="updated"></td>
</tr>
</tfoot>
</table>
</main>
<script>
async function getWeather() {
let result = {};
const response = await fetch('/weather');
result.locations = await response.json();
result.updated = new Date();
return result;
}
document.addEventListener('alpine:init', () => {
Alpine.data('weather', () => ({
async init() {
this.timer = setInterval(async () => {
const result = await getWeather();
this.locations = result.locations;
this.updated = result.updated;
}, 3000);
const result = await getWeather();
this.locations = result.locations;
this.updated = result.updated;
},
destroy: () => {
clearInterval(this.timer);
},
locations: [],
updated: "n/a",
timer: null
}));
});
</script>
</body>
</html>
Let me explain what’s happening in this HTML, starting with our weather
data model. We use the
Alpine.data
method to create a data context that our HTML can use with the
x-data
attribute. This data model has fields for locations
, updated
, and
timer
. The timer fetches data from our API every three seconds and changes the values. When the values change, the UI changes as well.
Let’s look at HTML binding because Alpine.js allows developers to employ a neat trick: the
template
HTML tag. By default, the
template
tag takes the user-defined contents and appends the hydrated version into the DOM as the following elements. In this sample, we get rows matching the values from the ASP.NET Core API. Now, running the sample, we get a table that updates its values every three seconds.
Note that JavaScript developers using intervals should call clearInterval if the DOM element can be removed before the user navigates away from the page. This ensures there will be no memory leaks. You can do this using the
destroy
method in your data definition.
Conclusion
There you go—a simple, updating UI with minimal JavaScript and an easy-to-understand and maintainable codebase. I like Alpine.js and what it offers. In most cases, a little bit of a user interface can go a long way. I hope you enjoyed this post, and thank you for reading. Cheers.