A quick intro to HTMX
HTMX is a small, dependency-less javascript library that sprinkles some fancy bindings on to your normal markup without you needing to write javascript. It gives you the ability to have things like asynchronous data fetching and dynamic content updates.
I came across this post by Chris James that sounded right up my alley.
I was following right along, and things were sounding great, but then I read this line
I did not write any JavaScript.
and I was sold!
For me, many of my applications just need minimal javascript for things like search autocomplete, toggling the view of something, or just delay loading hefty content.
I decided to try this out in a Lucky app to see how quick can I get something to show... (hint: it was really quick)
Example code
To get started, you just need to include their package:
<script src="https://unpkg.com/htmx.org@1.3.3"></script>
Then somewhere on your page, you just use HTML attribute designated by their API to augment your markup.
This example taken from the docs site https://htmx.org/examples/click-to-load/
<tr id="replaceMe">
<td colspan="3">
<button class='btn' hx-get="/contacts/?page=2"
hx-target="#replaceMe"
hx-swap="outerHTML">
Load More Agents... <img class="htmx-indicator" src="/img/bars.svg">
</button>
</td>
</tr>
This will call back to your server hitting the /contacts/?page=2
path which will return some HTML. Then it takes that markup and replaces the <tr id="replaceMe">
with the content that was fetched.
Using Lucky for the server
Lucky is a full-stack framework written in the Crystal programming language. One of the neat benefits of using Lucky with Crystal is the typesafety you get.
Lucky doesn't use standard HTML, but instead uses Crystal methods that generate HTML. This ensures that your markup is always rendered properly. No misspelling a tag name, or forgetting to close a tag. No passing in the wrong type and accidentally rendering something that looks like [object Object]
, or worse, rendering an empty string to the page.
Example code
This is an example of a simple HTML page in Lucky.
class Products::ShowPage < MainLayout
needs product : Product
def content
# recognizable HTML tags
div class: "flex flex-col p-4" do
h1 product.title, class: "text-xl"
# use of sharable components
mount Products::Images, product: product
# built-in methods for displaying text
simple_format(product.description)
end
end
end
Lucky meets HTMX
Getting these two things together doesn't take too long. I will do a quick demonstration of an app where a user logs in, and on their "dashboard", some numbers are displayed from a timer. You can imagine that these numbers would be fed in to a chart or report that could update without the need of refreshing the browser.
At this point, if you're not familiar with Crystal or Lucky, just do your best to follow along. I'm skipping the setup/install process for brevity.
Check out the Lucky tutorial for later.
Generating the app
From the CLI, we will bootstrap the app.
> lucky init
Project name?: htmx_demo
API only or full support for HTML and Webpack? (api/full): full
Generate authentication? (y/n): y
> cd htmx_demo
> ./script/setup
Adding Report
This is a Report
model that just has a count
property. It'll be associated to a user.
> lucky gen.model Report
Next we will edit that new migration in db/migrations/
to add our new fields.
def migrate
create table_for(Report) do
primary_key id : Int64
add_timestamps
add count : Int32
add_belongs_to user : User, on_delete: :cascade
end
end
Now update the model in src/models/report.cr
class Report < BaseModel
table do
column count : Int32
belongs_to user : User
end
end
And lastly, update the User
model in src/models/user.cr
table do
column email : String
column encrypted_password : String
# Add this line
has_many reports : Report
end
Migrate it the DB
> lucky db.migrate
Setting up views
First we will add HTMX to our layout in src/components/shared/layout_head.cr
# add this line to the `head` block
script src: "https://unpkg.com/htmx.org@1.3.3"
For the next part, we will be setting up the user's "dashboard" to render this "report". We will first generate a new component to use for this report.
> lucky gen.component Reports::List
Then update that component in src/components/reports/list.cr
class Reports::List < BaseComponent
needs reports : Array(Report)
def render
div do
reports.each do |rep|
para rep.count
end
end
end
end
Now we can update the user's dashboard on src/pages/me/show_page.cr
def content
h1 "This is your profile"
h3 "Email: #{@current_user.email}"
# the actual HTMX magic
div hx_get: Stats::Index.path, hx_trigger: "every 10s"
end
Here we create a normal div that tells HTMX to do HTTP GET
to the Stats::Index.path
(which doesn't exist yet), and we tell it to do that "every 10s".
Setting up the actions
Now we need to define that Stats::Index
so it knows where to fetch the data.
> lucky gen.action.browser Stats::Index
We can update that action in src/actions/stats/index.cr
class Stats::Index < BrowserAction
get "/stats" do
reports = ReportQuery.new.user_id(current_user.id)
component Reports::List, reports: reports
end
end
Wrap it up
That's actually all it takes for the MVP just to see something in action. We can now boot up the app, create an account, and check it out.
- Boot the app
lucky dev
- Visit the app in browser
http://localhost:3000
- Click the "go to app" button
- Create a new account and sign in
- See that there's nothing to display
While you're on that page, go back to your terminal, and open a new terminal tab/window. We can jump in to the sql console and add some records manually to watch them show up on the page!
> lucky db.console
# insert into reports (user_id, count) values (1, 1);
# insert into reports (user_id, count) values (1, 9);
# insert into reports (user_id, count) values (1, 4);
I think you get the idea :D
Recap
Every 10 seconds, a call will be made back to the Stats::Index
action which will return the Reports::List
component markup. We do have an issue here where if someone tries to visit /stats
, they'll get some broken markup. We can fix that by detecting HTMX calls.
get "/stats" do
reports = ReportQuery.new.user_id(current_user.id)
if request.headers["HX-Request"]? == "true"
component Reports::List, reports: reports
else
html Stats::IndexPage, reports: reports
end
end
Then we'd create that Stats::IndexPage
to render the component
# src/pages/stats/index_page.cr
class Stats::IndexPage < MainLayout
needs reports : Array(Report)
def content
mount Reports::List, reports: reports
end
end
Next, you'll notice that when the /me
page first loads, there's no data... It takes 10 seconds before we see something. We can make some default data by telling the Me::ShowPage
that it needs reports : Array(Report)
div hx_get: Stats::Index.path, hx_trigger: "every 10s" do
mount Reports::List, reports: reports
end
There's still a lot left to explore with this, and obviously HTMX isn't going to cover every case when it comes to building an interactive site, but if you don't need a ton of interactivity, this is a beautiful solution!
Top comments (1)
Beautiful! I have to start playing with Crystal, Lucky and HTMX <3