Writing /

Datastar first impressions, coming from ReactDatastar first impressions, coming from React

Published on ⋅ 25min read

As an avid web developer, I strive to stay informed about the latest changes in the web. Some time ago, while experimenting with HTMX and exploring its philosophy about hypermedia applications, I discovered Datastar, a lightweight (~10KB gzipped) hypermedia framework that enables backend-, and frontend, reactivity with a declarative API.

Datastar immediately piqued my curiosity, for a variety of reasons. What follows is a write-up of my thoughts and impressions using Datastar for the first time, after developing with React professionally for the past decade.

This is not intended as a tutorial.

# The goal

I want to build a simple spellchecker component where I can:

  1. input some text;
  2. pass it into a checking function on the server, which corrects my spelling; and,
  3. updates the text on the client.

It should also let me know how many mistakes were corrected. I'll assume the language is always English for now. Nothing too fancy.

I know how I'd do this with React, but where do I begin with Datastar...? Unlike React, Datastar is not designed for Single Page Applications (SPA), but is meant to be server-driven, so it looks like I'll start by building a more traditional Multi-Page Application (MPA). I'll need to write and manage my own server; I guess I'm gonna need to pick a language for that, then.

# I like Javascript / Typescript

Awesome, it appears that I can keep using it! One rather unique feature of Datastar is that it is backend-agnostic; it doesn't prescribe any particular backend language, so I can use whatever I'm most comfortable with.

Basically, all I really need to use Datastar is to have a web server that is able to receive HTTP requests and send back responses. I'll probably need some API endpoints to handle functionality that can't be done on the client. That doesn't sound too different to most of the React applications I've built, to be honest. I think I'll use the Hono web application framework for my server, primarily since I've used it before and it was pretty simple to work with.

Oh, cool, Datastar even has a variety of language-based server SDKs that can help me interact with Datastar's API more easily, including one for Javascript / Typescript! I'll have to check that out. I wonder how it'll mesh with Hono..?

As for the client-side code, since Datastar is a hypermedia-focused framework, it looks like I'll mostly be writing HTML templates for my server. And that's about it..? No client-side code in the same vein as React; all I really need to do to get up and running with Datastar is to include its JS script on my page. Couldn't be easier!

It's actually so nice not to have to first worry about setting up some build tool to transpile my code, as there's no need to generate a client side bundle for my runtime code to get started.

I'll write my server in Typescript, which I prefer to use over Javascript these days. This often does require compilation, but I think I'll run Typescript natively via Node so I get a feel for how much of a benefit "no build step" is for the developer experience.

Reading the docs some more, the Datastar API seems to include a few magic helpers, like @post(), @toggleAll() and some $-prefixed values — oh, those are "signals", whatever that means — but they all just go in my HTML as attributes. It also seems like within those data attribute strings I'm mostly just writing Javascript, but it also embeds a small domain-specific language (DSL) in those Javascript expressions?

First thought: eww... Inline Javascript+DSL within HTML attributes? Why does that makes me feel kinda icky? Perhaps that's my own prejudice seeping through after years of indoctrination about "separation of concerns"? Okay, I'm going to try ignore that feeling for now, give this a chance and see how it goes...

# I like using JSX

Oh, cool, I can still use it for templating! Datastar doesn't enforce any particular method for how I generate my HTML content, it just expects that I will mostly be sending HTML down to the client.

I'm really familiar with JSX as a HTML templating language after a decade of React, I just need to remember that my JSX components will only be server-side rendered (SSR) and thus will only output the HTML. After so many years of React, I definitely have a habit of automatically including client-only code within my JSX components, which then gets ignored during SSR, so I'd better be wary of that.

Another thing I need to remember is that, unlike React, Datastar's frontend API is a set consisting entirely of data-* attributes that I add to HTML elements. The Datastar attributes seem pretty obvious to me as to what they do: I bet data-show toggles the visibility of an item based on the result of some boolean expression, and data-class can probably apply some classes to an element when a condition is true, much like using the classnames package with React. There are some, like data-ignore-morph (what does "morph" even mean?) and data-on-signal-patch, which are not immediately obvious to me... but I guess I'll worry about those later, if they become relevant.

Hmm, I do kinda miss the LSP tooling though... My editor isn't suggesting anything since it expects data attributes are just plain strings, not actual Javascript expressions... but on the other hand, based on what I've seen in the Datastar docs it looks like I barely need to write any Javascript in those attributes anyway... Let's see how it goes, I can probably manage without LSP suggestions for now.

