Falco.Datastar 1.0.0-beta2

This is a prerelease version of Falco.Datastar.
dotnet add package Falco.Datastar --version 1.0.0-beta2
                    
NuGet\Install-Package Falco.Datastar -Version 1.0.0-beta2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Falco.Datastar" Version="1.0.0-beta2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Falco.Datastar" Version="1.0.0-beta2" />
                    
Directory.Packages.props
<PackageReference Include="Falco.Datastar" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Falco.Datastar --version 1.0.0-beta2
                    
#r "nuget: Falco.Datastar, 1.0.0-beta2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#addin nuget:?package=Falco.Datastar&version=1.0.0-beta2&prerelease
                    
Install Falco.Datastar as a Cake Addin
#tool nuget:?package=Falco.Datastar&version=1.0.0-beta2&prerelease
                    
Install Falco.Datastar as a Cake Tool

Falco.Datastar

NuGet Version build

open Falco.Markup
open Falco.Datastar

let demo =
    Elem.button
        [ Ds.onClick (Ds.get "/click-me") ]
        [ Text.raw "Reset" ]

Falco.Datastar brings type-safe Datastar support to Falco. It provides a complete mapping of all attribute plugins and action plugins. As well as helpers for retrieving the signals and responding with Datastar Server Side Events.

Key Features

  • Idiomatic mapping of data-* attributes (e.g. data-text, data-bind, data-signals, etc.).
  • Helper functions for reading signals and responding with Datastar Server Side Events.

Design Goals

  • Create a self-documenting way to integrate Datastar into Falco applications.
  • Provide type safety without over-abstracting.

Getting Started

First off, for any questions or criticisms of this library or Datastar in general, please join our Discord, where we are definitely not a cult.

This guide assumes you have a Falco project setup. If you don't, you can create a new Falco project using the following commands. The full code for this guide can be found in the Hello World example.

> dotnet new web -lang F# -o HelloWorld
> cd HelloWorld

Install the nuget package:

> dotnew add package Falco
> dotnew add package Falco.Datastar

Remove any *.fs files created automatically, crate a new file name Program.fs and set the contents to the following:

open Falco
open Falco.Datastar
open Falco.Markup
open Falco.Routing
open Microsoft.AspNetCore.Builder

let bldr = WebApplication.CreateBuilder()
let wapp = bldr.Build()

let endpoints = [ ]

wapp.UseRouting()
    .UseFalco(endpoints)
    .Run()

Now, let's incorporate Datastar into our Falco application. First, we'll define a simple route that returns a button that, when clicked, will merge an HTML fragment from a GET request.

let handleIndex : HttpHandler =
    let html =
        Elem.html [] [
            Elem.head [] [ Ds.script ]
            Elem.body [] [
                Text.h1 "Example: Hello World"
                Elem.button
                    [ Attr.id "hello"; Ds.onClick (Ds.get "/click") ]
                    [ Text.raw "Click Me" ] ] ]

    Response.ofHtml html

Next, we'll define a handler for the click event that will return an HTML fragment from the server to replace the HTML of the button; note the #hello.

let handleClick : HttpHandler =
    let html = Elem.h2 [ Attr.id "hello" ] [ Text.raw "Hello, World, from the Server!" ]
    Response.ofHtmlFragments html

And lastly, we'll make Falco aware of these routes by adding them to the endpoints list.

let endpoints =
    [
        get "/" handleIndex
        get "/click" handleClick
    ]

Save the file and run the application:

> dotnet run

Navigate to https://localhost:5001 in your browser and click the button. You should see the text "Hello, World from the Server!" appear in the place of the button.

Signals and Expressions

Datastar uses signals to manage state. Signals are reactive variables that automatically track and propagate changes in Datastar expressions. They can be created and modified using data attributes on the frontend, or events sent from the backend.

Datastar expressions are strings that are evaluated by bindings, events, and triggers. Updating a signal value in an expression will cause other bindings and expressions to update elsewhere.

Some important notes: Signals defined later in the DOM tree override those defined earlier. data-* attributes are evaluated in the order they appear in the DOM; meaning that signals need to be specified before they can be used.

Sections:

Creating Signals

Create signals, which are reactive variables that automatically propagate their value to all references of the signal.

Ds.signals / Ds.signal : data-signals

Serializes the passed object with System.Text.Json.JsonSerializer and will merge the signals with the existing signals.

