Datastar
Datastar is a hypermedia framework for building reactive web apps. With reactive signals and DOM patching (using a morphing strategy), Datastar allows you to build everything from simple sites to real-time collaborative web apps.
Usage
Using Datastar requires:
- Installation of the Datastar client-side library.
- Modifying the HTML markup to instruct the library to perform DOM patches.
Installation
Datastar can be installed by adding a script tag to your HTML. See the installation instructions.
Example Project
Northstar is a boilerplate project for building real-time hypermedia applications with Datastar. All of the examples use templ.
Counter Example
We are going to modify the templ counter example to use Datastar, as per the example on the site.
Frontend
First, define some HTML with two buttons. One to update a global state, and one to update a per-user state.
package site
import "github.com/starfederation/datastar-go/datastar"
type TemplCounterSignals struct {
Global uint32 `json:"global"`
User uint32 `json:"user"`
}
templ templCounterExampleButtons() {
<div>
<button
data-on:click="@post('/examples/templ_counter/increment/global')"
>
Increment Global
</button>
<button
data-on:click={ datastar.PostSSE('/examples/templ_counter/increment/user') }
<!-- Alternative: Using Datastar SDK sugar-->
>
Increment User
</button>
</div>
}
templ templCounterExampleCounts() {
<div>
<div>
<div>Global</div>
<div data-text="$global"></div>
</div>
<div>
<div>User</div>
<div data-text="$user"></div>
</div>
</div>
}
templ templCounterExampleInitialContents(signals TemplCounterSignals) {
<div
id="container"
data-signals={ templ.JSONString(signals) }
>
@templCounterExampleButtons()
@templCounterExampleCounts()
</div>
}
Note that Datastar sends all[^1] signals to the server (as JSON) on each request. This means far less bookkeeping and more predictable state management than when using html forms.
data-signals is a Datastar attribute that patches (adds, updates or removes) one or more signals into the existing signals. In the example, we store $global and $user when we initially render the container.
data-on:click="@post('/examples/templ_counter/increment/global')" is an attribute expression that says "When this element is clicked, send a POST request to the server to the specified URL". The @post is an action that is a sandboxed function that knows about things like signals.
data-text="$global" is an attribute expression that says "replace the contents of this element with the value of the global signal". This is a reactive signal that will update the page when the value changes, which we'll see in a moment.
Backend
Note the use of Datastar's helpers to set up SSE.
package site
import (
"net/http"
"sync/atomic"
"github.com/Jeffail/gabs/v2"
"github.com/go-chi/chi/v5"
"github.com/gorilla/sessions"
"github.com/starfederation/datastar-go/datastar"
)
func setupExamplesTemplCounter(examplesRouter chi.Router, sessionSignals sessions.Store) error {
var globalCounter atomic.Uint32
const (
sessionKey = "templ_counter"
countKey = "count"
)
userVal := func(r *http.Request) (uint32, *sessions.Session, error) {
sess, err := sessionSignals.Get(r, sessionKey)
if err != nil {
return 0, nil, err
}
val, ok := sess.Values[countKey].(uint32)
if !ok {
val = 0
}
return val, sess, nil
}
examplesRouter.Get("/templ_counter/data", func(w http.ResponseWriter, r *http.Request) {
userVal, _, err := userVal(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
signals := TemplCounterSignals{
Global: globalCounter.Load(),
User: userVal,
}
c := templCounterExampleInitialContents(signals)
datastar.NewSSE(w, r).MergeFragmentTempl(c)
})
updateGlobal := func(signals *gabs.Container) {
signals.Set(globalCounter.Add(1), "global")
}
examplesRouter.Route("/templ_counter/increment", func(incrementRouter chi.Router) {
incrementRouter.Post("/global", func(w http.ResponseWriter, r *http.Request) {
update := gabs.New()
updateGlobal(update)
datastar.NewSSE(w, r).MarshalAndMergeSignals(update)
})
incrementRouter.Post("/user", func(w http.ResponseWriter, r *http.Request) {
val, sess, err := userVal(r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
val++
sess.Values[countKey] = val
if err := sess.Save(r, w); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
update := gabs.New()
updateGlobal(update)
update.Set(val, "user")
datastar.NewSSE(w, r).MarshalAndMergeSignals(update)
})
})
return nil
}
The atomic.Uint32 type stores the global state. The userVal function is a helper that retrieves the user's session state. The updateGlobal function increments the global state.
In this example, the global state is stored in RAM and will be lost when the web server reboots. To support load-balanced web servers and stateless function deployments, consider storing the state in a data store such as NATS KV.
Per-user session state
In an HTTP application, per-user state information is partitioned by an HTTP cookie. Cookies that identify a user while they're using a site are known as "session cookies". When the HTTP handler receives a request, it can read the session ID of the user from the cookie and retrieve any required state.
Signal-only patching
Since the page's elements aren't changing dynamically, we can use the MarshalAndMergeSignals function to send only the signals that have changed. This is a more efficient way to update the page without even needing to send HTML fragments.
Datastar will merge updates to signals similar to a JSON merge patch. This means you can do dynamic partial updates to the store and the page will update accordingly. Gabs is used here to handle dynamic JSON in Go.