deep·tech·intuition
intermediate ·

Datastar Deep Intuition

An experienced engineer's guide to Datastar

1. One-Sentence Essence

Datastar is a declarative hypermedia engine that uses Server-Sent Events and reactive signals to let the backend drive the frontend — combining what htmx does for server-driven HTML with what Alpine.js does for client-side reactivity, in a single ~12 KiB script tag.

2. The Problem It Solved

Web development in the 2020s split into two worlds that don’t talk to each other well.

In the SPA world (React, Vue, Svelte), you write your UI in JavaScript, manage state on the client, serialize everything to JSON, and the server becomes a dumb API. You get rich interactivity, but you pay for it with massive client bundles, complex build pipelines, duplicated validation logic, and the constant temptation to put business logic in the wrong place. Your “source of truth” is ambiguous — is it the server’s database or the client’s Redux store?

In the hypermedia world (htmx, Turbo), the server renders HTML and the browser receives fragments to swap into the DOM. This is closer to how the web was designed to work. Your backend is the source of truth. No JSON serialization dance. But htmx only handles the “server pushes HTML” half. If you need any client-side reactivity — toggling a dropdown, validating a form before submission, showing/hiding elements based on user input — you reach for a second library like Alpine.js. Now you’re managing two libraries that don’t know about each other, orchestrating their initialization order, and writing glue code to keep them in sync.

Datastar’s creator, Delaney Gillilan, experienced this friction firsthand. He originally proposed a rewrite of htmx in TypeScript with modern tooling. When that wasn’t accepted by the htmx community, the project became its own thing. The key insight was: if the server is going to drive the frontend, use the protocol designed for exactly that — Server-Sent Events. And if you need client-side reactivity, build it into the same framework using a signals system, so there’s one unified model for state and one way data flows.

The result: a single script tag, no build step required, no npm, no bundler, no framework boilerplate. One library that handles both “the server sends me new HTML” and “the user clicked a thing and I need to react.”

3. The Concepts You Need

Before you can think clearly about Datastar, you need to internalize these concepts. They’re the vocabulary of the framework.

The Hypermedia Model

  • Hypermedia-driven application: An architecture where the server sends HTML (not JSON) and the client renders it. The server decides what the user can do next by including appropriate controls in the HTML. This is how the web originally worked before SPAs took over.
  • Backend as source of truth: The server owns the application state. The frontend is a thin view layer that displays whatever the server tells it to display. This is a philosophical commitment, not just a technical choice.

Signals

  • Signal: A reactive variable. When a signal’s value changes, every expression that references that signal re-evaluates automatically. If you’ve used React’s useState or Vue’s ref, you already understand the concept. Signals are denoted with the $ prefix: $mySignal.
  • Computed signal: A read-only signal whose value is derived from other signals. Like a spreadsheet cell with a formula — it recalculates whenever its dependencies change. Created with data-computed.
  • Local signal: A signal whose key begins with an underscore (e.g., $_menuOpen). Local signals are not sent to the server with backend requests. Use them for purely client-side state like UI toggle states.
  • Signal store: The global collection of all current signals. Every signal is globally accessible from anywhere in the DOM. There’s no component-scoped state — signals live in a flat (or nested) global namespace.

Data Attributes

  • data-* attributes: Standard HTML attributes that Datastar hooks into to provide declarative behavior. The data- prefix is part of the HTML spec, not a Datastar invention. Datastar just gives these attributes meaning.
  • data-signals: Declares one or more signals with initial values. Think of it as variable declaration.
  • data-bind: Two-way binding between a signal and a form input. Change the input, the signal updates. Change the signal, the input updates.
  • data-text: Sets an element’s text content to the value of an expression. The reactive equivalent of element.textContent = value.
  • data-show: Shows or hides an element based on whether an expression is truthy.
  • data-class: Adds or removes CSS classes based on expressions.
  • data-attr: Binds any HTML attribute to an expression.
  • data-on: Attaches event listeners that execute Datastar expressions.
  • data-init: Runs an expression when an element is first loaded into the DOM.
  • data-indicator: Creates a boolean signal that’s true while a backend request is in flight.
  • data-effect: Runs an expression whenever any signal it references changes. The escape hatch for side effects.

Datastar Expressions

  • Expression: A string in a data-* attribute that Datastar evaluates. It’s JavaScript-like but runs in a sandboxed Function() constructor. You can use operators, ternaries, method calls on signal values, and semicolons to chain statements.
  • Action: A helper function prefixed with @ that you call inside expressions. Actions are Datastar’s way of providing built-in functionality. @get('/endpoint'), @post('/endpoint'), @toggleAll(), @setAll().

Server-Sent Events (SSE)

  • Server-Sent Events: A web standard for unidirectional streaming from server to client over HTTP. The server opens a connection and sends events as plain text. The client receives them as a stream. Unlike WebSockets, SSE is one-way (server → client) and works over regular HTTP.
  • text/event-stream: The MIME type for SSE responses. Each event has an event: line naming the type and data: lines carrying the payload, terminated by two newlines.
  • datastar-patch-elements: An SSE event type that tells Datastar to morph HTML elements into the DOM.
  • datastar-patch-signals: An SSE event type that tells Datastar to update signal values.

DOM Patching

  • Morphing: Datastar’s default strategy for updating the DOM. Instead of blowing away elements and recreating them (like innerHTML), morphing diffs the old and new HTML and applies the minimum changes needed. This preserves event listeners, CSS transitions, focus state, and other DOM state that a naive replacement would destroy.
  • Patching: The general term for adding, updating, or removing elements or signals. You “patch elements” into the DOM and “patch signals” into the signal store.
  • Fat morph: The pattern (encouraged by the Tao of Datastar) of sending large chunks of the DOM tree — even up to the <html> tag — and trusting morphing to efficiently update only what changed. You don’t need to micro-manage which element IDs to swap.

