DEV Community

Marvin Ahlgrimm
Marvin Ahlgrimm

Posted on

Hotwire Marten: Updating Just the Parts That Changed

In the first part, we wired up Hotwire in a Marten application and built a small task list. Clicking "Create task" loaded a form into a Turbo Frame. Pretty nice for something that barely involved JavaScript!

But a task app where you can't complete a task feels a little broken. Nothing is done or is everything already done? Or maybe I just forgot to add some mechanisms for that 🤷🏻.

Let's fix that with Turbo Streams. New tasks should appear in the list without a full page reload, completing a task should update just that item, and deleting one should remove it.

Turbo, Stimulus, and importmap are already in place from the first part, so we can focus on the behavior this time. We'll start with task creation.

Render a task once

Before adding the streams, let's extract the markup for a task into src/templates/tasks/_task.html:

<li id="{% dom_id task %}">
  <span>{{ task.title }}</span>
</li>
Enter fullscreen mode Exit fullscreen mode

The dom_id tag comes from marten-turbo. For a task with ID 42, it produces task_42. We'll use that ID later to update or remove exactly this element.

In tasks/list.html, replace the existing list with:

<ul id="tasks">
  <li class="empty">No tasks yet.</li>
  {% for task in records %}
    {% include "tasks/_task.html" %}
  {% endfor %}
</ul>
Enter fullscreen mode Exit fullscreen mode

The surrounding "Create task" link and empty new_task frame stay as they were. The list now has an ID too, so we can append things to it.

One small detail: the empty message is always in the HTML. A little CSS handles its visibility:

#tasks .empty:not(:only-child) {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

When a task appears, the message hides. When we delete the last task, it comes back. Conveniently, our stream responses don't have to do anything about it. Modern CSS is awesome!

Also add ordering :id to Tasks::ListHandler. We're going to append new tasks at the bottom, so the initial page should use the same order. Otherwise a reload could rearrange our list. Small but important!

Creating a task with Turbo Streams

Our create handler already knows how to validate and save a task. We can keep that and switch its parent class to the Turbo-aware version:

class Tasks::CreateHandler < MartenTurbo::Handlers::RecordCreate
  model ::Task
  schema TaskSchema
  template_name "tasks/create.html"
  turbo_stream_name "tasks/create.turbo_stream.html"
  record_context_name "task"
  success_route_name "home"
end
Enter fullscreen mode Exit fullscreen mode

turbo_stream_name tells the handler which template to render after a successful Turbo submission. record_context_name makes the created record available as task, matching the variable our partial already uses.

Now create src/templates/tasks/create.turbo_stream.html:

{% turbo_stream 'append' 'tasks' template: 'tasks/_task.html' %}
{% turbo_stream 'replace' 'new_task' do %}<turbo-frame id="new_task"></turbo-frame>{% end_turbo_stream %}
Enter fullscreen mode Exit fullscreen mode

That's the entire response template.

After saving, the task appears in the list and the form closes.

Why replace the frame instead of removing it? Our "Create task" link still needs that frame as its target. Replacing it also clears the src attribute Turbo added when it loaded the form, so clicking the link again can fetch a fresh form.

What about validation?

A successful submission is the easy part. Let's submit an empty title.

We want the form to stay open and show the error from Marten. For that, move the frame and form into src/templates/tasks/_form.html:

<turbo-frame id="new_task">
  <form action="{% url 'tasks_create' %}" method="post">
    {% csrf_input %}
    <label for="{{ schema.title.id }}">Title:</label>
    <input type="text" id="{{ schema.title.id }}" name="{{ schema.title.id }}">
    {% for error in schema.title.errors %}
      <p class="input-error">{{ error.message }}</p>
    {% endfor %}
    <button type="submit">Create</button>
  </form>
</turbo-frame>
Enter fullscreen mode Exit fullscreen mode

Then replace the frame in tasks/create.html with {% include "tasks/_form.html" %}. Keep the page's template inheritance and content block around it.

I've removed the input's required attribute here so we can actually send an empty title to Marten and see its validation response. The schema still validates the title on the server:

class TaskSchema < Marten::Schema
  field :title, :string, max_size: 255
end
Enter fullscreen mode Exit fullscreen mode

The old completed field is gone from this schema. Creating a task only needs a title; completion will get its own schema in a moment.

Add one more setting to Tasks::CreateHandler:

turbo_invalid_stream_name "tasks/create_invalid.turbo_stream.html"
Enter fullscreen mode Exit fullscreen mode

And create that template:

{% turbo_stream 'replace' 'new_task' template: 'tasks/_form.html' %}
Enter fullscreen mode Exit fullscreen mode

The handler returns a 422 response for invalid submissions and renders the form with Marten's validation errors. The form stays open so we can try again.

Finally, completing some work

Let's add a checkbox. First, a small schema in src/schemas/task_completion_schema.cr:

class TaskCompletionSchema < Marten::Schema
  field :completed, :bool, required: false
end
Enter fullscreen mode Exit fullscreen mode

And an update handler in src/handlers/tasks/complete_handler.cr:

class Tasks::CompleteHandler < MartenTurbo::Handlers::RecordUpdate
  http_method_names :post

  model ::Task
  schema TaskCompletionSchema
  turbo_stream_name "tasks/complete.turbo_stream.html"
  record_context_name "task"
  success_route_name "home"
end
Enter fullscreen mode Exit fullscreen mode

Add its route inside Marten.routes.draw:

path "/tasks/<pk:int>/complete", Tasks::CompleteHandler, name: "tasks_complete"
Enter fullscreen mode Exit fullscreen mode

The handler only accepts POST requests. We don't need a separate page for editing one checkbox.

Its stream template, tasks/complete.turbo_stream.html, is another one-liner:

{% turbo_stream 'replace' task template: 'tasks/_task.html' %}
Enter fullscreen mode Exit fullscreen mode

This time we pass the record itself as the target. The tag derives its DOM ID, so task becomes something like task_42, matching the <li> from our partial.

Now update _task.html:

<li id="{% dom_id task %}"{% if task.completed %} class="completed"{% endif %}>
  <form action="{% url 'tasks_complete' pk: task.pk %}" method="post">
    {% csrf_input %}
    <input type="hidden" name="completed" value="0">
    <input type="checkbox" name="completed" value="1" aria-label="Completed"{% if task.completed %} checked{% endif %}>
    <button type="submit">Save</button>
  </form>
  <span>{{ task.title }}</span>
</li>
Enter fullscreen mode Exit fullscreen mode

And give completed tasks a little styling:

#tasks .completed span {
  color: #888;
  text-decoration: line-through;
}
Enter fullscreen mode Exit fullscreen mode

Check the box and click Save. The task is saved and shown as completed.

There's a slightly sneaky detail in the form: the hidden input before the checkbox. An unchecked checkbox sends no value at all. We need an explicit 0 to clear an existing completion value. When checked, both values are sent, and Marten takes the last one: 1.

If you've used Rails form helpers, this probably looks familiar.

A little Stimulus, finally

The Save button works, but clicking it after every checkbox change feels unnecessary. This is a good job for Stimulus.

Generate a controller:

marten stimulus generate controller autosubmit
Enter fullscreen mode Exit fullscreen mode

Replace its generated contents with:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  submit() {
    this.element.requestSubmit()
  }
}
Enter fullscreen mode Exit fullscreen mode

Then add the controller and action to the completion form:

<form action="{% url 'tasks_complete' pk: task.pk %}" method="post"
      data-controller="autosubmit" data-action="change->autosubmit#submit">
Enter fullscreen mode Exit fullscreen mode

The checkbox's change event bubbles up to the form, where Stimulus calls submit(). We can put the Save button inside <noscript> so it's only displayed when JavaScript is disabled:

<noscript><button type="submit">Save</button></noscript>
Enter fullscreen mode Exit fullscreen mode

Our importmap and controller loading were already configured in the first part. Rebuild and restart the server after adding the controller so its pin appears in the generated importmap.

Now clicking the checkbox submits the form, saves the task, and replaces the list item. Stimulus also connects the controller on the replacement form, so the next click works too.

And that's all the custom JavaScript for this feature. The controller doesn't even know what a task is. It just submits its form.

Deleting a task

We can create and complete tasks now. Let's also add a way to delete them.

Create src/handlers/tasks/delete_handler.cr:

class Tasks::DeleteHandler < MartenTurbo::Handlers::RecordDelete
  http_method_names :post

  model ::Task
  success_route_name "home"

  def turbo_stream
    MartenTurbo::TurboStream.remove(record)
  end
end
Enter fullscreen mode Exit fullscreen mode

The handler also lets us return a MartenTurbo::TurboStream directly from Crystal. For deletion, that saves us a template file.

Add the route:

path "/tasks/<pk:int>/delete", Tasks::DeleteHandler, name: "tasks_delete"
Enter fullscreen mode Exit fullscreen mode

And add a second form after the title's <span> inside _task.html:

<form action="{% url 'tasks_delete' pk: task.pk %}" method="post">
  {% csrf_input %}
  <button type="submit">Delete</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Click Delete, the record is deleted, and the stream removes its list item. If that was the last task, our empty message becomes visible again through CSS.

Where we ended up

We now have a task list where creating, completing, and deleting tasks updates just the relevant elements on the page.

What I like about the Marten integration is how little we had to add to our existing handlers. We switched to the Turbo-aware base classes, configured the stream templates, and reused the same task partial throughout. The only custom JavaScript is our small autosubmit controller, but it feels SPA like nonetheless!

Top comments (0)