---
title: Turbo morphing woes
teaser: Morphing is cool, but it can break your JavaScript. Here are some of the problems
  of using it and how to work around them.
tags: hotwire,rails
author: Matheus Richard
published_on: 2024-12-11
---

Turbo 8 [introduced] the concept of page refreshes with morphing. It is a
powerful feature, so you might be tempted to enable it globally. However, that can
cause some issues with your JavaScript code. Here are some of the common pitfalls
and how to work around them.

<aside class="info">
  <p>I'm using Turbo morphing in the examples specifically to illustrate the problems you might encounter. In a real-world scenario, you might not need it at all.</p>
</aside>

## A common problem with morphing

Let's see the problem in action. Imagine we have a Rails app with a simple
scaffold for a Comment model that `has_rich_text :content`. We list all comments
in the index page, with a form to create a new one:

```erb
<!-- app/views/comments/index.html.erb -->
<h1>Comments</h1>

<% if @comments.empty? %>
  <p>No comments yet.</p>
<% else %>
  <ol id="comments" reverse>
    <%= render @comments %>
  </ol>
<% end %>

<hr>

<%= render "form", comment: Comment.new %>
```

The controller action is very simple:

```ruby
# app/controllers/comments_controller.rb
def create
  Comment.create(comment_params)

  redirect_to comments_path
end
```

So, when submitting the form, the server will render the `index` view again with
the new comment. Let's enable Turbo morphing and see what happens. In your app
layout, add the following:

```diff
<!-- app/views/layouts/application.html.erb -->
+<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<%= yield :head %>
```

If we try adding a comment, the editor toolbar simply disappears and the text
area stops being interactive:

<video controls muted playsinline height="500" src="https://images.thoughtbot.com/rcqbzqitakye9zara3szu5mgrofo_problem.mp4"></video>

That happens because Morphing replaces the trix-editor element with a new one,
but the JavaScript state is lost. If you used Turbolinks in the past, you might
be familiar with this issue.

## Opting out of morphing

Luckily, there's an easy way to opt-out of morphing for specific elements. Just
add the `data-turbo-permanent` attribute to the element you want to stay untouched:

```diff
<!-- app/views/comments/_form.html.erb -->
-<div>
+<div data-turbo-permanent>
  <%= form.label :content %>
  <%= form.rich_text_area :content %>
</div>
```

<aside class="info">
  <p>Note that we have to add the attribute to the wrapping div, not the rich textarea itself.</p>
  <p>That's because we want the <code>&lt;trix-toolbar&gt;</code> element to be preserved as well.</p>
</aside>

Now, the comment is added to the list and the editor is kept intact. Unfortunately,
that means that its content isn't cleared. We can fix that by resetting the form
after a successful submission. For example, with a Stimulus controller:

```javascript
// app/javascript/controllers/form_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  reset() {
    this.element.reset()
  }
}
```

And we can use it in the form:

```erb
<!-- app/views/comments/_form.html.erb -->
<%= form_with(model: comment, data: {controller: "form", action: "turbo:submit-end->form#reset"}) do |form| %>
  <%# ... %>
<% end %>
```

Problem solved! This is the basic pattern with the problem: listen for a certain [Turbo event][]
(e.g.
`turbo:submit-end` or `turbo:morph-element`) and reset/reconnect the JS library.

## When we can't skip morphing

Let's say we got two new requirements:

1. Make the list update in real-time as people add/edit/delete comments.
2. Make comments collapsible, so they don't take up too much space.

To implement the first requirement, we'll use Turbo streams. First, let's
broadcast refreshes from the model:

```diff
# app/models/comment.rb
class Comment < ApplicationRecord
  # ...
+  broadcasts_refreshes
end
```

And then stream from the `comments` channel in the index view:

```diff
<!-- app/views/comments/index.html.erb -->
+<%= turbo_stream_from 'comments' %>
<div id="comments">
  <%= render @comments %>
</div>
```

And in the comment partial:

```diff
<!-- app/views/comments/_comment.html.erb -->
+<%= turbo_stream_from comment %>
```

On to the second task! To make comments collapsible, we'll simply wrap them with a
`<details>` element:

```erb
<!-- app/views/comments/_comment.html.erb -->
<div id="<%= dom_id comment %>">
  <%= turbo_stream_from comment %>
  <details open>
    <summary>
      Comment #<%= comment.id %> | <%= comment.updated_at %>
    </summary>

    <%= comment.content %>

    <%= button_to "Update", comment, method: :patch %>
    <%= button_to "Delete", comment, method: :delete %>
  </details>
</div>
```

That all works, but everytime someone edits their comment, the model broadcasts
a page refresh, which makes the details element open again for everyone that had
it closed. Annoying!

<video controls muted playsinline height="500" src="https://images.thoughtbot.com/cf8nu09pf2up7jeusl7o2ydsryuh_problem2.mp4"></video>

We can't use `data-turbo-permanent` here because we want the comment to be
updated when users change it. Instead, what we will do is to prevent Turbo from
morphing the `open` attribute of the `details` element. We'll create a Stimulus
controller to handle that:

```javascript
// app/javascript/controllers/details_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  preventToggle(event) {
    const { attributeName } = event.detail;
    if (attributeName === "open") event.preventDefault()
  }
}
```

And use it in the details element with the [`turbo:before-morph-attributes` event][`turbo:before-morph-attributes` event]:

```diff
<!-- app/views/comments/_comment.html.erb -->
  <%= turbo_stream_from comment %>
-  <details open>
+  <details open data-controller="details" data-action="turbo:before-morph-attribute->details#preventToggle">
```

And that's it. Now the details element won't be reopened when the model changes,
but its contents are still updated. If you're using a `dialog` element, you
may need to do something similar too.

<video controls muted playsinline height="500" src="https://images.thoughtbot.com/o3w3kshljeywce1qhm1j79p8csih_final.mp4"></video>

## Morphing by default?

I hope these examples illustrate that morphing isn't as simple as it might seem,
so enabling it by default can be dangerous. There's [an open issue in the Turbo
repository] to discuss this and other morphing-related problems. Follow it to
stay updated on other possible solutions.

With that all in mind, I think that's safe to say that at this point Turbo
morphs are [sharp knives] that should be wielded with care in specific scenarios,
but not something ready yet to be enabled globally.

[introduced]: https://dev.37signals.com/page-refreshes-with-morphing-demo/
[Turbo event]: https://turbo.hotwired.dev/reference/events
[`turbo:before-morph-attributes` event]: https://turbo.hotwired.dev/reference/events#turbo%3Abefore-morph-attribute
[an open issue in the Turbo repository]: https://github.com/hotwired/turbo/issues/1083
[sharp knives]: https://rubyonrails.org/doctrine#provide-sharp-knives