SDKs

  • Server SDK: A library for your backend language (Go, Python, PHP, Ruby, Java, Kotlin, C#, Rust, TypeScript, Clojure) that handles formatting SSE responses, reading signals from requests, and providing helper functions like PatchElements() and PatchSignals(). You don’t need an SDK — the SSE format is simple text — but SDKs eliminate boilerplate.

4. The Distilled Introduction

Installation

Add a single script tag:

<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js"></script>

That’s it. No npm install, no webpack config, no node_modules. The script is ~12 KiB gzipped. For production, self-host the file. Datastar also provides a bundler tool to create custom builds with only the plugins you need.

Your First Reactive Page

Here’s a complete, working Datastar page with no backend:

<!DOCTYPE html>
<html>
<head>
    <script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js"></script>
</head>
<body>
    <div data-signals:name="'world'">
        <input data-bind:name />
        <div data-text="'Hello, ' + $name + '!'"></div>
    </div>
</body>
</html>

Type in the input. The greeting updates instantly. No event handlers, no DOM manipulation, no JavaScript file. data-bind:name creates a signal called $name and two-way binds it to the input. data-text re-evaluates whenever $name changes.

The Core Workflow: Client-Side Reactivity

Declare signals using data-signals or data-bind:

<div data-signals="{count: 0, message: 'hello'}">

React to signals using data-text, data-show, data-class, data-attr:

<span data-text="$count"></span>
<div data-show="$count > 5">You've clicked more than 5 times</div>
<button data-class:active="$count > 0">Active when count > 0</button>
<button data-attr:disabled="$count >= 10">Disabled at 10</button>

Handle events with data-on:

<button data-on:click="$count++">Increment</button>
<button data-on:click="$count = 0">Reset</button>

Derive values with data-computed:

<div data-computed:doubled="$count * 2"
     data-text="'Doubled: ' + $doubled">
</div>

This much gives you Alpine.js-level reactivity. No server needed.

The Core Workflow: Server-Driven Updates

Now the Datastar-specific part. When you need the server to update the page:

Frontend: Use a backend action to send a request:

<button data-on:click="@get('/api/data')">
    Load Data
</button>
<div id="content">Waiting...</div>

Backend (Go example): Respond with SSE events:

sse := datastar.NewSSE(w, r)
sse.PatchElements(`<div id="content">Here's your data!</div>`)

That’s it. The server sent an HTML fragment. Datastar found the element with id="content" in the current DOM, morphed the new content into it, and the page updated. No JSON parsing, no manual DOM manipulation, no client-side template rendering.

The server can also update signals:

sse.PatchSignals([]byte(`{count: 42, message: 'Updated from server'}`))

And it can send multiple events in a single response, because it’s a stream:

sse.PatchElements(`<div id="header">New Header</div>`)
sse.PatchElements(`<div id="sidebar">New Sidebar</div>`)
sse.PatchSignals([]byte(`{loggedIn: true}`))

Sending Signals to the Server

By default, every backend action sends all signals (except local ones starting with _) to the server. For GET requests, they’re sent as a datastar query parameter. For other methods, they’re sent as a JSON body.

<div data-signals="{username: '', email: ''}">
    <input data-bind:username placeholder="Username" />
    <input data-bind:email placeholder="Email" />
    <button data-on:click="@post('/api/register')">Register</button>
</div>

When the button is clicked, Datastar sends {username: "whatever", email: "whatever"} to /api/register. The server reads these, processes the registration, and responds with SSE events to update the page.

Loading Indicators

Use data-indicator to track in-flight requests:

<button data-on:click="@get('/api/slow-endpoint')"
        data-indicator:loading
        data-attr:disabled="$loading">
    Fetch Data
</button>
<div data-show="$loading">Loading...</div>

The $loading signal is true while the request is in flight, false otherwise.

Signal Naming

Hyphenated attribute keys are automatically converted to camelCase:

<input data-bind:first-name />
<!-- Creates signal $firstName -->

Signals can be nested using dot notation:

<div data-signals:form.email="''">
<input data-bind:form.email />
<!-- Access as $form.email in expressions, sent as {form: {email: ""}} -->

The SSE Protocol

Under the hood, the SSE format is dead simple:

event: datastar-patch-elements
data: elements <div id="content">Hello</div>

event: datastar-patch-signals
data: signals {count: 42}

Each event has an event: line and one or more data: lines. Events are separated by two newlines. datastar-patch-elements has options for patching mode (outer, inner, append, prepend, replace, before, after, remove), CSS selectors for targeting, and view transitions. The default — morph by element ID — is correct for 90%+ of cases.

The SDKs format this for you. If you’re working without an SDK, it’s just string concatenation on the server.

Content-Type Negotiation

Datastar is smart about response types. The response’s Content-Type determines what happens:

  • text/event-stream → Processed as SSE events (the full-featured path)
  • text/html → Top-level elements are morphed into the DOM by ID
  • application/json → Merged into signals
  • text/javascript → Executed as JavaScript in the browser

This means simple cases don’t even need SSE. Return text/html from an endpoint and Datastar morphs it in. But SSE is the recommended path because it supports multiple events per response and long-lived streaming connections.

The CQRS Pattern: Real-Time Without Effort

This is the pattern that makes Datastar shine for real-time applications. CQRS (Command Query Responsibility Segregation) means separating reads from writes:

<div id="dashboard" data-init="@get('/api/dashboard/stream')">
    <button data-on:click="@post('/api/dashboard/action')">
        Do Something
    </button>
    <div id="stats">Loading...</div>
</div>

Here’s what happens: data-init fires on page load and opens a long-lived @get() connection to /api/dashboard/stream. That endpoint is a streaming SSE connection — it sends the initial state, then keeps the connection open and sends updates whenever the data changes (because another user acted, a background job finished, a webhook arrived, etc.).

The button fires a separate @post() — a short-lived write request. The server processes the write, updates the database, and the read stream picks up the change and pushes it to all connected clients.

Multi-user real-time collaboration falls out of the architecture naturally. You don’t need WebSockets, you don’t need a pub/sub system on the client, you don’t need socket.io. The server has open SSE connections to every client and pushes updates when state changes. The Go SDK example:

// Read endpoint — long-lived SSE stream
func dashboardStream(w http.ResponseWriter, r *http.Request) {
    sse := datastar.NewSSE(w, r)
    sse.PatchElements(renderDashboard())      // Initial state
    for update := range subscribeToChanges() { // Push updates
        sse.PatchElements(renderDashboard())
    }
}

// Write endpoint — short-lived request, no HTML response needed
func dashboardAction(w http.ResponseWriter, r *http.Request) {
    signals := readSignals(r)
    processAction(signals)
    // The read stream handles pushing the update to all clients
}

Initializing Data on Page Load

data-init runs an expression when an element enters the DOM. The most common use is fetching initial data:

<div id="user-profile" data-init="@get('/api/profile')">
    <div class="skeleton-loader">Loading...</div>
</div>

When the page loads, Datastar fires GET /api/profile. The server responds with SSE events that patch #user-profile, replacing the skeleton loader with real content. You can add a delay with data-init__delay.500ms for staggered loading, and combine with data-indicator for loading states.

Reacting to Signal Changes with data-effect

data-effect is Datastar’s escape hatch for imperative side effects. It runs whenever any signal it references changes:

<!-- Trigger a server fetch when a filter signal changes -->
<div data-effect="@get('/api/items?filter=' + $currentFilter)"></div>

Use data-effect sparingly. If you’re using it to synchronize multiple signals, you’re probably overcomplicating your client-side state. Move the logic to the server.

data-bind Deep Dive

data-bind handles different input types automatically. Text inputs bind to value on input events. Checkboxes get interesting: if you predefine the signal as an array, multiple checkboxes with the same signal collect their values:

<div data-signals:toppings="[]">
    <input type="checkbox" data-bind:toppings value="cheese" />
    <input type="checkbox" data-bind:toppings value="pepperoni" />
    <!-- $toppings becomes ["cheese", "pepperoni"] when both checked -->
</div>

Select elements coerce to the predefined signal type — if the signal starts as a number, the selected value becomes a number, not a string. File inputs automatically base64-encode contents into an array of {name, contents, mime} objects without needing a form element.

You can customize binding behavior with modifiers: __prop binds to a specific property instead of the default, and __event changes which events trigger the sync:

<!-- Custom web component: bind to 'checked' property, sync on 'change' event -->
<my-toggle data-bind:is-active__prop.checked__event.change></my-toggle>

HTTP Methods

All standard HTTP methods are available as actions:

<button data-on:click="@get('/api/items')">List</button>
<button data-on:click="@post('/api/items')">Create</button>
<button data-on:click="@put('/api/items/1')">Update</button>
<button data-on:click="@patch('/api/items/1')">Partial Update</button>
<button data-on:click="@delete('/api/items/1')">Delete</button>

Standard SSE only supports GET. Datastar extends the text/event-stream protocol to work with any HTTP method by using the Fetch API under the hood instead of the browser’s EventSource API.

Web Components Integration

For JavaScript that doesn’t fit in data-* attributes, Datastar encourages web components with a “props down, events up” pattern:

<my-component
    data-attr:src="$someSignal"
    data-on:mycustomevent="$result = evt.detail.value">
</my-component>

The web component receives data via attributes and dispatches custom events. Datastar attributes bind the two together reactively. The component is encapsulated; it doesn’t know or care about Datastar.

5. The Mental Model

Core Idea 1: The Server Is the Brain, the Client Is the Screen

This is the foundational commitment. Your server holds application state, decides what the user should see, and sends HTML. The client is a thin rendering layer that displays what it’s told and forwards user interactions back to the server.

This predicts:

  • You don’t store application state in signals. Signals are for ephemeral UI state (is this dropdown open?) and for collecting user input to send to the server.
  • Your server endpoints don’t return JSON data for the client to render — they return rendered HTML that the client morphs into the DOM.
  • Authorization, validation, and business logic live on the server. The client has no opinion about what the user is allowed to do — the server simply doesn’t send controls the user isn’t authorized to use.
  • When in doubt about where logic should go, the answer is almost always “the server.”

Core Idea 2: Everything Flows Through Two Channels — Patch Elements and Patch Signals

There are only two things Datastar does between server and client: patch HTML elements into the DOM, and patch signal values into the signal store. That’s the entire server→client API.

This predicts:

  • There’s no RPC-style invocation system. No “call this client method.” If you want the client to do something, you either send it HTML to display or signal values to react to.
  • Complex UI updates are just multiple patch events in a single SSE stream. Need to update a header, a sidebar, and a counter simultaneously? Three patch events. Not three separate requests.
  • Real-time features like live dashboards are just long-lived SSE connections that keep sending patches. No special WebSocket setup.
  • The server can do anything at any time by sending patches. Show a notification? Patch a toast element. Update a counter? Patch a signal. Redirect? Patch a script tag that sets window.location.

Core Idea 3: Signals Are Reactive Glue, Not Application State

Signals make the client reactive without JavaScript files. They’re the mechanism that connects data-* attributes to each other. But they’re not where your application state lives.

This predicts:

  • You should use signals sparingly. If you find yourself building complex signal graphs, you’re probably putting too much logic on the client.
  • The default behavior of sending all signals with every request makes sense: the server needs to know what the user has typed/selected/toggled, but the server doesn’t rely on the client to maintain state correctly.
  • Local signals (prefixed with _) exist for things the server genuinely doesn’t need to know about — like whether a local accordion is expanded.

Core Idea 4: Morphing Makes Coarse Updates Cheap

Because Datastar uses Idiomorph (a DOM morphing algorithm) by default, you can send large chunks of HTML and let the morph figure out what changed. You don’t need to carefully craft minimal diffs.

This predicts:

  • The “fat morph” pattern works: send the entire content area, or even the whole page body, and morphing will efficiently update only the changed nodes.
  • You don’t need to worry about preserving input focus, scroll position, or CSS transitions — morphing handles this by matching elements by ID and updating in place.
  • Element IDs matter. Morph matches on ID. If your elements don’t have IDs, morphing can’t match them, and you’ll get replacements instead of updates. Put IDs on any element you want to preserve state across patches.

6. The Architecture in Plain English

What Happens When You Click a Button

Let’s trace through <button data-on:click="@get('/api/data')">Load</button>:

  1. Datastar walks the DOM on page load, finds data-on:click, and attaches a click event listener to the button.

  2. User clicks. The event fires. Datastar evaluates the expression @get('/api/data').

  3. @get() fires a Fetch API request to /api/data. It’s a regular GET request with some special handling:

    • All non-local signals are serialized and sent as a datastar query parameter.
    • A Datastar-Request: true header is added.
    • If there’s a data-indicator on this element or its ancestors, that signal is set to true.
  4. The server processes the request. It reads the signals (if needed), does whatever logic it needs, and writes an SSE response. The response has Content-Type: text/event-stream.

  5. Datastar reads the response as a stream. It parses each SSE event as it arrives. For each datastar-patch-elements event, it takes the HTML, finds the target element in the DOM (by default, matching the top-level element’s ID), and morphs the new content in. For each datastar-patch-signals event, it merges the new values into the signal store.

  6. Reactivity kicks in. Any data-text, data-show, data-class, etc. attributes that reference updated signals re-evaluate and update the DOM.

  7. The stream ends. The indicator signal (if any) is set back to false.

Where State Lives

  • Server: All persistent application state. Database, session, business logic outputs.
  • Signals: Ephemeral UI state and user input. Open/closed toggles, form field values, loading states.
  • DOM: The visible truth. What the user sees. Driven by a combination of server-sent HTML and signal-reactive attributes.

The Fetch-Not-EventSource Trick

Standard SSE uses the browser’s EventSource API, which only supports GET. Datastar doesn’t use EventSource. It uses the Fetch API to make requests of any HTTP method, then manually parses the text/event-stream response. This is what allows @post(), @put(), @delete() to all return SSE streams. The server side is the same — it just writes SSE-formatted text to the response — but the client side is more capable than vanilla SSE.

How Morphing Works

Datastar uses Idiomorph under the hood. The algorithm:

  1. Takes the new HTML and the existing DOM subtree.
  2. Walks both trees simultaneously.
  3. For each element, checks if there’s a matching element (by ID) in the old tree.
  4. If matched: updates attributes, recurses into children.
  5. If not matched: inserts/removes as needed.

The result is that event listeners, CSS animation states, <input> focus, scroll positions — all the fragile DOM state that innerHTML would destroy — are preserved.

The Attribute Processing Pipeline

When Datastar initializes (or when new elements are patched into the DOM), it walks the DOM tree top-down. For each element, it processes data-* attributes in a specific order:

  1. data-signals — declares signals (must happen before anything that reads them)
  2. data-computed — creates derived signals
  3. data-bind — sets up two-way binding
  4. Other reactive attributes (data-text, data-show, data-class, data-attr) — these subscribe to signals and update the DOM reactively
  5. data-on — attaches event listeners
  6. data-init — runs initialization expressions

This ordering matters. If you reference a signal in data-text but declare it with data-signals on a child element, the signal might not exist yet when data-text evaluates. Put signal declarations at or above the level where they’re used.

The Modifier System

Modifiers are Datastar’s way of adding behavior to attributes without needing new attributes. They use double underscores and dots:

data-on:click__debounce.500ms__once
         ^       ^       ^      ^
         |       |       |      modifier 2: only fire once
         |       |       tag: 500 milliseconds
         |       modifier 1: debounce
         event name

The modifier system is one of Datastar’s most ergonomic features. The most useful modifiers on data-on:

  • __debounce.{time} — wait until the event stops firing for {time} before running the expression. Essential for search-as-you-type.
  • __throttle.{time} — run at most once per {time} interval. Good for scroll and resize handlers.
  • __once — only fire the handler once, then remove it.
  • __delay.{time} — wait {time} before running. Useful for hover menus that shouldn’t trigger instantly.
  • __window — listen on window instead of the element. Needed for custom events that bubble to window.
  • __outside — trigger when the event occurs outside the element. Perfect for “click outside to close” dropdowns.

Casing modifiers exist on multiple attributes:

  • __case.camel (default for signals), __case.kebab (default for classes/events), __case.snake, __case.pascal

These let you match whatever naming convention your backend or CSS framework uses.

How Data Flows in a Typical Page

Here’s the complete data flow for a page with a search input and a CQRS stream:

Page Load

    ├─ Datastar walks DOM, processes data-* attributes
    │  ├─ data-signals declares initial signals
    │  ├─ data-bind attaches input listeners
    │  ├─ data-text/data-show subscribe to signals
    │  └─ data-init fires @get('/stream') → opens SSE connection

    ├─ Server sends initial state via SSE
    │  ├─ datastar-patch-elements → morph into DOM
    │  └─ datastar-patch-signals → merge into signal store

    │  (connection stays open for push updates)

User Types in Search Input

    ├─ data-bind updates $query signal
    ├─ data-on:input__debounce.300ms fires @get('/search')
    │  ├─ All non-local signals sent as query params
    │  └─ data-indicator sets $searching = true

    ├─ Server reads signals, queries database, returns SSE
    │  └─ datastar-patch-elements with search results

    └─ Datastar morphs results into DOM
       └─ data-indicator sets $searching = false

7. The Things That Bite You

Gotcha 1: The 6-Connection Limit (HTTP/1.1)

If your server uses HTTP/1.1, browsers limit you to 6 concurrent connections per domain. SSE connections are long-lived, so each one consumes a connection slot. Open a long-lived @get() (like for a real-time dashboard) and a few tabs, and you’ve exhausted your pool. New requests queue.

Why it happens: This is a browser limitation, not a Datastar limitation. It applies to all SSE under HTTP/1.1.

How to handle it: Use HTTP/2. It multiplexes all streams over a single TCP connection, with a default of 100 concurrent streams. This is effectively a non-issue on HTTP/2, which you should be using anyway.

Gotcha 2: CSP Requires unsafe-eval

Datastar evaluates expressions using Function() constructors. If you have a Content Security Policy, you need script-src 'unsafe-eval'. This weakens your CSP because it allows string-to-code evaluation.

Why it happens: The Function() constructor is how Datastar turns the string in data-on:click="$count++" into executable code. There’s no compile step to pre-process these.

How to handle it: Accept the trade-off for Datastar projects. Ensure you escape all user input rigorously to prevent XSS (which is your primary concern regardless). The data-ignore attribute can protect subtrees you can’t escape. If strict CSP without unsafe-eval is a hard requirement for your organization, Datastar may not be the right choice.

Gotcha 3: Signals Are Visible in the DOM and Modifiable by Users

Signal values appear in the HTML source and can be modified via browser dev tools before being sent to the server.

Why it happens: Signals are client-side state stored in the DOM. The browser is an untrusted environment.

How to handle it: Treat signals exactly like form input — validate everything on the server. Never put secrets, auth tokens, or sensitive data in signals. The server should never trust signal values for authorization or business-critical decisions.

Gotcha 4: All Signals Are Sent With Every Request (By Default)

When you call @post('/endpoint'), Datastar serializes every non-local signal and sends it. If you have 50 signals, they all go. This can be surprising if you’re used to frameworks that send only what you explicitly include.

Why it happens: Datastar is designed so the server always has full context. The philosophy is that the server needs to know the entire frontend state to make correct decisions.

How to handle it: Keep your signal count low. Use local signals ($_prefix) for things the server doesn’t need. If you must filter, use the filterSignals option: @post('/endpoint', {filterSignals: {include: /^form\./}}). But the Datastar team recommends against partial sends as a default practice.

Gotcha 5: Element IDs Are Critical for Morphing

If you send <div>Hello</div> without an ID, morphing can’t match it to an existing element. It’ll be inserted or replaced rather than merged.

Why it happens: Idiomorph matches elements by ID. Without IDs, it falls back to positional matching, which is fragile and loses state.

How to handle it: Always put IDs on elements you’re patching from the server. Also put IDs on elements within patched subtrees that have state you want to preserve (inputs with focus, elements with CSS transitions, etc.).

Gotcha 6: Expressions Are Not Full JavaScript

Semicolons are required between statements (line breaks alone don’t separate statements). You can’t use let/const/var, for loops, or if statements directly. Datastar expressions are evaluated as function bodies, not script blocks. For complex logic, extract to external functions or web components.

Why it happens: Expressions run in a sandboxed Function() constructor, which is intentionally limited.

How to handle it: Keep expressions simple. One or two statements max. If you’re writing logic that needs conditionals or loops, that logic belongs on the server or in a web component.

Gotcha 7: SSE Reconnection Behavior

If a long-lived SSE connection drops (network hiccup, server restart), Datastar’s @get() with default settings will try to reconnect. But the retry behavior and what happens to missed events during the disconnection depends on your configuration. There’s no built-in event replay mechanism.

Why it happens: SSE has a Last-Event-ID mechanism for resumption, but most Datastar SDK implementations don’t use it by default.

How to handle it: For critical real-time data, implement idempotent endpoints that can reconstruct full state on reconnection rather than relying on incremental patches. The CQRS pattern helps here — the read endpoint sends the full current state when connected.

Gotcha 8: Some Features Are Pro-Only

Datastar v1.0 moved several features behind the Datastar Pro paywall, including data-persist (signal persistence to localStorage), data-replace-url (browser URL bar updates), data-scroll-into-view, data-view-transition, data-animate, and data-query-string. If you’re coming from a tutorial that uses beta-era Datastar, some attributes may not work in the open-source bundle.

Why it happens: The Datastar team chose an open-core business model to fund development.

How to handle it: Check the reference docs — Pro attributes are clearly labeled. For URL manipulation, use standard JavaScript (window.history.pushState). For view transitions, use the CSS View Transition API directly. A community fork called “dataSPA” restores some of these features to the open-source version, though it may not stay in sync with mainline development.

Gotcha 9: Attribute Evaluation Order Within an Element

Datastar processes attributes in a defined order, not necessarily the order they appear in your HTML. data-signals is processed before data-bind, which is before data-text, which is before data-on. This means writing data-on:click="$foo" and data-signals:foo="1" on the same element works — data-signals is processed first regardless of HTML attribute order.

But across different elements, processing is top-down in DOM order. A signal declared on a child element isn’t available to a parent’s data-text during initial processing.

How to handle it: Declare signals at or above the level where they’re used. When in doubt, put data-signals on a wrapper div high in the tree.

8. The Judgment Calls

1. Datastar vs. htmx + Alpine.js

Use Datastar when: you want a single library, you value SSE for real-time streaming, you want signals and HTML patching unified, you’re starting a new project and can think in the Datastar way.

Use htmx + Alpine when: you’re already invested in the htmx ecosystem, you have existing htmx code, you need the broader community and more third-party resources, or you need htmx’s broader set of triggers and swap strategies.

The signal: If your app needs real-time features (live updates, collaborative editing, dashboards), Datastar’s SSE-first architecture gives you that for free. If your app is mostly traditional CRUD forms with occasional sprinkles of interactivity, htmx is battle-tested and has a larger community.

2. When to Use Signals vs. Server State

Keep it in a signal when: it’s purely UI state (is this dropdown open? what has the user typed so far? is this panel expanded?), or when you need instant feedback without a server round-trip (showing character count on a text input).

Move it to the server when: it affects what other users see, it needs to persist, it involves business logic, or it determines what actions are available.

The signal: If you’re writing data-computed expressions that span more than one line, you’re probably building application logic on the client. Stop and move it to the server.

3. SSE vs. WebSockets

Use SSE (Datastar’s default) when: data flows primarily server→client with occasional client→server requests. This covers dashboards, notifications, live feeds, search results, form submissions — the vast majority of web apps.

Use WebSockets when: you need persistent bidirectional streaming. Real-time multiplayer games, collaborative text editors with OT/CRDT, or chat applications where both sides send high-frequency messages.

The signal: Can you model your interaction as “user does action → server processes → server sends update”? If yes, SSE is sufficient. Datastar handles the client→server part with regular HTTP requests, and the server→client part with SSE streams.

4. Fat Morph vs. Fine-Grained Patches

Use fat morph when: you’re unsure what changed, or multiple parts of a page depend on the same state. Send the whole content area. Morphing is fast and handles it.

Use fine-grained patches when: you have a specific element to update and bandwidth matters (mobile, high-frequency updates), or when the full subtree is very large.

The signal: Start with fat morph. Only optimize to fine-grained patches if profiling shows it matters. Fat morph with Brotli compression can achieve 200:1 ratios, making even large HTML chunks tiny on the wire.

5. When to Reach for JavaScript

Stay in data-* attributes when: the logic is one or two expressions — a toggle, a format, a conditional show/hide, a backend request.

Use external JavaScript/web components when: you need async operations, complex DOM manipulation, third-party library integration, animations, or canvas/WebGL. Extract the JavaScript into a web component with the “props down, events up” pattern.

The signal: If you’re chaining more than 2-3 semicolons in a Datastar expression, extract to a function or web component.

6. Form Handling: Signals vs. FormData

Use signals (default) when: your forms are manageable in size and you want the server to have full context. Signals are sent as JSON, which handles nested structures naturally.

Use contentType: 'form' when: you need multipart/form-data for file uploads, or you’re integrating with endpoints that expect form-encoded data.

The signal: For most CRUD forms, signals are simpler. Switch to form content type when you need file uploads.

7. Compression: Always

Enable Brotli compression on your server for SSE responses. The Datastar community reports compression ratios of 200:1 for HTML streams. This is essentially free performance and makes fat morph viable even on constrained connections.

8. Single Page App vs. Multi-Page App

Use MPA (traditional page navigation) when: your pages are largely independent, SEO matters, or the mental model of “each URL is a resource” maps cleanly to your domain.

Use SPA-like behavior with Datastar when: you want fluid transitions within a section of your app, you’re building a dashboard or tool where full-page reloads feel jarring, or you’re using CQRS with a persistent read connection.

The signal: Datastar’s creators recommend using <a> tags for navigation and letting the browser handle history. If you’re fighting the browser, you’re overcomplicating it.

9. Optimistic Updates: Don’t

The Datastar team has an explicit opinion here: don’t do optimistic updates. An optimistic update shows the user a success state before the server confirms it, then awkwardly rolls back if the server rejects it.

Use loading indicators instead. Show the user that something is happening (data-indicator), and only display the result when the server confirms. With SSE streaming and Brotli compression, the latency between “user clicks” and “server response arrives” is typically under 100ms for well-configured servers. That’s fast enough that a loading state feels instant.

The signal: If your round-trip latency is so high that you feel compelled to use optimistic updates, the problem is your server performance, not your UI strategy.

10. Choosing a Backend Language

Datastar has official SDKs for Go, Python, PHP, Ruby, Java, Kotlin, C#, Rust, TypeScript, and Clojure. The SSE protocol is simple enough that you can implement it without an SDK in any language.

Go is the community’s center of gravity — the creator uses it, the examples use it, and Go’s goroutine-per-connection model is a natural fit for long-lived SSE streams. If you’re starting fresh and have no language preference, Go with templ templates is the well-trodden path.

Python/PHP/Ruby work well but need attention to concurrency. Long-lived SSE connections hold a thread/process. With Python, use an async framework (Sanic, FastAPI, Starlette). With PHP, be aware that traditional PHP-FPM setups aren’t designed for long-lived connections — you may need Swoole or ReactPHP. With Ruby, Rack streaming works but consider Puma’s threading model.

The signal: Pick the language you know. The SDK smooths over the SSE formatting. The only real consideration is whether your runtime handles many concurrent long-lived connections efficiently.

9. The Attributes and Actions That Actually Matter

Attributes You’ll Use Every Day

AttributeWhat It DoesWhen to Reach for It
data-signalsDeclare signals with initial valuesPage load, setting up initial state
data-bindTwo-way bind input ↔ signalEvery form input
data-textSet text content from expressionDisplaying dynamic values
data-onAttach event handlersClicks, keypresses, custom events
data-showToggle visibilityConditional UI sections
data-classToggle CSS classesStyling based on state
data-indicatorTrack request in-flight stateLoading spinners, disabled buttons

Attributes You’ll Use Weekly

AttributeWhat It DoesWhen to Reach for It
data-attrBind any HTML attributedisabled, aria-*, href
data-computedDerived read-only signalAvoiding repeated expressions
data-initRun expression on element loadInitial data fetch, setup
data-effectSide effect on signal changeSyncing external state
data-ignore-morphExclude from morph updatesThird-party widgets, video players
data-refReference an element in expressionsDirect DOM access

Actions You’ll Use Every Day

@get('/url')       — Fetch data from server
@post('/url')      — Send data to server
@put('/url')       — Full update
@patch('/url')     — Partial update
@delete('/url')    — Delete resource

Actions You’ll Use When Needed

@toggleAll({include: /^menu\./})  — Toggle all matching boolean signals
@setAll(false, {include: /^menu\./})  — Set all matching signals to a value
@peek(() => $signal)  — Read a signal without subscribing to changes

Modifier Patterns

Modifiers use the __ (double underscore) syntax:

<!-- Debounce input to avoid hammering the server -->
<input data-bind:query data-on:input__debounce.500ms="@get('/search')" />

<!-- Only trigger once -->
<button data-on:click__once="@post('/onboarding/complete')">Got it</button>

<!-- Throttle scroll events -->
<div data-on:scroll__throttle.100ms="handleScroll()"></div>

10. How It Breaks

Failure Mode 1: Morph Doesn’t Update What You Expected

Symptoms: You send an element from the server but the DOM doesn’t change, or changes in unexpected places.

Root cause: The element you sent doesn’t have an ID, or its ID doesn’t match anything in the current DOM.

Diagnose: Open browser dev tools, inspect the element you’re trying to update. Check that it has an id. Check that your SSE response’s element has the same id. Use the Datastar Inspector (Pro) to watch incoming events.

Fix: Add IDs to your elements. Use deterministic, meaningful IDs in your templates.

Failure Mode 2: Signal Updates Don’t Trigger Reactivity

Symptoms: You change a signal value but data-text or data-show doesn’t update.

Root cause: You’re modifying the signal outside of Datastar’s reactivity system (e.g., in vanilla JS), or the expression has a typo, or you forgot the $ prefix.

Diagnose: Use <pre data-json-signals></pre> to dump all signal values live. Check if the signal value actually changed.

Fix: Only modify signals through Datastar expressions, data-bind, or server-sent patch-signals events.

Failure Mode 3: SSE Connection Drops Silently

Symptoms: Live updates stop. The page appears frozen but there are no errors.

Root cause: Network interruption, server restart, proxy timeout, or the 6-connection limit under HTTP/1.1.

Diagnose: Check browser dev tools Network tab — is the SSE connection still open? Check the server logs. Check if nginx/proxy is configured for long-lived connections (disable buffering, increase timeouts).

Fix: Configure your reverse proxy for SSE (set proxy_buffering off in nginx, increase proxy_read_timeout). Use HTTP/2. For critical connections, implement heartbeats — the server periodically sends a comment line (: heartbeat) to keep the connection alive.

Failure Mode 4: Expressions Throw Errors Silently

Symptoms: An attribute doesn’t seem to do anything. No visible error.

Root cause: Datastar evaluates expressions in a Function() constructor. If the expression has a JS error, it may fail silently or log to the console.

Diagnose: Open browser console. Datastar logs expression evaluation errors there.

Fix: Check expression syntax. Remember: semicolons required between statements, $ prefix required for signals, actions need @ prefix.

Failure Mode 5: Reverse Proxy Buffers or Kills SSE Connections

Symptoms: Responses arrive all at once instead of streaming, or connections drop after 60 seconds.

Root cause: Nginx (and many other reverse proxies) buffers responses by default and has a default read timeout. SSE requires unbuffered, long-lived connections.

Diagnose: Check if responses arrive all at once (buffering) vs. gradually (streaming). Check proxy access logs for timeout-related closes.

Fix: Configure your proxy explicitly for SSE:

location /api/ {
    proxy_pass http://backend;
    proxy_buffering off;           # Critical — don't buffer SSE
    proxy_cache off;               # Don't cache SSE streams
    proxy_read_timeout 86400s;     # 24 hours, or whatever makes sense
    proxy_set_header Connection ''; # Prevent connection: close
    proxy_http_version 1.1;        # Or use HTTP/2
}

This is the most common production issue people hit with Datastar. It’s not a Datastar problem — it’s an SSE-with-proxies problem. Every SSE-based system (including htmx’s SSE extension) hits this.

Failure Mode 6: Request Cancellation Confusion

Symptoms: Clicking a button rapidly fires multiple requests, or a new request doesn’t cancel the old one.

Root cause: By default, Datastar cancels an in-flight request if you fire another one from the same element. But if you fire from different elements, both run concurrently.

Diagnose: Open Network tab and click rapidly. Check if previous requests are being cancelled (they should show as “cancelled” in the Network tab).

Fix: For @get() requests, Datastar’s default behavior handles this — the connection closes when hidden (e.g., navigating away). For other methods, openWhenHidden defaults to true, keeping them alive. If you need to prevent concurrent requests explicitly, use an indicator signal to disable the button while a request is in flight:

<button data-on:click="@post('/api/action')"
        data-indicator:_busy
        data-attr:disabled="$_busy">
    Submit
</button>

General Debugging Workflow

  1. Open browser dev tools Console — check for Datastar errors.
  2. Open Network tab — check if requests are being sent and responses received.
  3. Add <pre data-json-signals></pre> to your page to see live signal state.
  4. Check your server’s response body — is it valid SSE format? (event line, data lines, double newline).
  5. If using a proxy, verify SSE-compatible configuration (no buffering, adequate timeouts).
  6. Install the Datastar Inspector (Pro) for real-time event monitoring.

11. The Taste Test

Good Datastar Code

<!-- Signals are few, purposeful, and named clearly -->
<div data-signals="{_menuOpen: false}">
    <button data-on:click="$_menuOpen = !$_menuOpen"
            data-attr:aria-expanded="$_menuOpen">
        Menu
    </button>
    <nav data-show="$_menuOpen" style="display:none">
        <!-- Server renders menu items; this just toggles visibility -->
    </nav>
</div>

<!-- Form collects user input, server does all the work -->
<div data-signals:form.name="''" data-signals:form.email="''">
    <input data-bind:form.name placeholder="Name" />
    <input data-bind:form.email placeholder="Email" />
    <button data-on:click="@post('/api/subscribe')"
            data-indicator:_submitting
            data-attr:disabled="$_submitting">
        Subscribe
    </button>
    <div id="subscribe-result"></div>
</div>

Notice: local signal $_menuOpen for UI-only state, namespaced form signals, indicator signal for loading state, server decides what to render in #subscribe-result, accessibility via aria-expanded.

Bad Datastar Code

<!-- Too much logic on the client -->
<div data-signals="{
    items: [],
    filteredItems: [],
    sortOrder: 'asc',
    searchQuery: '',
    page: 1,
    pageSize: 10,
    totalPages: 0
}">
    <input data-bind:searchQuery
           data-on:input="
               $filteredItems = $items.filter(i => i.name.includes($searchQuery));
               $totalPages = Math.ceil($filteredItems.length / $pageSize);
               $page = 1
           " />