type MySignals() =
    member var firstName = "Don" with get, set
    member var lastName = "Syme" with get, set

let signals = MySignals()

Elem.div [ Ds.signals signals ] []

As a convenience, you can create a single signal with the option to add it only if it is missing.

Important note: if you use kebab-case, it will be returned in pascal-case.

Elem.div [ Ds.signal (sp"signalPath", "signalValue", ifMissing = true) ] []

Ds.computed : data-computed

Creates a read-only signal that is computed based on a Datastar expression. data-text is used here to bind and display the signal value.

Elem.div [ Ds.computed "foo" "$bar + $baz" ] []
Elem.div [ Ds.text "$foo" ] []

Ds.ref : data-ref

Creates a new signal that is a reference to the element on which the data attribute is placed. data-text is used here to bind and display the signal value.

Elem.div [ Ds.ref "foo" ] []
Elem.div [ Ds.text "$foo.tagName" ] []

Ds.indicator : data-indicator

Creates a signal and sets its value to true while an SSE request is in flight, otherwise false. As an example, the signal can be used to show a loading indicator.

Elem.button [
    Ds.onClick (Ds.get "/fetchBigData")  // make a request to the backend
    Ds.indicator "fetching"  // the signal we are creating
    Ds.attr' "disabled" "$fetching"  // assigns the "disabled" attribute if the `fetching` signal value is true
    ] [ Text.raw "Fetch!" ]

Elem.div
    [ Ds.show "$fetching" ]  // show or hide this <div> if the `fetching` signal value is true or false, respectively
    [ Text.raw "Fetching" ]

The previous example uses a couple functions we haven't covered yet. Ds.onClick firing a Ds.get action, which sends a GET request to the server. Ds.attr' and Ds.show are evaluating the Datastar expression $fetching and are assigning disabled attribute and show/hiding the div, respectively, based on the fetching signal value's "true-ness".

Signal Binding

Binding to a signal means tying an attribute or value of an element to a value that can be modified by another effect. Example: setting the innerText of a <div> to a value that is updated by a server; or, toggling an HTML class on an element.

Ds.bind : data-bind

Creates a two-way binding from a signal to the "value" of an HTML element. Can be placed on any HTML element on which data can be input or choices selected (e.g. input, textarea, select, checkbox and radio elements, as well as web components. You can see in the source how signals are translated). The signal will be created if it does not already exist.

