open Falco.Markup
open Falco.Datastar
let demo =
Elem.button
[ Attr.id "replace_me"
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.
- 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.
- Create a self-documenting way to integrate Datastar into Falco applications.
- Provide type safety without over-abstracting.
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 HelloWorldInstall the nuget package:
> dotnet add package Falco
> dotnet add package Falco.DatastarRemove any *.fs files created automatically, crate a new file name Program.fs and set the contents to the following:
open Falco
open Falco.Markup
open Falco.Routing
open Falco.Datastar
open Microsoft.AspNetCore.Builder
let wapp = WebApplication.Create()
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.cdnScript ]
Elem.body [] [
Text.h1 "Example: Hello World"
Elem.button
[ Attr.id "hello"; Ds.onClick (Ds.get "/click") ]
[ Text.raw "Click Me" ]
]
]
Response.ofHtml htmlNext, we'll define a handler for the click event that will return an HTML element 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.ofHtmlElements htmlAnd 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 runNavigate 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.
Jump to Signal Reading and Server Side Events.
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.
- Index
- Creating Signals
- Binding to Signals
- Events and Triggers
- Actions and Functions
- Miscellaneous Actions
- When to $
- data-attr
- data-bind
- data-class
- data-computed
- data-effect
- data-ignore
- data-indicator
- data-json-signals
- data-on
- data-on-intersect
- data-on-interval
- data-on-load
- data-on-signal-patch
- data-on-signal-patch-filter
- data-ref
- data-show
- data-signals
- data-style
- data-text
Create signals, which are reactive variables that automatically propagate their value to all references of the signal.
Serializes the passed object with System.Text.Json.JsonSerializer
and will merge the signals with the existing signals.
type MySignals() =
member val firstName = "Don" with get, set
member val 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) ] []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. Important: Computed signal expressions must not be used for performing actions.
If you need to perform an action in response to a signal change, use the data-effect attribute.
Elem.div [ Ds.computed (sp"foo", "$bar + $baz") ] []
Elem.div [ Ds.text "$foo" ] []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" ] []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, making fetch happen
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".
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, changing the class attribute on an element.
Creates a two-way binding from a signal to the "value" of an HTML "input" 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. (Although not necessary, you can find the switch statement in the
source to see how signals are translated).
The signal will be created if it does not already exist. And the type of the signal is preserved during binding; if an element's value changes,
the signal value is automatically converted to match the original (see the documentation for an example.)
Elem.input [ Attr.type' "text"; Ds.bind "firstName" ]Binds the text value of an element to a Datastar expression. The value in $foo will be automatically set to the divs innerText.
Elem.div [ Ds.text "$foo" ] []Binds the value of an HTML attribute to an expression.
Elem.div [ Ds.attr' ("title", "$foo") ] []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" ] []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 trueSets the value of inline CSS styles on an element based on an expression, and keeps them in sync.
Elem.div [ Ds.style "backgroundColor" "$usingRed ? 'red' : 'blue'" ] [ Text.raw "Red of Blue" ]
Elem.div [ Ds.style "display" "$hiding && 'none'" ] [ Text.raw "Might be hiding" ]Events and triggers result in Datastar expressions being executed. This can result in signal changes and other expressions being run. Example: clicking a button to send a request or an element scrolling into view.
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") ] []Elem.button [ Ds.onClick "$show = !$show" ] [ Text.raw "Peek-a-boo!" ]
Elem.div [ Ds.onLoad (Ds.get "/edit") ] []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
| Delay of TimeSpan
| DelayMs of int // identical to Delay, but just milliseconds
| Debounce of Debounce // timespan, leading, and notrail
| Throttle of Throttle // timepan, noleading, and trail
| ViewTransition
| Window
| Outside
| Prevent
| StopAs an example:
Elem.div [
Ds.onEvent ("click", "$foo = ''", [ Window; Debounce.With(1000, leading = true) ])
] []Results in:
<div data-on-click__window__debounce.1000ms.leading="$foo = ''"></div>Runs an expression when the element is loaded into the DOM. Important: when patching elements, ElementPatchMode.Replace the Datastar expression
will be fired a second time, but will not with ElementPatchMode.Outer.
Elem.div [ Ds.onLoad (Ds.get "/moreAgents") ] []Executes an expression on page load and whenever any signals in the expression change. This is useful for performing side effects, such as updating other signals, making requests to the backend, or manipulating the DOM.
Elem.div [ Ds.effect @"$foo = $bar + $baz" ] []
Elem.div [ Ds.text "$foo" ] []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))) ] []Runs an expression any signal changes. This should be used sparingly, as it is cost intensive.
Elem.div [ Ds.onAnySignalChange "$show = !$show" ] []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"
] []Datastar provides a number of actions and functions that can be used in Datastar expressions for making server requests and manipulating signals.
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" ]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)) ] []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.")) ] []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" ] []
]
Elem.div [ Ds.ignoreMorph ] [
Elem.div [ Ds.text "thisWillNotBeMorphed" ] []
]Sets the text content of an element to a reactive JSON stringified version of signals. Useful when troubleshooting an issue. Has options for restricting the signals displayed.
Elem.pre [ Ds.jsonSignals ] []
Elem.pre [ Ds.jsonSignalsOptions (SignalsFilter.Include "/foo/") ] []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 it as either being a signal path or an expression.
The $ symbol is a shorthand to get the value of the signal (e.g. $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 it requires the signal path, no $.
Ds.text is replacing the element's innerText, so it 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") ]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
- Responding with Signals
- Responding with HTML Elements
- Streaming Server Side Events
All requests are sent with a JSON {datastar: *} block 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.
Signals are streamed, so you do have to worry about not calling the getSignals methods a second time.
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.
[<CLIMutable>]
type MySignals =
{ firstName : string
lastName : string
email : string }
...
let httpHandler : HttpHandler = (fun ctx -> task {
let! signals : MySignals voption = Request.getSignals<MySignals> (ctx)
...
})Will return a System.Text.Json.JsonDocument of the signals.
let httpHandler : HttpHandler = (fun ctx -> task {
let! jsonDocument = Request.getSignals (ctx)
...
})Serializes signals with System.Text.Json.JsonSerializer and sends to client where Datastar will merge them.
Response.ofPatchSignals (MySignals())Updates a single signal on the client.
Response.ofPatchSignal (sp"user.firstName", "Don")Takes the signals as a JSON string and sends them to the client where Datastar will merge them.
Response.ofPatchSignals @" { ""firstName"": ""Don"", ""lastName"": ""Syme"" } "Given a seq of signal paths, will remove that signals from the client.
Response.ofRemoveSignals [ sp"user.firstName"; sp"user.lastName" ]HTML elements 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.
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.ofHtml ( Elem.h2 [ Attr.id "hello" ] [ Text.raw "Hello, World from the Server!" ] )Will send HTML fragments to the client. Client Datastar will replace the element with the matching id attribute (or optionally provided selector)
Response.ofHtmlStringElements @"<h2 id='hello'>Hello, World from the Server!"Will send a command to client Datastar to remove fragments with the matching selector.
Response.ofRemoveElements [ sel"hello" ]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 keeps a connection open to the server
and updates are streamed 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 Elements
are mirrored with a function with sse as their prefix instead of of.
let handleStream = (fun ctx -> task {
do! Response.sseStartResponse ctx
let mutable counter = 0
while true do // all Datastar methods (unless requested otherwise) will throw on ctx.RequestAborted
do! Response.ssePatchSignal ctx (sp"counter") counter
do! Response.sseHtmlElements ctx ( Elem.pre [ Attr.id "counterId" ] [ Text.raw counter.ToString() ] )
do! Task.Delay(TimeSpan.FromSeconds 1L, ctx.RequestAborted)
counter <- counter + 1See the Streaming example for more.