This is building a client-side data grid. The server should do the filtering, pagination, and sorting — then patch the result HTML into the DOM. The client should send the search query and page number; the server should return the rendered table.

Side-by-Side: Todo App

beginner approach (too much client logic):

<div data-signals="{todos: [], newTodo: '', editingId: null, editText: ''}">
    <input data-bind:newTodo />
    <button data-on:click="
        $todos = [...$todos, {id: Date.now(), text: $newTodo, done: false}];
        $newTodo = ''
    ">Add</button>
    <!-- Then iterating $todos in expressions, toggling done states client-side... -->
</div>

The todos live in a signal. Adding, editing, completing — all client-side. The server never knows what’s happening. If the user refreshes, everything’s gone.

experienced approach (server owns state):

<div data-signals:newTodo="''">
    <input data-bind:newTodo placeholder="What needs doing?" />
    <button data-on:click="@post('/api/todos')"
            data-indicator:_adding
            data-attr:disabled="$_adding || $newTodo == ''">
        Add
    </button>
    <div id="todo-list" data-init="@get('/api/todos/stream')">
        <!-- Server renders the todo list and streams updates -->
    </div>
</div>

The client has one signal: what the user is typing. Everything else — the list of todos, their completion state, their order — is owned by the server. The data-init opens an SSE stream for live updates. Adding a todo is a @post() that tells the server to create it; the SSE stream pushes the updated list to all connected clients.