# I like thinking in request / response(s)

I keep hearing about Server Sent Events (SSE) and pushing updates to the page, but that all sounds a bit daunting and not at all what I'm used to... I want to make a fetch request when my input loses focus, process the text on my server (I don't want to have to send a big dictionary down to the client to perform the spellchecking), get the response and then update my component state accordingly. Can I do that with Datastar? Its website claims so.

I guess to start with I need to figure out how to handle user events for input elements. I know that in React, I'd write something like:

1<input type="text" onBlur={onBlurHandler} />

Okay, with Datastar it also seems to follow a similar, logical pattern. Instead, I would need to use the data-on attribute to listen for the blur event. So I'd write:

1<input type="text" data-on:blur="@post('/spellcheck')" />

Oh, but hey, that's actually pretty neat!

With React, I would have defined onBlurHandler somewhere but since it is (traditionally) a client-focused library, it always felt to me like React didn't provide much support for making async requests to the server. For example, I would be responsible for writing the code in onBlurHandler to make the fetch request, parse the response, deal with error handling, manage retries with suitable backoffs, and then map the response to the relevant component state to trigger a re-render. Or, I may have pulled in some other third-party dependency to help with all that...

However, as Datastar is meant to be backend-driven and expects you to be making requests to the server, the backend actions are first-class components of its API; it handles most of that additional functionality for me automatically! All I have to do is specify the @post action with the server endpoint, and have that return the HTML I want to be shown; Datastar will do the rest.

That seems pretty simple.


In order to really embrace Datastar and its hypermedia focus, I think that I should mostly be sending HTML responses for my endpoints. Datastar will take any standard text/html response and, by default, morph that HTML into the page; which means it will preserve any existing elements and apply changes from the response to those elements, or create new elements that are not yet in the DOM. All I need to do is make sure I have specified some id attributes on landmark elements so they can be matched and morphed correctly.

Let's add an id to our input, then, and create our new /spellcheck endpoint returning some HTML:

 1app.post("/spellcheck", async (c) => {
 2  c.html(
 3    <input
 4      id="text"
 5      type="text"
 6      data-on:blur="@post('/spellcheck')"
 7      value="foo"
 8    />
 9  )
10});

Cool, yeah, that works! When the text input loses focus, it makes a request to /spellcheck, which returns the input back with a value of foo. I guess I don't have to worry about knowing how SSE works yet, after all, but SSE does sounds handy so I'll bookmark it to read about later.

Now, how can I get the text the user had already entered into the input up to the server to run it through my spellchecking function..?

I suppose I can always use traditional HTML forms to send the input state up to the server, or write a manual fetch call in my blur handler expression, but I like the convenience of the Datastar @post action. There must be a way to include data with that request...

# I'm used to having state on the client

So, "reactive signals" seem to be the way I manage state on the client in Datastar. My understanding of signals currently is that they are like global variables that can trigger things (i.e. update properties, or re-run Datastar expressions) to "react" when they change. This seems kinda similar to me as props or state variables in React, but based on fine-grained usage, rather than coupled to a specific component.

For example, if I have the data-text="$foo" attribute on an element, any time the foo signal changes (regardless of what changed it), the text of that element will also change accordingly, but the rest of the element will not "re-render" — it's just the text that updates.

When using the Datastar backend actions, like @post, all signals will be included with the request that's sent to the server, by default. This seems to be the preferred way to get data back up to the server. I'm going to need to attach a signal to my <input> somehow...

Unlike React, where I would add a useState hook at the top of my JSX component, I need to add a data-signals attribute to an element in the DOM that occurs before I use that signal, or specify a "signal creating" attribute, such as data-bind, before I specify an attribute that reads the signal's value. Otherwise, when Datastar runs an expression that references a reactive signal that doesn't exist, it will initialise that signal with an empty string as a placeholder. I imagine that could have some undesirable consequences, I might want to keep an eye out for that...

Hmm... in all my years of web development, the order of attributes in my HTML has never mattered. This is probably going to trip me up more than once.

I guess it could kinda make sense to me if I think about it like this: HTML documents are parsed sequentially, so how could a signal's value be used if the signal hadn't been defined yet? No idea what the real reason is, but that might help me remember this particular API quirk.

