Joyus: I tried Datastar

Alex Moon
Alex Moon
11 January 2026

Social media is bad for you! Due to what a 2024 study dubs the "confrontation effect" (Hacker News readers may know it as the "contrarian dynamic"), social media users are four times more likely to engage with content they hate than content they love. Specifically, they are most likely to engage with "high threat (fighting for a cause)" content. This, in turn, feeds engagement-based algorithms, creating a "variable ratio schedule" of reinforcement, or "intermittent reinforcement" loop, creating the conditions for habit formation in much the same way slot machines do.

This in turn leads to what another 2024 study calls a "funhouse mirror" effect, which artificially inflates the most threatening and extreme content which, in turn, massively distorts perceptions of social norms, leading to a "professionalization of conspiracy- or hate-based influencer practices". What's more, engagement-based algorithms only exacerbate an existing human tendency to spread "novel information". Through the sheer efficiency of "hate-sharing", we do it to ourselves. There is, strictly speaking, no algorithm required.

The problem isn't that social media is evil. The problem is that social media is frictionless: it speaks directly to the fast bits of the brain, which are also the bits of the brain that are impulsive and full of fear.

What would a social media app look like that deliberately imposed friction in order to counter this effect? Meet Joyus: an "anti-social media" app. The premise is very straightforward: you can't post straight away - instead, you have to answer three questions:

  1. What upset or annoyed you today?

  2. What were you doing when this happened?

  3. What joy do you normally get doing this?

Your answer to the third question is your post. That's it!

Context

In my last post, I had a go at using HTMX with custom elements and found that it wasn't straightforward to get that framework to do what I wanted. When I posted the write-up to Hacker News, many of the commenters suggested I take a look at Datastar, which is closer in its motivation to what I was trying to do with MESH.

The premise of MESH is very straightforward: one Web Component equals one endpoint. Each component is served by a backend that pushes updates via an SSE service.

I chose Rust for the backend implementation. I wanted to have a go at Rust, and I am satisfied that I've done that. I won't be putting any of the Rust code in this write-up, because, honestly, it is garbage (no pun intended).

Make no mistake, I consider compile-time memory safety an extraordinary achievement. I have nothing but the highest respect for the computer scientists behind RAII and for Graydon Hoare for building a programming language with the principles baked in as first class citizens.

Unfortunately, it doesn't work with my brain. I've been writing in GC languages for two decades. I found working with Rust excruciating and I genuinely hope I never have to touch it again.

Suffice it to say, the Rust in this project is "vibe-coded." That's OK! The interesting stuff is on the front-end anyway.

Modding Datastar

The first implementation of a MESH component using Datastar looked promising. Datastar already engages with the SSE-driven component swaps principle out of the box. Unfortunately, we run into the same fundamental conflict we had with HTMX: Datastar is built for the Light DOM. It is built on global state and it works with a single set of DOM elements.

Our Web Component architecture, which relies on encapsulated Shadow DOM for isolation, would not work with Datastar. Moreover, unlike HTMX, Datastar doesn't expose any event hooks to tap into. I'd have to hack it - but how?

First things first: I was going to have to clone Datastar into my repo so I could see exactly what it was doing. As I wandered through the Datastar code, it became clear I was going to have to do some edits.

I took the idea to Claude for inspiration, and something strange happened: instead of giving me suggestions, Claude decided I was trying to procrastinate, and went into what I call "ELIZA mode", a loop of demoralizing questions about my mental health, along with "gentle" suggestions to abandon the idea of modding Datastar directly.

I was adamant that I could do it; Claude was equally adamant that I was looking for an excuse to give up. What can I say? Never underestimate the power of spite as a motivator. I decided I was going to mod Datastar, just to spite Claude.

I cannot overstate the joy involved in this exercise. If you're a HN regular you'll be intimately familiar with the genre I call "for the glory of Satan" posts. Someone has decided to pull something apart and put it back together differently, to make it do something it was never designed to do.

When you first start doing something like this, there is a big front-load happening in the anterior cingulate cortex, which is responsible for error detection. All the signals coming into your brain suggest the exercise is futile. You're wasting your time. It will never work, and you'll eventually have to give up empty-handed. Every obstacle, every dead-end reinforces an increasing negative reward prediction, which grows like a charge on a capacitor.

"I am invincible!" - Boris Grishenko, GoldenEye (1995)

