Templates and bindings
A template is a tree of elements, text and parts (attributes, properties, events and hooks) for one instance type 'inst. It is a value of type TemplateView<'inst>, built once when its component is defined. Every dynamic part is a function of the instance: when a component mounts, each instance is a copy of the template's static HTML plus one binding per dynamic part.
The examples use the sugar vocabulary. The explicit form at the end of the page builds the same trees. Every sample on this page compiles with these opens:
open Fable.Core.TS
open global.Ranvier
open Ranvier.View
open Ranvier.View.Sugar
Elements
A tag takes its parts as arguments and its children as a computation-expression body:
div (Attrs.className "panel", Attrs.id "main") {
h2 () { "Totals" }
input (Attrs.attribute ("type", "search"))
p () { }
}
()is an element with no parts.{ }is an element with no children; a non-void element needs it even when empty.- A void element (
input,br,img,metaand the other HTML void elements) takes parts and no body. Inside a body it is yielded as it is. In every other position that takes a view (a template root, the content ofshow, anarmOfview, afragmentchild, a row view) write itsView:(img (parts)).View. - An element's parts are typed by its DOM interface.
Props.Input.valueapplies toinputonly, and any other tag fails to compile with it.
type Avatar = { Shown: Signal<bool> }
let avatar =
Component.template (fun _ (shown: Signal<bool>) -> { Shown = shown }) (
span (Attrs.className "avatar") { show (fun a -> a.Shown.Value) (img (Attrs.attribute ("alt", "avatar"))).View }
)
let rule = Component.template (fun _ () -> ()) (hr (Attrs.className "rule")).View
Element names
Tag names are lowercase letters, digits and hyphens, starting with a letter; attribute names also allow underscores. The case-sensitive SVG names the HTML parser restores (viewBox, preserveAspectRatio, linearGradient, foreignObject, feGaussianBlur, refX and the others) are written as given, each matched as a whole name. Under Fable the module of the sugar's SVG tags registers them when it loads, so a module defining a template with such a name imports an SVG tag or follows a module that does; on .NET they are always accepted. Any other mixed-case name, such as className, raises, as does an attribute name that is empty, null, starts with on or is srcdoc.
Some elements are refused when a template or typed view mounts, under both commit modes:
- a
scriptelement, with or without children, in either namespace (t.element (HtmlTag "script"),t.script, an SVGscript): pages load scripts through the server document shell; - a raw-text element with children (
style,iframeand the others), andplaintext; - a
templateelement; - a void element with children.
A childless iframe mounts. The server render refuses every raw-text element. The server's definition path also refuses an RCDATA element (textarea, title) with element children, and one element's attribute names that differ only by case, such as viewBox and viewbox; a Fable client bundle accepts those last two.
Text
An element body yields text in three forms:
type Greeting = { Name: Signal<string>; Shout: Memo<string> }
let greeting =
Component.template
(fun _ (name: Signal<string>) -> { Name = name; Shout = createMemo (fun _ -> name.Value.ToUpper ()) })
(p (Attrs.className "greeting") {
"Hello, "
fun g -> g.Name.Value
"! "
from (fun g -> g.Shout)
})
| Yield | Result |
|---|---|
"Hello, " | A static text node. |
fun g -> g.Name.Value | A text binding. It reads the instance and re-runs when a signal or memo it read changes. |
from (fun g -> g.Shout) | A text binding over one signal or memo the instance holds. |
text read is the same node as a yielded reader. On the server, adjacent text nodes are separated by <!--!--> so that hydration finds each one:
<p class="greeting">Hello, <!--!--><Ada><!--!-->! <!--!--><ADA></p>
Text is always escaped. Raw markup goes through Unsafe.html.
Static structure
for, if and yield! inside a body run once, while the template is built. They repeat or omit structure in the template itself:
let columns = [ "Name"; "Size"; "Modified" ]
let header =
Component.template (fun _ () -> ()) (
table () {
thead () {
tr () {
for column in columns do
th () { column }
if columns.Length > 2 then
th (Attrs.className "actions") { }
}
}
}
)
Content that depends on the instance uses a list or an arm: see Lists and keyed rows and Conditional arms. A body line that reads a signal while the template is built reads it once, at definition time.
Attributes
Each attribute accepts a fixed value, a reader of the instance, or a from source. Readers and sources make the attribute a binding.
type Chore =
{ Title: Signal<string>
Note: Signal<string option>
Done: Signal<bool>
Owner: string }
let chore =
Component.template (fun _ (chore: Chore) -> chore) (
li (
Attrs.className "chore",
Attrs.title (fun c -> c.Title.Value),
Attrs.data ("owner", fun c -> Some c.Owner),
Attrs.attribute ("aria-describedby", fun c -> c.Note.Value |> Option.map (fun _ -> "note")),
Attrs.hidden (from (fun c -> c.Done))
) {
fun c -> c.Title.Value
}
)
- Named attributes (
className,id,title,lang,dir,role,hidden,inertand thearia*members) take astring, astring option, a reader returning either, or a source. - Optional values remove the attribute when they are
None. A reader returningnullalso removes it. - Boolean attributes (
hidden,inert,Attrs.Button.disabled,Attrs.Input.required, ...) take abooland are present with an empty value while it istrue. ARIA booleans (ariaExpanded,ariaBusy, ...) write"true"or"false". - Element groups hold the attributes of one element:
Attrs.Button,Attrs.Input,Attrs.Select,Attrs.TextAreaandAttrs.Option. dataandariatake a suffix:Attrs.data ("owner", ...)writesdata-owner.- Any other attribute goes through
Attrs.attribute (name, value), wherevalueis astring, a reader returningstring option, or a source ofstring option.
Properties
Props writes DOM properties: an input's current value, a button's disabled state, a checkbox's checked state. Attrs.Input.value writes the value attribute, which is the input's default value; Props.Input.value writes the live value.
type Field = { Text: Signal<string>; Busy: Signal<bool> }
let field =
Component.template (fun _ (field: Field) -> field) (
form () {
input (
Props.Input.value (from (fun f -> f.Text)),
Events.onInput (fun f event -> f.Text.Value <- (unbox<Dom.HTMLInputElement> event.currentTarget.Value).value)
)
button (Props.Button.disabled (fun f -> f.Busy.Value || f.Text.Value = "")) { "Save" }
}
)
Each generated property has a server form: value is written as the value attribute, disabled and checked as presence attributes, and a textarea's value as its child text. The field above renders as <form><input value=""><button disabled="">Save</button></form> for an empty text.
A property outside the generated set goes through Props.propertyStatic, Props.propertyWith or Props.propertyFrom with a writer of the native element:
type Selection = { Partial: Signal<bool> }
let selectAll =
Component.template (fun _ (selection: Selection) -> selection) (
label () {
input (
Attrs.attribute ("type", "checkbox"),
Props.propertyWith ((fun (box: Dom.HTMLInputElement) partial -> box.indeterminate <- partial), (fun s -> s.Partial.Value), primitiveSame)
)
"Select all"
}
)
The third argument compares the previous and next value; primitiveSame compares strings, numbers, booleans and options of them. Without a PropertySerialization argument the property is browser-only: the server's Reject policy raises for it, and Omit leaves it out (see Server rendering). PropertyHtml.text, PropertyHtml.number, PropertyHtml.presence, PropertyHtml.optional and PropertyHtml.childText give a custom property a server form.
How bindings re-run
Each instance has one render effect. It reads every binding of the instance in template order, then writes each value that differs from the one already in the DOM. A change to any signal read by any of the instance's bindings re-runs the effect, and the effect writes changed values only.
- A binding whose read is pending keeps the value already written. The enclosing boundary records the wait.
- A binding whose read throws keeps its value, and the failure goes to the enclosing boundary or, outside every boundary, to the mount's
report. - Reads in a binding are tracked; writes to the DOM are untracked.
- A binding with its own effect re-runs alone. Mark it with
t.isolated(below) when one value changes much more often than the rest of the instance.
Static text and fixed attribute values are written once, with the copy of the template.
Explicit-only members
Four template members have no sugar form. Use them inside Component.define setup (fun t -> ...), where free tags and t. members mix in one body:
| Member | Result |
|---|---|
t.isolated part / t.isolated (t.text read) | A binding with its own effect. |
t.view (fun i -> view) | A view hole that renders a Program.View, read once per instance. |
t.Unsafe.html content / t.Unsafe.html (fun i -> content) | The element's whole content, parsed from an UnsafeHtml value without escaping. |
t.Document.title, .meta, .link | Document head entries. |
type Note = { Html: UnsafeHtml; Count: Signal<int> }
let note =
Component.define (fun _ (note: Note) -> note) (fun t ->
div () {
div (t.Unsafe.html (fun n -> n.Html)) { }
span () { t.isolated (t.text (fun n -> string n.Count.Value)) }
})
UnsafeHtml.Trust markup wraps trusted or sanitised markup. Scripts, inline handlers and javascript: URLs in it run in server HTML; on the client, script elements stay inert and inline handlers and URLs run. An element with raw content declares no template children.
The explicit form
Component.define setup build passes build a TemplateBuilder<'inst>. Its members carry the instance type from setup's result into every lambda. Elements take a parts array and a children array, and text is t.text:
let greeting =
Component.define
(fun _ (name: Signal<string>) -> { Name = name; Shout = createMemo (fun _ -> name.Value.ToUpper ()) })
(fun t ->
t.p
[| t.Attrs.className "greeting" |]
[|
t.text "Hello, "
t.text (fun (g: Greeting) -> g.Name.Value)
t.text "! "
t.text (t.from (fun (g: Greeting) -> g.Shout))
|])
| Sugar | Explicit |
|---|---|
div (parts) { children } | t.div [\| parts \|] [\| children \|] |
input (parts) | t.input [\| parts \|] |
"text" / fun i -> ... / from (...) in a body | t.text "text" / t.text (fun i -> ...) / t.text (t.from (...)) |
Attrs, Props, Events, Hooks | t.Attrs, t.Props, t.Events, t.Hooks |
base', option' | t.base (a keyword, written in double backticks), t.option |
fragment [\| ... \|] | t.fragment [\| ... \|] |
Both forms produce the same template tree, the same server HTML and the same mounted resources.