Looking at the docs, it appears data-signals will just create bare signals, whereas data-bind will not only create a signal, but also attaches change and input event handlers to the element so that the signal automatically updates when the input's value changes. That reminds me somewhat of controlled inputs in React...

Let's adjust the HTML to use data-bind to create a text signal on my <input> so that my /spellcheck endpoint will receive some data when I @post to it:

1<input
2  id="text"
3  type="text"
4  data-bind:text
5  data-on:blur="@post('/spellcheck');"
6/>

Huh, now I've bound a signal to my <input>, when Datastar receives a response and morphs the HTML, my user input remains the same in the input field, even though the HTML fragment I returned didn't contain any value for that element. That's kinda neat, I don't have to worry about clobbering my client state, but how can I clear it then, or change the input's value programmatically...?

With React, I could just call my setState function to change the value after receiving the response from the server. Can I do something similar with Datastar?

Ah yes, it seems like most Datastar attributes can be any number of Javascript statements, as if I were writing a function inline. Therefore I could manually reset the value of text after calling @post; I just need to ensure the statements are separated by a semicolon so that, when Datastar parses the expression, it knows that they are distinct statements despite being on "the same line".

1<input
2  id="text"
3  type="text"
4  data-bind:text
5  data-on:blur="@post('/spellcheck'); $text = '';"
6/>

Yes, that clears the input! But it's not really what I want to do...I don't want to reset my input when it loses focus, but rather update it with the spell-checked value from the server. I tried setting the value attribute of my input when, but it didn't override the signal's value. According to the docs, when using data-bind, the value attribute will only set the default value for the signal that is bound to the input.

I know exactly how I'd do this in React — it would be so simple if my API could just return some JSON with the new text instead of HTML...

# I'm used to my APIs returning JSON

Oh, it seems that with Datastar I can also send new values for my signals from the server! There's a SSE event to patch signals, but I'm still not comfortable using SSE yet... Oh, but hey, it looks like any application/json response is automatically inferred as a signal update. That's easy; I just need to ensure the JSON key in my response matches my signal name.

Let's refactor my endpoint to return some JSON instead:

1app.post("/spellcheck", async (c) => {
2  const body = await c.req.json();
3  const { text } = spellcheck(body?.text);
4  c.json({ text });
5});

Great! Now I can update the input by having the server send the spell-checked text.

I'd also like to display a notification to the user with how many mistakes were corrected. I know, I can just send that down as a new signal value too!

1{
2  "text": "My favourite colour is red.",
3  "count": 2,
4}

Easy! That feels very familiar and is probably the exact same JSON payload as I'd use for my React component. I'll just need to update my component HTML now to use a count signal.

# I'm used to having logic in my JSX components

One of the aspects that always appealed to me with React is its emphasis for unidirectional data flow. Therefore, I'm very comfortable writing JSX components where I take some data as props and conditionally render elements based on those values:

 1const SpellcheckComponent = ({ count = 0 }) => (
 2  <div>
 3    <input
 4      type="text"
 5      data-bind:text
 6      data-on:blur="@post('/spellcheck')"
 7    />
 8    {count > 0 && (
 9      <small>
10        There were {count} spelling mistakes corrected.
11      </small>
12    )}
13  </div>
14);

It makes this component feel simple to reason about. However, with Datastar, since there is no client-side rendering of my JSX happening, this will only output the <small> element if count is greater than 0 when the server initially renders it... Since the user will never have submitted any text at that point, even if I were to update a count signal, there's nothing tying the count prop to the count signal. Thus, it will currently never include the <small> element in the DOM.

All right, let's try changing my component to use a count signal instead of passing it as a prop...

 1const SpellcheckComponent = () => (
 2  <div>
 3    <input
 4      type="text"
 5      data-bind:text
 6      data-on:blur="@post('/spellcheck')"
 7    />
 8    <small
 9      data-signals:count="0"
10      data-show="$count > 0"
11      style="display: none;"
12    >
13      There were <span data-text="$count"></span> spelling mistakes corrected.
14    </small>
15  </div>
16);

Yuck! That's quite a bit more verbose than my initial component. There are a few additional attributes that I have to reason about, and I don't like that I had to add an extra <span> so I could render the count with data-text; it felt way cleaner before when I was just doing {count} with JSX.

I also had to add some inline styling to hide the <small> element by default, which I really didn't want to have to do, because I noticed a flash of content when the HTML rendered before Datastar had loaded and could hide the element. That's also pretty gross.