Red Flags in Code Review

  • More than 5-6 non-local signals → probably managing too much state on the client
  • data-computed chains more than 2 deep → application logic creeping to the frontend
  • No data-indicator on buttons that trigger backend requests → UX will feel broken on slow connections
  • Elements being patched from the server without IDs → morphing will be unreliable
  • data-on:click expressions longer than ~80 characters → extract to a function
  • No style="display:none" on elements with data-show → flash of unwanted content on page load
  • Signals containing sensitive data (tokens, passwords) → security vulnerability
  • Trying to use localStorage or sessionStorage for signal persistence → fight the framework; let the server own state

12. Where to Go Deeper

  1. The Official Guidehttps://data-star.dev/guide — Read the entire guide start to finish. It’s short, well-written, and has interactive demos. The single best starting resource.

  2. The Tao of Datastarhttps://data-star.dev/guide/the_tao_of_datastar — The core team’s opinionated guide to how Datastar should be used. Read this before you write your first real app. Re-read it after a month.

  3. The Referencehttps://data-star.dev/reference — Complete reference for all attributes, actions, SSE events, and SDKs. Bookmark this.

  4. The Examples Pagehttps://data-star.dev/examples — Working, interactive examples covering common patterns. Study these before building from scratch.

  5. “Why Another Framework?”https://data-star.dev/essays/why_another_framework — The creator explains what he found lacking in htmx and Alpine.js and what design decisions Datastar makes differently. Essential for understanding the philosophy.

  6. “Event Streams All the Way Down”https://data-star.dev/essays/event_streams_all_the_way_down — Why SSE was chosen as the transport, and how Datastar extends it beyond GET-only.

  7. The Datastar Discord — The community is active and the creator is responsive. For questions the docs don’t answer. Find the link on the official site.

  8. Hypermedia Systems (book) — https://hypermedia.systems — Not Datastar-specific, but the definitive guide to the hypermedia philosophy that Datastar embodies. Read this to understand why backend-driven UI works, not just how Datastar implements it.


The ideas are mine. The writing is AI assisted