Skip to content

// Fine-grained reactivity for F#

Reactive values.
Nothing else
recomputes.

Focused F# libraries for reactive Fable applications. Each library does one thing and can be used on its own.

> dotnet add package Fable.Ripple --prerelease
Todos.fs
open Fable.Ripple

type Todo = { Text: string; Done: bool }

let todos =
    Var.create [
        { Text = "Write the guide"; Done = true }
        { Text = "Ship 1.0"; Done = false }
    ]

let remaining =
    Signal.computed (fun () ->
        todos.Value
        |> List.filter (fun t -> not t.Done)
        |> List.length
    )

printfn "Remaining = %d" remaining.Value

todos.Value <-
    todos.Value
    |> List.map (fun t ->
        if t.Text = "Ship 1.0" then
            printfn "Completed: Ship 1.0"
            { t with Done = true }
        else
            t
    )

printfn "Remaining = %d" remaining.Value

Synchronous

A write settles before the next line runs. No scheduler, no tick.

Glitch-free

A node runs once per write, after all its inputs are current.

Renderer-agnostic

The core knows nothing about the DOM. Rendering ships separately.

Counter.fs
open Fable.Ripple
open Fable.Ripple.Dom

let counter () =
    let count = Var.create 0

    Html.div
        [
            Html.button
                [
                    on.click (fun _ ->
                        count.Value <- count.Value + 1
                    )
                    Html.text "Count"
                ]
            Html.output count
        ]

Html.mount "app" (counter ())

// Rendering

Signals, bound
straight to the DOM.

Fable.Ripple.Dom is an HTML DSL with no virtual tree. A signal knows which nodes read it, so a write updates those nodes and nothing else - no diff, no re-render of the component around them.

  • One list model - attributes, events and children are items in the same list.
  • Two-way bindings - attr.bindValue keeps an input and a Var in sync.
  • Control flow - Html.show and keyed Html.each add and remove real nodes.
> dotnet add package Fable.Ripple.Dom --prerelease

// Routing

URLs parsed into
your own types.

Fable.UrlParser turns a URL into a value of your route type. Parsers compose left to right, in the order of the URL, and a failure reports the attempt that got furthest rather than a blank no-match.

  • Typed segments - string, int, and your own conversions.
  • Query and fragment - required, optional, repeated and flag parameters.
  • Two-way codecs - one description parses a URL and builds it back.
> dotnet add package Fable.UrlParser --prerelease
Routes.fs
open Fable.UrlParser

type Route =
    | Home
    | Blog of id: int
    | Search of query: string * page: int option

let routes =
    [
        Parser.succeed Home

        Parser.succeed (fun id -> Blog id)
        |> Parser.segment "blog"
        |> Parser.int

        Parser.succeed (fun q page -> Search(q, page))
        |> Parser.segment "search"
        |> Parser.Query.Required.string "q"
        |> Parser.Query.Optional.int "page"
    ]

[ "/blog/42"; "/search?q=signals&page=3"; "/blog/latest" ]
|> List.iter (fun url ->
    Parser.tryParsePath routes url |> printfn "%s -> %A" url
)

// Forms

Typed forms,
one Var per field.

Fable.Ripple.Form composes fields into a form whose output is your own record. A parser turns each value into a typed result, and a parser that reads another field re-runs when that field changes. A keystroke writes one Var and touches no node.

  • Combinators - append, andThen, optional, showIf, list.
  • Validation - on submit, blur or change; async checks with debounce.
  • Renderers - Bulma, plain classes, or in-place editing. Or write your own.
> dotnet add package Fable.Ripple.Form.Plain --prerelease
SignUp.fs
open Fable.Ripple
open Fable.Ripple.Dom
open Fable.Ripple.Form
open Fable.Ripple.Form.Plain

let email = Var.create ""
let password = Var.create ""
let repeat = Var.create ""

let form =
    Form.succeed (fun email password _ -> email, password)
    |> Form.append (
        EmailField.create "email"
        |> EmailField.withLabel "Email"
        |> Field.create email (fun value ->
            if value.Contains "@" then Ok value else Error "An email needs an @"
        )
        |> Form.emailField
    )
    |> Form.append (
        PasswordField.create "password"
        |> PasswordField.withLabel "Password"
        |> Field.create password Ok
        |> Form.passwordField
    )
    |> Form.append (
        PasswordField.create "repeat"
        |> PasswordField.withLabel "Repeat password"
        |> Field.create repeat (fun value ->
            if value = password.Value then Ok() else Error "The passwords do not match"
        )
        |> Form.passwordField
    )

let state = Var.create View.Idle

Html.mount
    "app"
    (Form.View.asHtml
        {
            OnSubmit = fun (email, _) -> state.Value <- View.Success $"Welcome, %s{email}."
            State = state
            ErrorVisibility = View.errorVisibility ()
            Action = View.Action.SubmitOnly "Sign up"
            Validation = ValidateOnBlur
        }
        form)