Okay, marginal improvements(?) discovered:

  • I can use string template literals to avoid the use of that extra <span> element to render $count.
  • I also switched out data-show for the data-class attribute, which means I can move the display: none into my CSS stylesheet (instead of an inline style) and have the visible class applied when $count > 0. Another benefit with this is that I can more easily use CSS for animations, if I wanted to make the visibility state transition a bit fancier.
1<small
2  data-signals:count="0"
3  data-class="{ visible: $count > 0 }"
4  data-text="`There were ${$count} spelling mistakes corrected.`"
5></small>

Since data-text expects a Javascript expression, it's necessary to have two levels of quoting; the double quotes to wrap the attribute value, and then backticks to wrap the actual string template literal value. And because both template literal expressions and Datastar signals start with the $ symbol, the ${$count} is also a bit messy to parse while reading. It works, but I'm not so keen on this.

Hmm... Maybe I shouldn't be using a signal to manage the count state. It feels clunkier and more verbose. I've been lurking in the Datastar Discord server for a while and have noticed they do keep saying that one should patch HTML elements instead of relying on signals. Maybe I could keep my JSX component the same as what I originally had (that nice, clean-looking component) if I just re-rendered it with the new count and sent back that HTML instead?

But wait, I need to send JSON in the response to update the text signal, I can't also send down HTML... can I?

# SSE streams are wonderful

Wow, I finally took the time to read up about how SSE works with Datastar and I feel foolish for trying to avoid it thus far. I had no idea what I was missing out on all this time. After reading about Datastar's SSE event API, I realised that my endpoint can respond with a single text/event-stream response that pushes multiple events before closing; one to update the text signal with the spell-checked value, and another that sends down the newly re-rendered component HTML with the updated count. Datastar can handle either type of event from the same response stream! Fantastic!

So, using the Datastar Typescript SDK, in my server's POST /spellcheck endpoint handler I now basically have:

 1import { ServerSentEventGenerator } from "@starfederation/datastar-sdk";
 2// ...
 3
 4// Back to my nice, simple component
 5const SpellcheckComponent = ({ count = 0 }) => (
 6  // I added a new ID here
 7  <div id="spellcheck">
 8    <input
 9      type="text"
10      data-bind:text
11      data-on:blur="@post('/spellcheck');"
12    />
13    {count > 0 && (
14      <small>
15        There were {count} spelling mistakes corrected.
16      </small>
17    )}
18  </div>
19);
20
21app.post("/spellcheck", async (c) => {
22  // Hono-specific; get the NodeJS req/res objects to pass into the stream generator
23  const { incoming: res, outgoing: req } = c.env;
24
25  const body = await c.req.json();
26  const output = spellcheck(body?.text);
27
28  // Re-render the component with the updated count
29  const jsx = <SpellcheckComponent count={output.count} />;
30
31  // Using the Datastar SDK, create a new SSE stream
32  ServerSentEventGenerator.stream(req, res, (stream) => {
33    // Update the text signal
34    stream.patchSignals({ text: output.text });
35    // Send the new HTML in order to show the count
36    stream.patchElements(jsx.toString());
37  });
38});

That really wasn't so tricky after all. My component stays nice and tidy, I only have one signal bound to the <input> where I want to collect user input state, and my component still only makes one request per user interaction event (because of the flexibility of SSE, I didn't have to make multiple requests to different endpoints in order to return both JSON and HTML).

One important thing to note is that in order for the PatchElements event to be able to update the DOM correctly, I had to add an id attribute to the wrapping <div> element in my JSX component. This will enable Datastar to identify where in the DOM the morph should occur. Without it, Datastar threw a runtime error.

# Did I still overcomplicate things?

Yeah... So remember when all those Discord members told me to just patch HTML elements? Well, I think they were right; that was all I needed to do.

Just because data-bind creates the signal, doesn't mean it has to be the attribute to do so. I could also use the data-signals attribute at the root level of my component. A nice feature of the data-signals attribute is that, when patching elements, if the value of the attribute has changed, Datastar will update the signals accordingly.

So, in other words, if I just include the spell-checked text as a prop in my JSX component, and set the text signal definition on the root element, Datastar will automatically sync the new signal value from the HTML to the input with data-bind when it merges the incoming response from the server. Wow, powerful stuff.

