Skip to content

Updating far away elements

Let's add an unread count to each feed in the sidebar!

Adding the unread count initially is relatively straightforward. When we load the page, count how many unread entries there are, and put them on the sidebar beside each feed:

templates/feed.html
<span>{{ feed_['entries'].values() | rejectattr("read") | list | length }}</span>

This is not the simplest bit of code! Using Jinja filters we build an expression that:

  • Gets the current feed's entries.
  • Rejects those that have read=True.
  • Turns it into a list.
  • Gets the length.

We can put that in a span, and we could style it however we want.

Updating without reloading

The main difficulty with this is that when we mark an entry as read, the sidebar won't reload. We also can't use HTMX out-of-band swaps easily, because the endpoint that marks an entry as read redirects the user to a different site.

So we go back to AlpineJS, where we'll use event-driven development to achieve this.

When we mark an entry as read we're already using AlpineJS to change the state locally:

x-on:click="read = true"

AlpineJS also has event handling, so we can dispatch an event here that can then be caught by the span that contains the number of unread entries.

To dispatch an event, we do:

x-on:click="$dispatch('read'); read = true;"

We can include extra data, and we should! A user might click an entry multiple times, so let's include whether the entry has an unread state or not:

x-on:click="$dispatch('read' { read: read }); read = true;

There's a lot of read there! In order:

  • 'read' is the event name.
  • In { read: read }, the first read is the name of the key in the object that we are passing.
    • The second read is the current value of the AlpineJS attribute we're using to store whether an entry has been read or not.
  • After the semicolon, we change the aforementioned status to true. Nothing to do with the event dispatch.

You could probably write this line of code better than me!

Here's the full entry.html code with the changed line highlighted:

templates/partials/entry.html
{#
A rendered summarized entry
- title
- published
- summary
- media_content (post image)
- author
- link
- read
- feed_url
#}
<article class="grid grid-cols-[300px_600px] mb-4">
    {% if media_content %}
        <img class="aspect-video rounded-md shadow-md"
             src="{{ media_content[0]['url'] }}" />
    {% endif %}
    <div class="ml-8 pt-4 {{ 'col-start-2' if media_content else 'col-start-1 col-span-2' }}"
         x-data="{ read: {{ read | lower }} }">
        <h2 class="font-bold text-2xl">
            <svg x-show="!read"
                 viewBox="0 0 16 16"
                 xmlns="http://www.w3.org/2000/svg"
                 class="fill-emerald-500 h-3 w-3 inline-block mb-1">
                <circle cx="8" cy="8" r="8" />
            </svg>
            {{ title }}
        </h2>
        <p>
            Published by <span class="font-medium">{{ author }}</span> on <span class="font-medium">{{ published
        }}</span>
    </p>
    <div class="leading-6 mb-2">{{ summary }}</div>
    <a x-on:click="$dispatch('read', {read: read}); read = true;"
       href="{{ url_for('read_entry', feed_url=feed_url, entry_url=link) }}"
       class="underline"
       target="_blank">Read article</a>
</div>
</article>

Catching the event

AlpineJS will bubble up the event upwards through any HTML container that is itself also an AlpineJS component.

At the moment, there are no common ancestors of the entry and the sidebar which are AlpineJS components. So there is no way for this event to be caught by the sidebar span.

Let's find a common ancestor of entries and the sidebar: the body.

We could structure our HTML page a bit better, put everything inside a div and use that. But we've got what we've got, and the body is a fine element.

Let's make the body an AlpineJS component so that the event can bubble up to it and be caught by any of its children.

<body x-data>

We don't need to put anything in x-data, just doing this does what we need.

Now we can go to the event and add some logic there to catch the event. If the current status of the entry is unread, then we can decrease the number in the badge by 1:

<span @read.window="{{ '$el.innerHTML -= $event.detail.read ? 0 : 1' if feed.href == feed_['href'] else '' }}">{{ feed_['entries'].values() | rejectattr("read") | list | length }}</span>

We also need to make sure that we're only decreasing the number in the currently active feed, as that's the only feed in which we could be clicking an article. Otherwise, all feeds would see their unread article count lower.

Here's the final code for the feed with changed lines highlighted:

templates/feed.html
{% extends "base.html" %}
{% block content %}
  <div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
    <div class="flex grow flex-col gap-y-5 overflow-y-auto bg-white px-6 py-4">
      <nav class="flex flex-1 flex-col">
        <ul role="list" class="flex flex-1 flex-col gap-y-3">
          {% for feed_url, feed_ in feeds.items() %}
            <li>
              <a href="{{ url_for('render_feed', feed_url=feed_.href) }}"
                 hx-boost
                 class="{{ 'bg-green-700 text-white' if feed.href == feed_['href'] else '' }} group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
                {{ feed_['title'] }}
                <span @read.window="{{ '$el.innerHTML -= $event.detail.read ? 0 : 1' if feed.href == feed_['href'] else '' }}">{{ feed_['entries'].values() | rejectattr("read") | list | length }}</span>
              </a>
            </li>
          {% endfor %}
        </ul>
      </nav>
      <div class="flex flex-col justify-center items-center"
           x-data="{show: false}">
        <!-- The below doesn't need to be a partial anymore because we're never rendering it in a Flask endpoint -->
        <div x-show="show">{{ render_partial("partials/add_feed.html") }}</div>
        <button x-on:click="show = !show"
                class="bg-slate-600 text-white font-semibold rounded-md px-4 py-2"
                x-text="show ? 'Hide' : 'Show'">Show</button>
      </div>
    </div>
  </div>
  <div class="pl-72">
    <main class="py-10">
      <div class="px-4 sm:px-6 lg:px-8"
           id="entries"
           hx-get="{{ url_for('render_feed_entries', feed_url=feed.href, page=0) }}"
           hx-swap="afterbegin"
           hx-target="this"
           hx-trigger="load"></div>
    </main>
  </div>
{% endblock content %}

Note

@read is the event name, and @read.window makes it so the span can catch the event even if it wasn't emitted by a child component.

With this, we're done! We've rapidly built a simple app that is just interactive enough, simple to understand, and very fast to incrementally update.

Thank you for reading, and I hope you've enjoyed this session!