Skip to content

Getting started with views

Build a counter component, compile it with Fable and mount it into a page. The finished app is the same shape as the playground, which is the runnable reference for everything in this section.

Project

Reference the Ranvier.View.Dom package; it brings in Ranvier.View and Ranvier. The view packages are on nuget.org and need the temporary Xantham Cloudsmith feed; Installation shows the nuget.config.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <Compile Include="Counter.fs" />
    <Compile Include="Main.fs" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Ranvier.View.Dom" Version="0.1.0-alpha.1" />
    <PackageReference Update="FSharp.Core" Version="10.1.401" />
  </ItemGroup>
</Project>

Install Fable 5.18.0 as a local tool:

dotnet new tool-manifest
dotnet tool install fable --version 5.18.0

The component

Counter.fs declares the props, the instance and the template on one record type:

module Counter

open global.Ranvier
open Ranvier.View
open Ranvier.View.Sugar

type CounterProps = { Start: int; Label: string }

type Counter =
    { Count: Signal<int>; Label: string }

    static member Create(_: Setup, props: CounterProps) =
        { Count = createSignal props.Start; Label = props.Label }

    static member View =
        div (Attrs.className "counter") {
            span (Attrs.className "label") { fun c -> c.Label }

            button (Attrs.attribute ("type", "button"), Events.onClick (fun c _ -> c.Count.Value <- c.Count.Value + 1)) {
                fun c -> string c.Count.Value
            }
        }

let counter = Component.ofMembers<Counter, _> ()
  • Create is the setup. It runs once for each mounted instance, untracked, and returns the instance. The signal it creates belongs to the instance's owner and is disposed with it.
  • View is the template. Component.ofMembers reads it once, when counter is bound, and every instance shares it.
  • A string inside an element body is static text. A fun c -> ... is a text binding: it reads the instance and re-runs when a signal it read changes.
  • Events.onClick receives the instance and the event.

The three open lines are the same in every view file: Ranvier for signals, Ranvier.View for the template types, and Ranvier.View.Sugar for the lowercase tags, Attrs, Events and Component.ofMembers. Sugar describes that vocabulary and its rules.

The same component without the sugar

Every sugar form lowers to Component.define setup build, where build receives a TemplateBuilder named t by convention. This definition renders the same HTML and mounts the same resources:

type Counter = { Count: Signal<int>; Label: string }

let counter =
    Component.define
        (fun _ (props: CounterProps) -> { Count = createSignal props.Start; Label = props.Label })
        (fun t ->
            t.div
                [| t.Attrs.className "counter" |]
                [|
                    t.span [| t.Attrs.className "label" |] [| t.text (fun (c: Counter) -> c.Label) |]
                    t.button
                        [|
                            t.Attrs.attribute ("type", "button")
                            t.Events.onClick (fun (c: Counter) _ -> c.Count.Value <- c.Count.Value + 1)
                        |]
                        [| t.text (fun (c: Counter) -> string c.Count.Value) |]
                |])

Mounting

Main.fs creates a graph, mounts the counter into #app and returns a function that disposes both:

module Main

open Fable.Core
open Fable.Core.TS.Dom
open global.Ranvier
open Ranvier.View
open Ranvier.View.Dom
open Counter

[<Global "document">]
let private document: Document = jsNative

let start () : unit -> unit =
    let graph = new Graph ()
    let host = document.getElementById "app" |> Option.get

    let report (errors: exn list) =
        for error in errors do
            JS.console.error ("Ranvier mount failure", error)

    let mounted =
        Mounting.mount graph None report host None (fun context ->
            Program.run (Component.render counter { Start = 0; Label = "Clicks" }) context.Backend)

    fun () ->
        mounted.Dispose ()
        graph.Dispose ()

Mounting.mount graph parentOwner report host before build builds the view under a new root owner of graph and inserts it into host before before (None appends). report receives failures that happen after the call returns, such as a throwing binding. The returned Mounted removes the view's nodes, effects and listeners when disposed. Mounts and lifetimes covers the arguments and the commit modes.

The mounted DOM is the template's HTML between two comment markers that delimit the mount:

<!--ranvier:start--><div class="counter"><span class="label">Clicks</span><button type="button">0</button></div><!--ranvier:end-->

The page

Compile the project to JavaScript and serve it with any bundler. With Vite:

npm install --save-dev vite
dotnet fable Counter.fsproj --outDir generated
npx vite
<!doctype html>
<html>
  <body>
    <main id="app"></main>
    <script type="module" src="/main.js"></script>
  </body>
</html>
// main.js
import { start } from "./generated/Main.js";

const dispose = start();
if (import.meta.hot) import.meta.hot.dispose(() => dispose());

Each click writes Count; the button's text node changes, and the button keeps its identity.

Next

Edit this page