Tony Messias

April 1, 2021

Turbo Frames, Sidebar Layout, and Details Page

I have been thinking about this problem for a while. There is a page design style that I found hard to replicate in a Hotwire way, but I think I got a good solution.

Say you have a sidebar section where you list all notes and the main content section on the right where you render each note. Clicking on a note (or creating a new one) would redirect users to the note details page, but only the main content would change.

The URL would also change, so if users refresh the page, they would still be on the note's details page. But the sidebar is only filled on the index page. Otherwise, we would have to pass down all the notes to each view that uses this sidebar layout.

image.png


The notes index page is easy enough:

  • List all the notes the user has created
  • Show a cool message where the note details would render

To make sure the sidebar persists between page requests, we can make use of Permanent Elements. Turbo allows you to annotate certain pieces of the page as "permanent", which means that as long as an element with the same ID renders on subsequent Turbo visits, it won't touch that element.

This preserves data and event listeners that you may have on those elements. The interesting bit: the element content on the page visit doesn't matter much (or so I think.) As long as there is a matching permanent element with the same ID on the next page, it works.

So we can add an ID and a `data-turbo-permanent` pair of attributes to the sidebar. When users click on a note, they will navigate to the note details page and the sidebar's content will be kept intact. Only the note details will re-render.

However, once we are on the note details page, we need to handle the case where users refresh the page after the visit. Ideally, the notes sidebar would render in place. But we could also lazy load it. Wait a minute... that's something we could do if the sidebar notes list was wrapped in a Turbo Frame!

If we do so, we would have a structure like this on the index page:

<body>
  <div>
    <sidebar
      id="turbo-notes-sidebar"
      data-turbo-permanent
    >
      <h1>Turbo Notes</h1>
      <turbo-frame id="notes" target="_top">
        @include('notes._list', [
          'notes' => $notes,
        ])
      </turbo-frame>
    </sidebar>

    <main>
      <p>Create a note or select one from the list.</p>
    </main>
  </div>
</body>

The `target="_top"` on the `<turbo-frame>` will make sure the URL is updated when the note's link is clicked on the notes list, while the `data-turbo-permanent` that wraps the `<turbo-frame>` will make sure the element is kept in place.

Next, on the notes detail page, we would have the following HTML structure:

<body>
  <div>
    <sidebar
      id="turbo-notes-sidebar"
      data-turbo-permanent
    >
      <h1>Turbo Notes</h1>
      <turbo-frame
        id="notes"
        target="_top"
        src="{{ route('notes.index') }}"
      >
        <p>Loading notes...</p>
      </turbo-frame>
    </sidebar>

    <main>
      <h2>{{ $note->title }}<h2>

      {!! clean($note->content) !!}
    </main>
  </div>
</body>

The `<main>` section will now render the note details instead of a placeholder message. The sidebar structure is almost the same, the Permanent Element is still there and all. But notice the difference in the `<turbo-frame>`? Now, instead of listing the notes again, it will render a lazy-loading Turbo Frame that points to the index, which also contains a matching Turbo Frame, but there the notes are rendered inside of the Turbo Frame.

This means that when we navigate from the index page to the details page, the sidebar is kept untouched in the DOM. Nothing changes there. But when we are in the details page and reload the browser, another sidebar will render, this one will contain a lazy-loading Turbo Frame, which will instruct Turbo to make an AJAX request and look for a matching Turbo Frame on the index and replace its content.

There is a gotcha though. Now we have a lazy-loading Turbo Frame on the page that is permanent. So, whenever we visit another note, Turbo will re-trigger the AJAX requests of every lazy-loading Turbo Frame on the page. That's probably not what we want.

artflow_202103312137.png


One way to solve this is to remove the `src` attribute of the Turbo Frame after it enters the DOM. Turbo will still replace the content, but then it won't be a lazy-loading Turbo Frame anymore, just a regular Turbo Frame.

We could use Stimulus for that, but for this example, I'm using Alpine.js:

<body>
  <div>
    <sidebar
      id="turbo-notes-sidebar"
      data-turbo-permanent
    >
      <h1>Turbo Notes</h1>
      <turbo-frame
        id="notes"
        target="_top"
        src="{{ route('notes.index') }}"
        x-data
        x-init="
          if ($el.hasAttribute('src')) {
            $el.removeAttribute('src')
          }
        "
      >
        <p>Loading notes...</p>
      </turbo-frame>
    </sidebar>

    <main>
      <h2>{{ $note->title }}<h2>

      {!! clean($note->content) !!}
    </main>
  </div>
</body>

With that, the `src` attribute will be removed, and that Turbo Frame from the details page will no longer be a lazy loading one.

artflow_202103312105.png


Bonus round: instead of adding a "loading notes..." message, we could implement some kind of skeleton loading element and give this part more love, example:

skeleton-loading-example.gif


So that's how I think I would implement this kind of layout with Hotwire. Do you know of another way to achieve this? Let me know.