As awesome as it is to be able to respond with both signals and html in the same stream... do I really need to do it..? The answer seems, no.

 1const SpellcheckComponent = ({ text = "", count = 0 }) => (
 2  <div
 3    id="spellcheck"
 4    data-signals:text={`'${text}'`}
 5  >
 6    <input
 7      type="text"
 8      data-bind:text
 9      data-on:blur="@post('/spellcheck');"
10    />
11    {count > 0 && (
12      <small>
13        There were {count} spelling mistakes corrected.
14      </small>
15    )}
16  </div>
17);

An additional benefit of doing everything in a single PatchElements event is that both the count and the signal value will update near-simultaneously. However, when sending two events there could, theoretically, be some delay between when each are received, causing parts of the UI to update earlier than others.

Even though I didn't need SSE after all, I'm very grateful for having discovered its potential and, more importantly, how easy it actually is! I can just go back to rendering the component on the server with the new prop values and sending a single text/html response from my endpoint. It all just works, with no additional Javascript on the client. Wow.

Unfortunately, one thing that doesn't play so nicely with JSX is that Datastar requires me to wrap the signal value in quotes in order for it to be correctly inferred as a string. If I don't do this, it assumes I am trying to execute some JS expressions, which throws an error.

But in order to do string interpolation in JSX, I also need use backticks and curly braces... this means 3 levels of wrapping just to output the value of a variable into a string. It's a bit tedious to write, but maybe I'll figure out a way to work around that, too.

# I love self-contained, reusable components

This is probably what I appreciated most about React when I first started using it; the ability to encapsulate state and behaviour within a single component and be able to easily use it multiple times without affecting one another. I feel like I'm so close to that right now with my current implementation, except for a couple of issues...

  1. Datastar requires ids when merging fragments so that it can match incoming elements with those already in the DOM and morph correctly. And, of course, ids in HTML have to be unique.
  2. Datastar's signals are also global. This means if I include multiple instances of my SpellcheckComponent, my text signal will be shared across all of them.

That's not ideal given how my component is currently built. Fortunately, it appears that Datastar does provide a namespacing feature for signals. To namespace a signal with Datastar, it looks like I just need to add a prefix and a dot separator when declaring the signal, e.g. data-signals-foo.text="..."; or I could pass a JSON object with the value nested inside an object, keyed by namespace.

So, if I take the id as a prop, then I should be able to use that to solve both of those aforementioned issues. I'll also need to send the id to my backend so when the /spellcheck endpoint recieves a request, it knows which component instance it's dealing with. I think passing the id as a query parameter when I @post should be sufficient here; I can easily extract that in the server endpoint handler and use it again when re-rendering the JSX component. There should be no need to mess around with storing the ID in a signal.

Let's refactor the Spellcheck component and see how it looks with those changes...

 1const SpellcheckComponent = ({ id, text = "", count = 0 }) => (
 2  <div
 3    id={id}
 4    data-signals={JSON.stringify({ [id]: { text }})}
 5  >
 6    <input
 7      type="text"
 8      data-bind={`${id}.text`}
 9      data-on:blur={`@post('/spellcheck/${id}');`}
10    />
11    {count > 0 && (
12      <small>
13        There were {count} spelling mistakes corrected.
14      </small>
15    )}
16  </div>
17);

And adjust my /spellcheck endpoint to use the id path param:

1app.post("/spellcheck/:id", async (c) => {
2  const id = c.req.param("id");
3  const body = await c.req.json();
4  const output = spellcheck(body[id]?.text);
5  return c.html(<SpellcheckComponent id={id} {...output} />);
6});

Now, I'll add a couple of instances to the page on the initial server render:

1<body>
2  <main>
3    <!-- ... -->
4    <SpellcheckComponent id="spellcheck_1" />
5    <SpellcheckComponent id="spellcheck_2" />
6  </main>
7</body>

That's... not so bad, actually. I expected it to be worse. It feels like both the component and the endpoint handler barely changed.

I wonder how this will feel over time as my project grows in complexity..? Right now, it's trivial to assign unique id values to those components... but I may want to switch out those static strings with something with a very low collision-chance, like nanoid, so that I don't have to manually ensure that all instances have unique IDs. I can imagine that once a component's usage is scattered throughout a large codebase, it'll be much more difficult and annoying to keep track of what all the instance IDs are.