Elem.input [ Attr.type' "text"; Ds.bind "firstName" ]

Ds.text : data-text

Binds the text value of an element to a Datastar expression.

Elem.div [ Ds.text "$foo" ] []

Ds.attr' : data-attr

Binds the value of an HTML attribute to an expression.

Elem.div [ Ds.attr' "title" "$foo" ] []

Ds.show : data-show

Show or hides an element based on whether a Datastar expression evaluates to true or false. For anything with custom requirements, use data-class instead.

Elem.div [ Ds.show "$foo" ] []

Ds.class' : data-class

Adds or removes a class to or from an element based on the "true-ness" of a Datastar expression.

Elem.div [ Ds.class' "hidden" "$foo" ]  // add the 'hidden' class when $foo evaluates to true

Ds.viewTransition : data-view-transition

Sets the view-transition-name style attribute explicitly.

Elem.div [ Ds.viewTransition "$foo" ]

Ds.customValidity : data-custom-validity

Allows adding custom validity to an element using an expression. The expression must evaluate to a string, which will be set as the custom validity message. If the string is empty, the input is considered valid.

Elem.form [] [
    Elem.input [ Ds.bind "foo"; Attr.name "foo" ]
    Elem.input [ Ds.bind "bar"; Attr.name "bar"
                 Ds.customValidity "$foo === $bar ? '' : 'Field values must be the same.'" ]
    Elem.button [] [ Text.raw "Submit form" ]
]

Events and Triggers

Events and triggers result in Datastar expressions being executed. This can possibly result in signal changes and other expressions being run. Example: clicking a button to send a request or update the visibility on an element via Ds.show.

Ds.onEvent : data-on

Attaches an event listener to an element, executing a Datastar expression whenever the event is triggered. An evt variable that represents the event object is available in the expression.

Elem.div [ Ds.onEvent("mouseup", "$selection = document.getSelection().toString()") ] [ Text.raw "Highlight some of me!" ]
Elem.div [ Ds.onEvent("mouseenter", "$show = !$show"); Ds.onEvent("mouseexit", "$show = !$show") ] []

There are helper methods for Ds.onEvent("click", ...) and Ds.onEvent("load", ...):

Elem.button [ Ds.onClick "$show = !$show" ] [ Text.raw "Peek-a-boo!" ]
Elem.div [ Ds.onLoad (Ds.get "/edit") ] []
data-on Modifiers

Modifiers allow you to alter the behavior when events are triggered. (Modifiers with a '*' can only be used with the built-in events).

 type OnEventModifier =
    | Once     // * - can only be used with built-in events
    | Passive  // * - can only be used with built-in events
    | Capture  // * - can only be used with built-in events
    | Debounce of Debounce  // timespan, leading, and notrail
    | Throttle of Throttle  // timepan, noleading, and trail
    | ViewTransition
    | Window
    | Outside
    | Prevent
    | Stop

As an example:

Elem.div [
    Ds.onEvent ("click", "$foo = ''", [ Window; Debounce.With(TimeSpan.FromSeconds(1.0), leading = true) ])
    ] []

Results in:

<div data-on-click__window__debounce.1000ms.leading="$foo = ''"></div>

Ds.onIntersect : data-on-intersect

Runs an expression when the element intersects with the viewport.

Elem.div [ Ds.onIntersect "$intersected = true" ] []

Elem.div [ Ds.onIntersect ("$intersected = true", visibility = Full) ] []

Elem.div [ Ds.onIntersect ("$intersected = true", visibility = Half, onlyOnce = true) ] []

Elem.div [ Ds.onIntersect ("$intersected = true", visibility = Half, onlyOnce = true, debounce = Debounce.With(TimeSpan.FromSeconds(1.0))) ] []

Elem.div [ Ds.onIntersect ("$intersected = true", visibility = Half, onlyOnce = true, throttle = Throttle.With(TimeSpan.FromSeconds(1.0))) ] []

Ds.onSignalChange | Ds.onAnySignalChange : data-on-signal-change

Runs an expression when another signal changes. This should be used sparingly, as it is cost intensive.

Elem.div [ Ds.onAnySignalChange "$show = !$show" ] []

Elem.div [ Ds.onSignalChange (sp"foo", "$show = !$show") ] []

Ds.onRequestAnimationFrame : data-on-raf

Runs an expression on every request animation frame event.

Elem.div [ Ds.onRequestAnimationFrame "$frame++" ] []

Elem.div [ Ds.onRequestAnimationFrame ("$frame++", debounce = Debounce.With(TimeSpan.FromSeconds(1.0))) ] []

Elem.div [ Ds.onRequestAnimationFrame ("$frame++", throttle = Throttle.With(TimeSpan.FromSeconds(1.0))) ] []

Ds.onInterval : data-on-interval

Runs an expression at a regular interval. The interval duration defaults to 1 second and can be modified by passing a TimeSpan

Elem.div [
    Ds.signal (sp"intervalSignalOneSecond", false)
    Ds.onInterval "$intervalSignalOneSecond = !$intervalSignalOneSecond"
    Ds.text "'One Second Interval = ' + $intervalSignalOneSecond"
] []

Elem.div [
    Ds.signal (sp"intervalSignalFiveSecond", false)
    Ds.onInterval ("$intervalSignalFiveSecond = !$intervalSignalFiveSecond", TimeSpan.FromSeconds(5.0), leading = true)
    Ds.text "'Five Second Interval = ' + $intervalSignalFiveSecond"
] []

Actions and Functions

Datastar provides a number of actions and functions that can be used in Datastar expressions for making server requests and manipulating signals.

@get | @post | @put | @patch | @delete

These actions make requests to any backend service that supports Server Side Events (SSE). Luckily an F#-friendly SDK exists and Falco.Datastar has several helper methods

All signals, that do not have an underscore prefix, are sent in the request. @get will send the signal values as query parameters. All others are sent within a JSON body.

Elem.div [ Ds.onLoad (Ds.get "/get") ] []

Elem.button [ Ds.onClick (Ds.post "/post") ] [ Text.raw "Post" ]

Elem.button [ Ds.onClick (Ds.put "/put") ] [ Text.raw "Put" ]

Elem.button [ Ds.onClick (Ds.patch "/patch") ] [ Text.raw "Patch" ]

Elem.button [ Ds.onClick (Ds.delete "/delete") ] [ Text.raw "Delete" ]

The majority of the above examples are fired from a button click, but remember that these are Datastar expressions and any event or trigger could activate them.

Each request action can also be provided a number of options, explained in depth here:

Elem.button [ Ds.onClick (Ds.get ("/endpoint",
                                  { RequestOptions.Defaults with
                                        IncludeLocal = true;
                                        Headers = [ ("X-Csrf-Token", "JImikTbsoCYQ9...") ]
                                        OpenWhenHidden = true }
                                 )) ] [ Text.raw "Push the Button" ]

@setAll

Sets all the signals that start with the prefix to the expression provided in the second argument. This is useful for setting all the values of a signal namespace at once.

Elem.div [ Ds.onEvent (OnEvent.SignalsChanged, (Ds.setAll "foo." true)) ] []

@toggleAll

Toggles all the signals that start with the prefix. This is useful for toggling all the values of a signal namespace at once.

Elem.div [ Ds.onEvent (OnEvent.SignalsChanged, (Ds.toggleAll "foo.")) ] []

Ds.persistSignals, Ds.persistAllSignals : data-persist

Persists signals in local or session storage. Useful for storing values between page loads.

// persist all signals in local storage
Elem.div [ Ds.persistAllSignals ] []

// persist the signals `foo` and `bar` in local storage
Elem.div [ Ds.persistSignals [ sp"foo"; sp"bar" ] ] []

// persist all signals in session storage
Elem.div [ Ds.persistAllSignals (inSession = true) ] []

Ds.scrollIntoView : data-scroll-into-view

Scrolls the element into view. Useful when updating the DOM from the backend, and you want to scroll to the new content.

Elem.div [ Ds.scrollIntoView (Smooth, Center, Center) ] []

Elem.div [ Ds.scrollIntoView (Auto, Left, Bottom, focus = true) ] []

Miscellaneous Actions

Helper actions that can be used in Datastar expressions and performing browser operations.

Ds.replaceUrl : data-replace-url

Replace the URL in the browser without reloading the page. Can be a relative of absolute URL, and is an evaluated expression.

Elem.div [ Ds.replaceUrl "/page${page}" ] []

@clipboard

Copies the provided evaluated expression to the clipboard.

Elem.button [ Ds.onClick (Ds.clipboard "'Hello, ' + $name") ] [ Text.raw "Copy Name" ]

@fit

Make a value linear interpolate from an original range to new one.

Elem.button [
    Ds.onClick (
        Ds.clipboard (
            Ds.fit (5, oldRange = (0, 10), newRange = (0, 100))
        )
    )
    ] [ Text.raw "Copy 50 to clipboard" ]

Ds.ignore, Ds.ignoreThis : data-star-ignore

Datastar walks the entire DOM and applies plugins to each element it encounters. It’s possible to tell Datastar to ignore an element and its descendants by placing a data-star-ignore attribute on it. This can be useful for preventing naming conflicts with third-party libraries.

Ds.ignore will force Datastar to ignore the element and all child elements. Ds.ignoreThis only affects the attribute it is attached to.

Elem.div [ Ds.ignore ] [
    Elem.div [ Ds.text "ignoredAsWell" ] []
]

Elem.div [ Ds.ignoreThis ] [
    Elem.div [ Ds.text "thisIsNotIgnored" ] []
]

When to $

You may have noticed in the sample code that the $ is used in some places, but not others. At first, it might be confusing when a $ is required, but it really isn't all that complicated when you think of the arguments as being a signal path or an expression.

The $ symbol is used as a shorthand to access the value of a signal in an expression (i.e. $count β†’ count.value), so when the $ is elided, you are referring to the signal directly. Ds.bind signalPath is two-way binding to the signal, so requires the signal path, no $. Ds.text is replacing the element's innerText, so it only needs the value, via $. Ds.computed (signalPath, expression) needs both a signal path AND an expression, e.g. Ds.computed ("countPlusTen", "$count + 10").

If you want to be certain you are doing it correctly, then there is a helper method SignalPath.sp that will throw an exception at startup, if a signal path contains any invalid symbols, such as $.

open StarFederation.Datastar.SignalPath
...
Elem.input [ Attr.typeCheckbox; Ds.bind (sp"checkBoxSignal") ]

Reading Signals and Server Side Events

Falco.Datastar has a number of Request and Response functions for reading the Datastar signal values and responding with Datastar Server Side Events (SSEs).

Sections:

Reading Signal Values

All requests are sent with a {datastar: *} object containing the current signals (you can keep signals local to the client by prefixing the name with an underscore). When using a GET request, the signals are sent as a query parameter; otherwise, they are sent as a JSON body. Luckily, with Falco.Datastar, you don't have to worry about any of that.

Request.getSignals<'T>

Will use System.Text.Json.JsonSerializer to deserialize the signals into a 'T. If there are no signals, then the default values will be returned.

type MySignals() =
    member val firstName = "John" with get, set
    member val lastName = "Doe" with get, set
    member val email = "john@doe.com" with get, set
...
let httpHandler : HttpHandler = (fun ctx -> task {
    let! signals = Request.getSignals<MySignals> (ctx)
    ...
    })

Request.getSignal<'T>

Given a signal path, it will deserialize the item at the end of the path into a 'T.

let httpHandler : HttpHandler = (fun ctx -> task {
    let! lastAgentShown = Request.getSignal<int> (ctx, "user.firstName")
    ...
    })

Request.getSignals

Will simply return the signal values as a JSON string

let httpHandler : HttpHandler = (fun ctx -> task {
    let! rawSignals = Request.getSignals (ctx)
    ...
    })

Responding with Signals

Response.ofMergeSignals<'T>

Serializes signals with System.Text.Json.JsonSerializer and sends to client where Datastar will merge them.

Response.ofMergeSignals (MySignals())

Response.ofMergeSignal<'T>

Updates a single signal on the client.

Response.ofMergeSignal (sp"user.firstName", "Don")

Response.ofMergeSignals

Takes the signals as a JSON string and sends them to the client where Datastar will merge them.

Response.ofMergeSignals @" { ""firstName"": ""Don"", ""lastName"": ""Syme"" } "

Response.ofRemoveSignals

Given a seq of signal paths, will remove that signals from the client.

Response.ofRemoveSignals [ sp"user.firstName"; sp"user.lastName" ]

Responding with HTML Fragments

HTML fragments are sent to client and replace the current element (matching on the id attribute) with the one that is sent. The following functions are HttpHandlers that will send down a single Server Sent Event.

Response.ofHtmlFragments

Will render an XMLNode and send it to the client. Client Datastar will replace the element with the matching id attribute (or optionally provided selector)

Response.ofHtmlFragments (Elem.h2 [ Attr.id "hello" ] [ Text.raw "Hello, World from the Server!" ])

Response.ofHtmlStringFragments

Will send HTML fragments to the client. Client Datastar will replace the element with the matching id attribute (or optionally provided selector)

Response.ofHtmlStringFragments @"<h2 id='hello'>Hello, World from the Server!"

Response.ofRemoveFragments

Will send a command to client Datastar to remove fragments with the matching selector.

Response.ofRemoveFragments [ sel"hello" ]

Streaming Server Side Events

Within the Response module there are the of methods that are for sending single server side events and then closing the connection. But, Datastar's true power is unlocked when the client keep a connection open to the server and updates are sent to all clients as they are received by the server. This is a much more efficient alternative to having all the clients poll the server every few moments and provides much greater control over back-pressure.

The progress bar example is a great and simple demonstration of what can be achieved with Datastar; no polling necessary.

All the functions in Responding with Signals and Responding with HTML Fragments are mirrored with a function with sse as their prefix instead of of.

The sse* functions require an sseHandler that is obtained through Response.startServerSentEventStream.

let handleStream = (fun ctx -> task {
    let sseHandler = Response.startServerSentEventStream ctx

    let mutable counter = 0

    while not <| ctx.RequestAborted.IsCancellationRequested do
        do! Response.sseMergeSignal (sseHandler, sp"counter", counter)
        do! Response.sseHtmlFragments (sseHandler, Elem.pre [ Attr.id "counterId" ] [ Text.raw counter.ToString() ] )
        do! Task.Delay(TimeSpan.FromSeconds 1L, ctx.RequestAborted)
        counter <- counter + 1

See the Streaming example for more.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
1.0.0-beta2 125 4/1/2025
1.0.0-beta1 116 3/31/2025