Then, suddenly, it works. The reward prediction error is enormous. A burst of dopamine hits the nucleus accumbens, which in turn triggers a flood of endogenous morphine to your brain's opiate receptors. Whenever you see one of these posts, and there's a subheading at the top that's like "Why do this?" This is why. This is why we do it: because that high is like a hit of pure opium.

MESH in Datastar

I already had the Datastar source code cloned raw into my project. Given the magnitude of the changes required, it became clear I was going to have to fork and fundamentally change the library's functionality. My changes will never go back into the main project (unless Delaney feels masochistic).

To bridge the gap between Datastar's global, Light DOM assumptions and MESH's component-level requirements, I made three core architectural modifications: to state, to visibility and to lifecycle. Let's dive right in!

State

Datastar implements reactivity very cleverly, and we will want to use all of it. The key concept underpinning the reactive logic of Datastar is the concept of "signals", which are stored on a reactive "root" object on the document. This object is defined thus:

export const root: Store = deep({})

That deep method is fairly involved, but, in a word, it creates a Proxy which handles reactivity via an internal signal, notifying subscribers when the object changes. The signal function, in turn, is implemented in Datastar, along with computed and effect, as per what you'd expect from a standard Signals API.

These three primitives, along with deep, give us everything we need to create a separate store for each component. To this end, we add three more methods to our Datastar fork:

export const createStore = (host: Element): Store => {
  const store = deep({}) as Store
  Object.defineProperty(store, '__ds', { value: true })
  Object.defineProperty(store, '__host', {
    value: host,
    writable: false,
    enumerable: false,
    configurable: false
  })
  return store
}

export const getHostFor = (el: Element): Element => {
  if (el.shadowRoot) {
    return el;
  }
  const rootNode = el.getRootNode() as Document | ShadowRoot
  const host = (rootNode as ShadowRoot).host
  return host ?? document.documentElement
}

export const getStoreFor = (el: Element): Store => {
  const owner: any = getHostFor(el)

  if (!owner.signals || owner.signals.__ds !== true) {
    const initialValues = owner.signals || {}
    owner.signals = createStore(owner)

    // Merge initial values into the new store using mergePatch
    if (Object.keys(initialValues).length > 0) {
      mergePatch(initialValues, owner.signals)
    }
  }

  return owner.signals as Store
}

That mergePatch, and other methods also defined by Datastar, remain as they are with one key change: any references to the document-level root are replaced with a method parameter for the store we want to use, which we can retrieve for any element by calling getStoreFor as above.

We can now use signals directly in our Component classes:

export class Component extends HTMLElement {
    protected signals = {};
}

Visibility

Datastar gives us a base apply method which adds an element in the DOM - and all its children - to a global MutationObserver which watches for changes and calls apply on any new additions as needed. The definition of these two is pretty straightforward:

// TODO: mutation observer per root so applying to web component doesnt overwrite main observer
const mutationObserver = new MutationObserver(observe)

export const apply = (
  root: HTMLOrSVG | ShadowRoot = document.documentElement,
): void => {
  if (isHTMLOrSVG(root)) {
    applyEls([root], true)
  }
  applyEls(root.querySelectorAll<HTMLOrSVG>('*'), true)

  mutationObserver.observe(root, {
    subtree: true,
    childList: true,
    attributes: true,
  })
}

As you can tell from the TODO comment, you can tell that Delaney has already been thinking about how to use Datastar properly with web components. As with our signals, we're going to create a separate MutationObserver for each component, which will only watch for changes within that component's Shadow Root:

const observeRoot = (root: Element | ShadowRoot): void => {
  const owner = ((root as ShadowRoot).host ?? (root as Element)) as HTMLOrSVG

  const mo = new MutationObserver(observe)
  const opts = { subtree: true, childList: true, attributes: true } as const

  mo.observe(root, opts)
  if ((root as ShadowRoot).host) {
    mo.observe(owner, { attributes: true })
  }
}

export const apply = (
    root: HTMLOrSVG | ShadowRoot,
): void => {
    if (isHTMLOrSVG(root)) {
        applyEls([root])
    }

    const shadowRoot = (root as HTMLElement).shadowRoot || root;
    applyEls(shadowRoot.querySelectorAll<HTMLOrSVG>('*'))

    if (shadowRoot !== root) {
        observeRoot(shadowRoot as ShadowRoot)
    } else {
        observeRoot(root)
    }
}

Again, this largely just works - the applyEls method is unchanged, and we only need to modify observe to step into the shadow root if it exists:

const observe = (mutations: MutationRecord[]) => {
  for (const {
    target,
    type,
    attributeName,
    addedNodes,
    removedNodes,
  } of mutations) {
    if (type === 'childList') {
      for (const node of removedNodes) {
        if (isHTMLOrSVG(node)) {
          cleanupEls([node])
          cleanupEls(node.querySelectorAll<HTMLOrSVG>('*'))
          // ADDED: check the shadow root as well
          const sr = (node as HTMLElement).shadowRoot
          if (sr) {
            cleanupEls(sr.querySelectorAll<HTMLOrSVG>('*'))
          }
        }
      }

      for (const node of addedNodes) {
        if (isHTMLOrSVG(node)) {
          applyEls([node])
          applyEls(node.querySelectorAll<HTMLOrSVG>('*'))
          // ADDED: check the shadow root as well
          const sr = (node as HTMLElement).shadowRoot
          if (sr) {
            applyEls(sr.querySelectorAll<HTMLOrSVG>('*'))
            observeRoot(sr)
          }
        }
      }
    }

We can now simply call apply from our base Component:

export class Component extends HTMLElement {
    connectedCallback() {
        apply(this);
    }
}

Lifecycle

Finally, the most important bit, and the bit I had the most difficulty figuring out how to do: actually mutating the components. Datastar gives us a bunch of clever "morphing" code which I just deleted because it was confusing me. In MESH, we always swap the entire component.

However, there is a problem: we don't want to create a new SSE connection for every component. Instead, what we want is a single SSE connection which starts on page load and then fires off incoming mutations to host components that actually care about them.

It's worth going into a bit of detail here about how Datastar is engineered under the hood. In a word, the bulk of the "meat" of the code is in the engine directory, where methods like observe and apply are defined. All of the rest of the functionality - in another word, everything there is a data-* attribute for - is implemented as "plugins".

These plugins are registered in the Datastar engine, and applied to any given element via the apply method. There are a handful of these plugins that are wired together via a handful of custom events. In particular, we are interested in the fetch and patchElements plugins - the former is an "action" and the latter is a "watcher".

In order to open a connection to our SSE endpoint on the back-end, we will need to call our fetch plugin from somewhere. I ended up just doing this on my root "app" component:

<app-app data-init="@get('/events')" id="app">

That @get triggers a fetch to the /events endpoint, which is where my SSE back-end is running. When the fetch finishes, it emits a DatastarFetchEvent, and any registered "watcher" plugins have their apply method called. In the original Datastar code, patchElements would do the mutation itself:

const onPatchElements = (
    { error }: WatcherContext,
    { elements, selector, mode }: PatchElementsArgs,
) => {
    for (const child of elements) {
        target = document.getElementById(child.id)!
        if (!target) {
            console.warn(error('PatchElementsNoTargetsFound'), {
                element: { id: child.id },
            })
            continue
        }
        applyPatch([target], child)
    }
}

const applyPatch = (
  targets: Iterable<Element>,
  element: DocumentFragment | Element,
) => {
  for (const target of targets) {
    const cloned = element.cloneNode(true) as Element
    execute(cloned)
    target.replaceWith(cloned)
  }
}

I puzzled my way around how I was going to apply a patchElements to the component that was interested in it. The problem is that the component calling fetch is not the component we want to swap.

Instead, we want to iterate over the components in the SSE event and, for each one, get the ID of the top-level element (the component) and call apply on that component in the DOM.

However, what we don't want to do is traverse over the entire DOM recursively, stepping into shadow roots, because we've already done that once when we called apply.

Eventually, it became clear to me that the most straightforward way to do this would be to emit a new custom event globally with the element ID on it:


const onPatchElements = (
  { el, error }: WatcherContext,
  { elements }: PatchElementsArgs,
) => {
  for (const child of elements) {
    if (!child.id) {
      console.warn(error('PatchElementsNoTargetsFound'), {
        element: { id: child.id },
      })
      continue
    }

    document.dispatchEvent(
        new CustomEvent<DatastarElementPatchEvent>(DATASTAR_ELEMENT_PATCH_EVENT, {
            detail: {
              id: child.id,
              element: child,
            },
        }),
    )
  }
}

We then simply move the mutation code to our base component and create a listener on our new custom event:

export class Component extends HTMLElement {
    connectedCallback() {
        document.addEventListener(
            DATASTAR_ELEMENT_PATCH_EVENT,
            this.handlePatchEvent.bind(this)
        );
    }

    disconnectedCallback() {
        document.removeEventListener(
            DATASTAR_ELEMENT_PATCH_EVENT,
            this.handlePatchEvent.bind(this)
        );
    }

    handlePatchEvent(event: CustomEvent<DatastarElementPatchEvent>) {
        if (event.detail.id === this.id) {
            this.applyPatch(event.detail.element);
        }
    }

    applyPatch(element: Element) {
        const cloned = element.cloneNode(true) as Element
        this.replaceWith(cloned);
    }
}

And with that, we have a fully functional MESH-like fork of Datastar. Now all that's left to do is build out the rest of the app.

A MESH Component

This modified version of Datastar is very powerful - I am honestly pleased with it. To give you an idea of what you can do with it, have a look at the code for our "joy card" component:

<app-joy-card
    id="joy-card-{{ joy.id }}"
    data-signals="{
        created: {{ created|json }},
        distance: {{ joy.distance|json }},
        joy: {{ joy.joy|json }}
    }"
>
    <template shadowrootmode="open">
        <link rel="stylesheet" href="/assets/css/component/joy_card.css"/>
        <div class="joy-card">
            <div class="joy-text">{{ joy.joy }}</div>
            <div class="joy-summary">
                <div class="joy-created" data-text="$createdFormatted">{{ joy.created|json }}</div>
                <div class="joy-distance" data-text="$distanceFormatted">{{ joy.distance|json }}</div>
            </div>
        </div>
    </template>
</app-joy-card>

And the web component:

import {Component} from "../component";
import {computed} from "@engine/signals";
import {DateHelper} from "../../service/date";
import {DistanceHelper} from "../../service/distance";

export class JoyCard extends Component {
    protected signals = {
        joy: '',
        created: 0,
        distance: null,
        createdDate: computed(() => {
            return new Date(this.signals.created);
        }),
        createdFormatted: computed(() => {
            return DateHelper.format(this.signals.createdDate);
        }),
        distanceFormatted: computed(() => {
            return DistanceHelper.format(this.signals.distance);
        })
    }
}
window.customElements.define('app-joy-card', JoyCard);

If you've ever worked with Vue, this will look very familiar. Indeed, Angular has recently switched over to using the standard signal, computed and effect primitives. Because the paradigm is more or less standardised, anyone who is familiar with SPA frameworks can write MESH code in the same "headspace". There is just one key difference: the HTML is served from the back-end.

Friction and Flow

Joyus is now deployed. It's rough around the edges, but it proves the concept. I greatly enjoyed this project, and it gave me a chance to reflect on some things that I think are important not just for me but for everyone building software today.

The development of Joyus wasn't just about building an "anti-social" app; it was a technical validation of the MESH pattern - a way to bridge the gap between modern reactive "headspace" and the simplicity of server-sent HTML.

Performance through Isolation

By surgically adapting Datastar's core engine for Scoped State and Shadow DOM visibility, we've decoupled rendering through a global Event Bus. This architecture offers a significant performance win over traditional "HTML-over-the-wire" approaches. The Datastar engine only needs to descend and apply logic inside a component's Shadow Root once during initialization. For all subsequent server-pushed updates, the data is delivered directly to the host via the Event Bus. This bypasses expensive, full-document DOM scanning, ensuring that even as the page grows in complexity, the update overhead remains constant.

Signals-Over-Wire

We have spent a decade convinced that rich, reactive UIs require us to move our entire application logic to the client. Joyus proves that we can have the "Signal Headspace" - the reactive, component-based flow familiar to Vue or Angular developers - without abandoning the server as the Source of Truth.

The HTML is served from the back-end, but the interactivity feels like a local SPA because we are using standard signal, computed, and effect primitives within the components. It turns out that the "friction" of the server roundtrip isn't a bottleneck; it's a boundary that enforces a clean separation of concerns.

The Glory of Hard Problems

The real meta-lesson of this project was the value of deliberate technical friction. When an LLM tells you a modification is "impossible" or "procrastination," the sheer improbability of success builds up a technical "charge".

The moment of release - the massive dopamine hit when the mod finally works - is the ultimate reward for solving a hard, self-imposed problem. This is the core contrast with the low-quality, intermittent hits of "rage-clicking" or endless scrolling.

Creating friction in your environment - forcing yourself to move slow, think deep, and overcome perceived impossibility - is the most potent antidote to a frictionless world. Whether you are building an app or a career, the most sustainable growth happens when you choose to walk toward the harder problem.