Remember when I complained earlier about the 3 layers of wrapping to print my variable into a Datastar attribute with JSX? I'm curious to see how tedious it might start to feel over time if I am having to repeatedly do things like JSON.stringify(), or doing a clunky double-quoting to print values/strings inside HTML attributes.

1// Before, with the awful double quoting
2<div data-signals:text={`'${text}'`}></div>
3
4// After, with JSON stringify
5<div data-signals={JSON.stringify({ [id]: { text }})}></div>

I'm torn. I'm not really a fan of either. That's definitely a downside of using JSX for my templating... Using JSON.stringify() ended up feeling like a much cleaner way to render the signals object into my HTML, particularly once I had the nested object structure. Yeah, it's a bit longer to write, but at least I don't have to mess around with a specific concoction of curly braces, backticks and quotes... Besides, tab-completion in my editor saves me having to do most of those keystrokes, anyway.

Note: If I just wrote data-signals={{ [id]: { text } }}, as I might have done with React props, then JSX rendered the output as [object Object], which I guess makes sense; since I'm compiling the JSX directly to HTML (not React's createElement function calls) and HTML attributes require string values, it was probably just calling the object's toString() method under the hood.

# Adding / removing component instances

Now I have a (mostly) encapsulated and re-usable "component", let me put it to use! I didn't initially plan to do this, but when I'm enjoying myself, well, it's hard to say "no" to scope creep...

I'll start by adding a button to the page that will add a new instance of the spellchecker component every time it's clicked. For this, I'll use data-on:click:

1<main id="main">
2  <!-- ... -->
3  <div id="container">
4    <SpellcheckComponent id={nanoid()} />
5  </div>
6  <button data-on:click="@post('/spellcheck')">Add</button>
7</main>

Per Datastar's ideology, I'll have the button click fire off a request to the backend, in this case the POST /spellcheck endpoint, which will return some HTML with the new component instance. This endpoint will be very simple; it'll just send a text/html response and render a new instance of the SpellcheckComponent.

1app.post("/spellcheck", async (c) => {
2  return c.html(<SpellcheckComponent id={nanoid()} />, 200, {
3    "datastar-mode": "append",
4    "datastar-selector": `#container`,
5  });
6});

The two most interesting tidbits here, I think, are the inclusion of the datastar-mode and datastar-selector headers.

  1. Previously with the POST /spellcheck endpoint, I had been using the default mode to "morph" the existing HTML. Here, however, I decided to use the append mode so that I preserve any existing instances on the page as-is.
  2. In order to be able to append, I needed to know what I could append to. Thus, I added a new wrapping #container div around the initial spellcheck component, and target this via the datastar-selector header. It seems I can use any CSS selector for this value, not just an ID — I'll store that somewhere in my mind, as it might be useful to know in the future.

I'll also take the opportunity here, on both the initial render of the page and each "add" request, to use nanoid (as previously mentioned), to generate random unique IDs for each instance.

And finally, let's add another button next to each of the spellcheck component input elements, which will enable the user to remove the instance when clicked. We can leverage Datastar's @delete backend action for this, which allows me to use semantic HTTP methods:

1<fieldset role="group">
2  <input
3    type="text"
4    data-bind={`${id}.text`}
5    data-on:blur={`@post('/spellcheck/${id}')`}
6  />
7  <button data-on:click={`@delete('/spellcheck/${id}')`}>X</button>
8</fieldset>

And for the backend endpoint, I can use the datastar-mode: remove header to tell the Datastar client library to delete the element from the page. Once again, I'll use datastar-selector and the ID to do the relevant targeting.

1app.delete("/spellcheck/:id", async (c) => {
2  const id = c.req.param("id");
3  return c.body(null, 200, {
4    "Content-Type": "text/html",
5    "datastar-mode": "remove",
6    "datastar-selector": `#${id}`,
7  });
8});

Very nice! Now I can add/remove components on the page to my heart's content!

I expected that to be a bit more effort than it was, to be honest. I'm not sure if I'm using Datastar's modes correctly — I have seen people on Discord repeatedly saying "just morph!" — so maybe there's a better architectural pattern to follow generally... but it was super easy to implement this behaviour, and for the purposes of this exercise I'm happy with how it is working.

# Demo

So, how does it all come together? Here's the result:

Sure, it's not much, but hey, as trivial as the demo might seem, the process of building it helped me learn a lot about Datastar, its API and various ways I can implement features.

# How might it have looked with React?

Ignoring the extra "add/remove instance" functionality — because at this point I'm not motivated to rebuild everything — and just focusing on the basic spellcheck component; if I don't bother with any proper error handling, retries or backoff for the /spellcheck request), I'd maybe write a simple controlled input component in React that, at the very least, looks something like this:

 1const SpellcheckComponent = () => {
 2  const [text, setText] = useState("");
 3  const [count, setCount] = useState(0);
 4
 5  const onBlurHandler = async () => {
 6    try {
 7      const res = await fetch('/spellcheck', {
 8        method: "post",
 9        body: JSON.stringify({ text }),
10        headers: {
11          "content-type": "application/json"
12        }
13      });
14      const payload = await res.json();
15      setText(payload.text);
16      setCount(payload.count);
17    } catch (e) {
18      console.log(e)
19    }
20  };
21  
22  return (
23    <div>
24      <input
25        type="text"
26        onChange={e => setText(e.target.value)}
27        onBlur={onBlurHandler}
28        value={text}
29      />
30      {count > 0 && (
31        <small>
32          There were {count} spelling mistakes corrected.
33        </small>
34      )}
35    </div>
36  );
37};

You know, it really doesn't feel all that different when authoring the component; the process was very similar. I wrote almost the same markup, declared some state, bound it to an input, made a request to the server to do the spell checking, and updated the state. I guess the main difference is that my POST /spellcheck endpoint would return JSON for the React component instead of HTML for the Datastar component.

Of course, the above code snippet is only focusing on the "component" itself and not any of the server-side code. However, even just focusing on that client-side code, with React, it does feel like there is:

  • more plumbing/boilerplate code that I have to write myself (this trivial component was already double the line count);
  • more client state that I have to manage and sync;
  • React-specific knowledge required, e.g. controlled vs uncontrolled inputs, component lifecycles, rules of hooks;
  • a lot more runtime Javascript that I have to not only transpile/bundle, but also ship to the browser;
  • and thus, generally a much larger code surface for things to go wrong...

I'm not going to bother building out the full React app to do a "proper" comparison here, because I've done enough React in the past to have a good sense of what it would entail, without having to build yet another throwaway app (ain't got no time for that).

# Closing thoughts

I must say, I'm quite surprised. The Datastar API was rather pleasant to use. For the most part, it was obvious how to use it and just stayed out of my way. I used, what, one signal? And just a couple of attributes from its (quite comprehensive) API. I was expecting to use more, honestly.

The mental model of "re-render HTML on server, then just display it" is refreshingly simple. Hooking onto the user interaction events declaratively feels just like what I'm used to after a decade of React with JSX, and the Datastar attribute API is intuitive and straightforward to use.

Another win for Datastar, in my opinion, is that it's just using native DOM Events, too — unlike React with its "synthetic events" wrapper. Yet another React-specific thing to understand.

Responding immediately with HTML and letting Datastar handle morphing it into the existing DOM also seems much simpler than having to map some JSON data back to component state in order for it to be able to re-render itself accordingly.

I ended up also appreciating the "no build" workflow, too. It was nice to not have the hassle of setting up build tooling for the client code and waiting for it to transpile after every change. All I had was a very minimal tsconfig.json file, and a NPM script to watch the index.tsx file, which runs the Typescript server code with Node. Again, super simple, and the server restarts quick enough to keep out of my way.

You know, I didn't even notice the lack of Datastar-specific LSP tooling at all during that whole process. I actually still got the benefit of the Typescript LSP for almost all of the code I wrote, since it was basically all on the server-side, and the Datastar-specific attributes were such a tiny (and simple) part of the code that I didn't really need it for those. Huh, I did not expect that. I'm so used to relying on LSPs now. And who knows, maybe the tooling will improve in the future..?

I saw there are official plugins for VSCode and IntelliJ, but I don't use either of those editors, and they seem to only add autocomplete for the attribute names, rather than LSP support within the attribute values. Helpful, for sure, but not quite what I desire.

Admittedly, this spellchecker remains a trivial example, but I'm curious to see how Datastar continues to fare as the complexity grows in an application. Despite my initial apprehensions about the DSL-like expressions, it didn't end up being an issue for me. I've enjoyed using Datastar, it certainly looks promising thus far.

I suppose for my next project, I should do something a little more interesting...

View the final code in its entirety on Github.