Skip to content

Solid-JS

This page covers the solid-js bindings in Partas.Solid 3.0, which target Solid 2.0 (2.0.0-rc.9). The examples run live on the page.

Solid 2.0 removed or renamed a large part of the 1.x API. If you are upgrading, read Migrating to Solid 2 first. Removed APIs maps each removed API to its replacement.

How the bindings are laid out

  • Everything imported from solid-js sits on the Bindings type in the Partas.Solid namespace. It is [<AutoOpen>], so open Partas.Solid is all you need. The functions are static members, so they can have overloads.
  • The web runtime (@solidjs/web) lives in the Partas.Solid.Web namespace: render, hydrate, renderToString, renderToStream, isServer, isDev, clientOnly, Portal and Dynamic. Add open Partas.Solid.Web to use them. DEV stays in Partas.Solid.
  • Stores are part of solid-js now. There is no solid-js/store module.
  • A control-flow component that takes children implements HtmlContainer. One that takes a function as its child implements a child-lambda interface, and you pass the function with yield fun ... -> ....

Signals

type Accessor<'T> = unit -> 'T
type Setter<'T> = 'T -> unit
type Signal<'T> = Accessor<'T> * Setter<'T>

createSignal returns an accessor and a setter. Call the accessor to read the value. Reading it inside a tracking scope (a memo, the compute half of an effect, or JSX) subscribes that scope to it.

[<SolidComponent>]
let SignalCounter () =
    let count, setCount = createSignal 0

    button (onClick = fun _ -> setCount (count () + 1)) {
        $"Clicked {count ()} times"
    }

Solid 2 batches writes. A setter queues the change, and the DOM updates on the next microtask, not when the setter returns. Call flush () when you need the update applied immediately.

createSignal

CallReturns
createSignal valueSignal<'T>A plain signal
createSignal<'T> ()Signal<'T option>Starts as None (undefined in JS)
createSignal<'T> (fun () -> ...)Signal<'T>A writable derived signal: it recomputes from its sources, and you can still set it
createSignal (value, equals = ..., name = ..., ownedWrite = ..., unobserved = ...)Signal<'T>Options as named arguments
createSignal (value, SignalOptions(...))Signal<'T>Options as an object
let name, setName = createSignal<string> ()          // Signal<string option>
let doubled, setDoubled = createSignal<int> (fun () -> count () * 2)
let point, setPoint =
    createSignal ({ x = 0; y = 0 }, equals = EqualityFunc (fun prev next -> prev = next))

equals takes an EqualityFunc<'T>, a delegate of prev * next -> bool. Return true to treat the new value as unchanged, so nothing downstream runs.

A function passed as the argument to createSignal makes a derived signal. The type argument is there because the value and function overloads are otherwise ambiguous for a lambda (FS0041).

Setting with the previous value

A setter is typed 'T -> unit, so F# will not let you pass it an updater function directly. Use the Invoke extension:

let index, setIndex = createSignal 0

setIndex 5                         // replace the value
setIndex.Invoke 5                  // same thing
setIndex.Invoke (fun i -> i + 1)   // compute the next value from the previous one

InvokeAndGet also exists, with the same two overloads. It sets the value and returns the new value.

Memos

createMemo (fun (prev: 'T option) -> ...) : Accessor<'T>

A memo is a cached, read-only derived value. The compute function tracks what it reads, runs again when any of it changes, and only notifies its own readers when the result is different. It gets the previous result as an option: None on the first run.

[<SolidComponent>]
let MemoDemo () =
    let count, setCount = createSignal 1
    let doubled = createMemo (fun (_: int option) -> count () * 2)
    let runningTotal = createMemo (fun (prev: int option) -> defaultArg prev 0 + count ())

    div () {
        button (onClick = fun _ -> setCount (count () + 1)) { "Add one" }
        p () { $"count = {count ()}, doubled = {doubled ()}, running total = {runningTotal ()}" }
    }

Annotate the parameter (fun (_: int option) -> ...). It tells F# which overload you mean, and it keeps the result type from being inferred as a promise.

Options go in as named arguments (name, equals, transparent, unobserved, lazy, sync, loadingValue) or as a MemoOptions object.

let expensive = createMemo ((fun (_: int option) -> heavyWork (input ())), ``lazy`` = true)

A lazy memo does not compute until something reads it.

Example: fuzzy search with Fuse.js

A memo can wrap any pure computation, including one from an npm package. The binding below covers only the parts of Fuse.js the example uses. [<ImportDefault("fuse.js")>] on a class makes Fuse (...) compile to new Fuse(...) on the package's default export.

type FuseMatch =
    abstract indices: (int * int) array

type FuseResult<'T> =
    abstract item: 'T
    abstract matches: FuseMatch array

[<ImportDefault("fuse.js")>]
type Fuse<'T>(docs: 'T array, options: obj) =
    member _.search(query: string, options: obj) : FuseResult<'T> array = jsNative

The index is built once, when the module loads. hits re-runs only when query changes, and For.Keyed renders its result. Try sgnal, stor or clean.

type HitPart = { text: string; hit: bool }
type ApiHit = { name: string; parts: HitPart array }

let solidApiNames =
    [| "createSignal"; "createMemo"; "createEffect"; "createRenderEffect"; "createTrackedEffect"
       "createReaction"; "createStore"; "createProjection"; "createOptimistic"; "createContext"
       "useContext"; "createRoot"; "onSettled"; "onCleanup"; "flush"; "untrack"; "isPending"
       "latest"; "refresh"; "action"; "mapArray"; "children"; "merge"; "omit"; "createUniqueId"
       "For"; "Show"; "Switch"; "Match"; "Errored"; "Loading"; "Repeat"; "Reveal" |]

// Split a name into plain and matched runs, from Fuse's inclusive [start, end] ranges.
let splitMatches (text: string) (ranges: (int * int) array) =
    let parts = ResizeArray ()
    let mutable pos = 0
    for (first, last) in ranges do
        if first > pos then parts.Add { text = text.Substring (pos, first - pos); hit = false }
        parts.Add { text = text.Substring (first, last - first + 1); hit = true }
        pos <- last + 1
    if pos < text.Length then parts.Add { text = text.Substring pos; hit = false }
    parts.ToArray ()

// Build the index outside the component. Inside one, the plugin reads `Fuse (...)` as a JSX tag.
let apiIndex =
    Fuse (solidApiNames, {| includeMatches = true; ignoreLocation = true; threshold = 0.4; minMatchCharLength = 2 |})

[<SolidComponent>]
let ApiSearch () =
    let query, setQuery = createSignal "efect"

    // Re-runs only when query changes.
    let hits =
        createMemo (fun (_: ApiHit array option) ->
            apiIndex.search (query (), {| limit = 6 |})
            |> Array.map (fun r ->
                let ranges = if r.matches.Length > 0 then r.matches[0].indices else [||]
                { name = r.item; parts = splitMatches r.item ranges }))

    div (style = "display: grid; gap: .75rem; width: 100%; max-width: 24rem") {
        input (
            type' = "search",
            value = query (),
            placeholder = "Search the solid-js API",
            style = "width: 100%; height: 2.5rem; padding: 0 .75rem",
            onInput = fun e -> setQuery (!!e.currentTarget?value)
        )
        ul (style = "list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: .5rem") {
            For.Keyed(each = hits (), fallback = li (style = "color: var(--nacara-text-muted)") { "No match" }) {
                yield fun hit _ ->
                    li (style = "margin: 0") {
                        code (style = "color: var(--nacara-text-muted)") {
                            For.Keyed(each = hit.parts) {
                                yield fun part _ ->
                                    span (style = if part.hit then "color: var(--nacara-heading); font-weight: 650" else "") {
                                        part.text
                                    }
                            }
                        }
                    }
            }
        }
    }

Async memos

A memo whose compute returns a JS.Promise<'T> gives you an Accessor<'T>. Reading it before the promise settles suspends the reader, and the nearest Loading boundary shows its fallback. There is no createResource in Solid 2: an async memo is its replacement.

let user = createMemo (fun (_: User option) -> fetchUser (userId ()))

createMemo (compute, loadingValue) gives the memo a value to hold while the first promise is pending.

Effects

Solid 2 splits an effect into two functions:

  1. compute runs in a tracking scope. It reads signals and returns a value. It gets the previous value as an option.
  2. effect gets that value and does the side effect. Nothing it reads is tracked.
createEffect (
    (fun (_: int option) -> count ()),
    fun (n: int) -> console.log $"count is {n}"
)

Annotate the compute function's parameter, as with a memo, so F# can pick the overload.

The effect runs after the first render, then again each time the compute result changes. The single-function createEffect (fun () -> ...) from Solid 1 no longer exists.

[<SolidComponent>]
let EffectDemo () =
    let count, setCount = createSignal 0
    let message, setMessage = createSignal "The effect has not run yet"

    createEffect ((fun (_: int option) -> count ()), (fun (n: int) -> setMessage $"The effect last saw {n}"))

    div () {
        button (onClick = fun _ -> setCount (count () + 1)) { "Click" }
        p () { message () }
    }

This example writes a signal from an effect to show when the effect runs. In real code, derive the value with a memo instead.

Cleanup

The effect function can return a cleanup, a unit -> unit. It runs before the effect runs again, and when the owner is disposed:

createEffect (
    (fun (_: int option) -> delayMs ()),
    fun (ms: int) ->
        let id = JS.setInterval tick ms
        fun () -> JS.clearInterval id
)

createRenderEffect, createTrackedEffect, createReaction and onSettled accept a cleanup-returning function in the same way. The primed imports such as createEffect' are hidden implementation details of these overloads; do not call them.

Example: confetti on milestones

The compute half decides when the effect function runs. Below it returns count () / 10, which only changes at 10, 20, 30 and so on, so clicks in between do not run the effect.

canvas-confetti's default export is a function with a reset method attached, so it is bound twice: as a let that takes parameters (a plain call), and as an anonymous record exposing reset. onCleanup clears any confetti still falling when the example is disposed.

[<ImportDefault("canvas-confetti")>]
let confetti (options: obj) : unit = jsNative

[<ImportDefault("canvas-confetti")>]
let confettiApi: {| reset: unit -> unit |} = jsNative
[<SolidComponent>]
let ConfettiCounter () =
    let count, setCount = createSignal 0
    let mutable launcher: Browser.Types.HTMLButtonElement = JS.undefined

    createEffect (
        (fun (_: int option) -> count () / 10),
        fun (milestone: int) ->
            if milestone > 0 then
                let r = launcher.getBoundingClientRect ()
                confetti {|
                    particleCount = 90
                    spread = 70
                    startVelocity = 35
                    origin = {| x = (r.left + r.width / 2.) / Browser.Dom.window.innerWidth
                                y = (r.top + r.height / 2.) / Browser.Dom.window.innerHeight |}
                    colors = [| "#f28b5b"; "#b845fc"; "#1d8fe0"; "#1fd8e8"; "#6366f1" |]
                    disableForReducedMotion = true
                |}
    )

    onCleanup (fun () -> confettiApi.reset ())

    div (style = "display: grid; gap: .75rem; justify-items: start") {
        div (style = "display: flex; gap: .5rem") {
            button(class' = "p-btn p-btn--accent", onClick = fun _ -> setCount (count () + 1)).ref (launcher) {
                $"Clicks: {count ()}"
            }
            button (class' = "p-btn p-btn--secondary", onClick = fun _ -> setCount 0) { "Reset" }
        }
        // The track and its fill are one element: the fill is a sized background layer.
        div (
            style =
                $"width: 14rem; height: .375rem; border-radius: 999px; border: 1px solid var(--nacara-border); background: linear-gradient(90deg, #f28b5b, #b845fc) 0 0 / {count () % 10 * 10}%% 100%% no-repeat, var(--nacara-bg-subtle); transition: background-size .2s"
        )
        p (style = "margin: 0; font-size: .875rem; color: var(--nacara-text-muted)") {
            $"{10 - count () % 10} more to the next burst"
        }
    }
export function ConfettiCounter() {
    const patternInput = createSignal(0);
    const setCount = patternInput[1];
    const count = patternInput[0];
    let launcher = void 0;
    createEffect((_arg) => (~~(count() / 10) | 0), (milestone) => {
        if (milestone > 0) {
            const r = launcher.getBoundingClientRect();
            canvas_confetti({
                colors: ["#f28b5b", "#b845fc", "#1d8fe0", "#1fd8e8", "#6366f1"],
                disableForReducedMotion: true,
                origin: {
                    x: (r.left + (r.width / 2)) / window.innerWidth,
                    y: (r.top + (r.height / 2)) / window.innerHeight,
                },
                particleCount: 90,
                spread: 70,
                startVelocity: 35,
            });
        }
    });
    onCleanup(() => {
        canvas_confetti.reset();
    });
    return <div style="display: grid; gap: .75rem; justify-items: start">
        <div style="display: flex; gap: .5rem">
            <button class="p-btn p-btn--accent"
                onClick={(_arg_1) => {
                    setCount(count() + 1);
                }}
                ref={launcher}>
                {`Clicks: ${count()}`}
            </button>
            <button class="p-btn p-btn--secondary"
                onClick={(_arg_2) => {
                    setCount(0);
                }}>
                Reset
            </button>
        </div>
        <div style={`width: 14rem; height: .375rem; border-radius: 999px; border: 1px solid var(--nacara-border); background: linear-gradient(90deg, #f28b5b, #b845fc) 0 0 / ${(count() % 10) * 10}% 100% no-repeat, var(--nacara-bg-subtle); transition: background-size .2s`} />
        <p style="margin: 0; font-size: .875rem; color: var(--nacara-text-muted)">
            {`${10 - (count() % 10)} more to the next burst`}
        </p>
    </div>;
}

Options and errors

Pass options as named arguments (defer, schedule, sync, transparent, name) or as an EffectOptions object. defer = true skips the first run of the effect function, so it first runs on the first change.

createEffect ((fun (_: int option) -> value ()), (fun (v: int) -> save v), defer = true)

To handle an error thrown by the compute function, add an error handler as the third argument:

createEffect (
    (fun (_: int option) -> parse (input ())),
    effect = (fun (v: int) -> show v),
    error = (fun (err: obj) -> console.error err)
)

EffectBundle (effect, error) passes the two as one object. Its handler is an EffectErrorHandler, which also receives the effect's cleanup:

createEffect (
    (fun (_: int option) -> parse (input ())),
    EffectBundle (
        (fun (v: int) -> show v),
        EffectErrorHandler (fun err cleanup ->
            console.error err
            cleanup ())
    )
)

Other effect primitives

FunctionUse
createRenderEffect (compute, effect)Same two phases, but the first effect run happens immediately, during rendering. Refs are not attached yet. Within a flush, render effects run before user effects. No error overload.
createTrackedEffect (fun () -> ...)A single function that both tracks and does the side effect. Prefer createEffect; this is for cases where the two phases cannot be separated.
createReaction effectReturns a track function. Call track (fun () -> box (source ())) to name what to watch; the next time it changes, effect runs once. Call track again to re-arm it.
onSettled (fun () -> ...)Runs once after the owner's first render has settled. It replaces onMount.
onCleanup (fun () -> ...)Runs when the current owner is disposed or re-runs.
let isOpen, setOpen = createSignal false
let track = createReaction (fun () -> console.log "opened for the first time")
track (fun () -> box (isOpen ()))

flush and untrack

flush () applies all queued writes now. flush (fun () -> ...) runs the function, then flushes, and returns what it returned. It replaces batch: writes are always batched in Solid 2, and flush is how you ask for them to land.

flush (fun () ->
    setFirst "Ada"
    setLast "Lovelace")

untrack (fun () -> ...) reads signals without subscribing to them:

let total = createMemo (fun (_: int option) -> value () + untrack other)

Stores

type Store<'T>                                // read with .Value, or let it convert to 'T
type StoreSetter<'T> = ('T -> 'T) -> unit
type StoreReturn<'T> = Store<'T> * StoreSetter<'T>

A store is a deeply reactive object. Every property you read through it is tracked on its own, so changing one field only updates what read that field.

  • Read through .Value: state.Value.todos. Store<'T> also converts implicitly to 'T.
  • The setter takes an updater, never a value. Either mutate the draft and return it, or return a new object.
type Settings = { mutable theme: string; mutable fontSize: int }

let settings, setSettings = createStore { theme = "light"; fontSize = 14 }

// mutate the draft in place
setSettings (fun s ->
    s.fontSize <- 16
    s)

// or return a replacement
setSettings (fun s -> { s with theme = "dark" })

Mark the fields you mutate as mutable. The draft is a proxy, so writing to it records exactly which properties changed.

type CartLine = { name: string; mutable qty: int }

[<SolidComponent>]
let StoreCart () =
    let cart, setCart =
        createStore<CartLine array> [| { name = "Apples"; qty = 1 }; { name = "Pears"; qty = 0 } |]

    let total =
        createMemo (fun (_: int option) -> cart.Value |> Array.sumBy (fun l -> l.qty))

    let addOne (name: string) =
        setCart (fun lines ->
            for l in lines do
                if l.name = name then
                    l.qty <- l.qty + 1

            lines)

    div () {
        ul () {
            For.Keyed(each = cart.Value) {
                yield fun line _ ->
                    li () {
                        button (onClick = fun _ -> addOne line.name) { "+1" }
                        span () { $"{line.name}: {line.qty}" }
                    }
            }
        }

        p () { $"Total items: {total ()}" }
    }

The rows are keyed by the line objects. Adding one updates the count in that row without re-creating it.

createStore has a few overloads that can clash for a value like an array literal. When F# reports FS0041, give the type argument explicitly, as above: createStore<CartLine array> [| ... |]. createStore (value, name = ..., shallow = ...) and createStore (value, StoreOptions (...)) take options.

reconcile, snapshot and deep

FunctionUse
reconcile nextReturns an updater that diffs next into the store, so only changed fields notify. Use it as setStore (reconcile next). reconcile (next, "id") or reconcile (next, fun item -> box item.id) sets how array items are matched.
snapshot storeA plain, non-reactive copy of the store's current value. It replaces unwrap.
deep storeReads every nested property, so a tracking scope that calls it re-runs on any change inside the store.
setTodos (reconcile freshFromServer)

createEffect ((fun _ -> deep settings), (fun s -> localStorage.setItem ("settings", JSON.stringify s)))

Derived stores and projections

createStore (fn, seed) makes a writable store derived from other state. The function gets the current draft. Return Some new value, or mutate the draft and return None. It may return a promise, in which case reads suspend like an async memo. createProjection (fn, seed) does the same with projection options. It is the Solid 2 replacement for createSelector.

type Row = { id: int; mutable active: bool }

let row, setRow =
    createStore (
        (fun (draft: Row) ->
            draft.active <- draft.id = selectedId ()
            U2.Case1 None),
        { id = 1; active = false }
    )

A seed type with an id member uses it as the key. Otherwise pass a key function as the third argument.

createStore (fn, seed) returns a RefreshableStoreReturn<'T>, a store and setter pair. createProjection (fn, seed) returns the store alone, as a RefreshableStore<'T>. Read it with .Value, convert it to a Store<'T> with .AsStore, or to a Refreshable<'T> for refresh with .AsRefreshable.

let selection = createProjection (project selected, seed)
let isA () = selection.Value.a

Control flow

For

The raw For is hidden. Pick one of three variants by how rows are keyed:

ComponentChild functionRows are keyed by
For.Keyed<'T>fun (item: 'T) (index: Accessor<int>) -> ...Item identity. A row is created once per item and moved when the list reorders.
For.NonKeyed<'T>fun (item: Accessor<'T>) (index: int) -> ...Position. Rows stay put and their item accessor updates. This replaces Index.
For.KeyedFn<'T>fun (item: Accessor<'T>) (index: Accessor<int>) -> ...The result of keyed = fun item -> ..., for example an id.

For.Component is an alias of For.Keyed. Each takes each (an array) and an optional fallback, shown when the array is empty.

type Fruit = { id: int; name: string }

[<SolidComponent>]
let FruitList () =
    let fruits, setFruits =
        createSignal [| { id = 1; name = "Apple" }; { id = 2; name = "Pear" } |]

    let nextId, setNextId = createSignal 3

    div () {
        button (
            onClick =
                fun _ ->
                    let id = nextId ()
                    setNextId (id + 1)
                    setFruits (Array.append (fruits ()) [| { id = id; name = $"Fruit {id}" } |])
        ) {
            "Add"
        }

        button (onClick = fun _ -> setFruits (Array.rev (fruits ()))) { "Reverse" }
        button (onClick = fun _ -> setFruits [||]) { "Clear" }

        ol () {
            For.Keyed(each = fruits (), fallback = li () { "No fruit" }) {
                yield fun fruit index -> li () { $"{fruit.name} (row {index ()})" }
            }
        }
    }
export class Fruit extends Record {
    constructor(id, name) {
        super();
        this.id = (id | 0);
        this.name = name;
    }
}

export function FruitList() {
    const patternInput = createSignal([new Fruit(1, "Apple"), new Fruit(2, "Pear")]);
    const setFruits = patternInput[1];
    const fruits = patternInput[0];
    const patternInput_1 = createSignal(3);
    return <div>
        <button onClick={(_arg) => {
                const id = patternInput_1[0]() | 0;
                patternInput_1[1](id + 1);
                setFruits(append(fruits(), [new Fruit(id, `Fruit ${id}`)]));
            }}>
            Add
        </button>
        <button onClick={(_arg_1) => {
                setFruits(reverse(fruits()));
            }}>
            Reverse
        </button>
        <button onClick={(_arg_2) => {
                setFruits([]);
            }}>
            Clear
        </button>
        <ol>
            <KeyedFor each={fruits()}
                fallback={<li>
                    No fruit
                </li>}>
                {(fruit, index) => <li>
                    {`${fruit.name} (row ${index()})`}
                </li>}
            </KeyedFor>
        </ol>
    </div>;
}
For.NonKeyed(each = names ()) {
    yield fun name index -> li () { string index + ": " + name () }
}

For.KeyedFn(each = users (), keyed = (fun u -> box u.id)) {
    yield fun user index -> li () { user().name }
}

Show

Show renders its children while when' is true, and fallback otherwise.

[<SolidComponent>]
let ShowDemo () =
    let isOpen, setOpen = createSignal false

    div () {
        button (onClick = fun _ -> setOpen (not (isOpen ()))) { "Toggle" }

        Show(when' = isOpen (), fallback = p () { "Closed" }) {
            p () { "Open" }
        }
    }

To use the value that when' checked, pass a child function. Show.Keyed gives the child the value itself, and re-creates the child when the value changes. Show.NonKeyed gives it an accessor, and keeps the child while the value stays truthy.

Show.Keyed(when' = selectedUser (), fallback = span () { "Nobody selected" }) {
    yield fun (user: User option) -> span () { user.Value.name }
}

Show.NonKeyed(when' = selectedUser ()) {
    yield fun (user: Accessor<User option>) -> span () { user().Value.name }
}

Show<'T> is the generic form, with a keyed flag. Prefer the two variants above, which type the child for you.

Switch and Match

Switch renders the first Match whose when' is true, or its fallback.

[<SolidComponent>]
let TrafficLightDemo () =
    let light, setLight = createSignal "red"

    let next () =
        match light () with
        | "red" -> setLight "green"
        | "green" -> setLight "amber"
        | _ -> setLight "red"

    div () {
        button (onClick = fun _ -> next ()) { "Next light" }

        Switch(fallback = span () { "Unknown" }) {
            Match(when' = (light () = "red")) { span (style = "color: crimson") { "Stop" } }
            Match(when' = (light () = "amber")) { span (style = "color: orange") { "Wait" } }
            Match(when' = (light () = "green")) { span (style = "color: green") { "Go" } }
        }
    }

Match<'T> takes a child function like Show<'T>, and has a when'option setter for option values. Match.Keyed and Match.NonKeyed mirror the Show variants.

Errored

Errored replaces ErrorBoundary. When something inside it throws, it renders fallback instead. The fallback is either an element, or an ErrorBoundary.Fallback delegate that gets the error (as an Accessor<obj>) and a reset function.

type ErrorBoundary.Fallback = delegate of err: Accessor<obj> * reset: (unit -> unit) -> HtmlElement
[<SolidComponent>]
let Fragile (value: Accessor<int>) =
    let checkedValue =
        createMemo (fun (_: int option) ->
            let v = value ()

            if v > 2 then
                failwith ("Too big: " + string v)

            v)

    span () { checkedValue () }

[<SolidComponent>]
let ErroredDemo () =
    let value, setValue = createSignal 0

    div () {
        button (onClick = fun _ -> setValue (value () + 1)) { "Increase" }

        Errored(
            fallback =
                !^(ErrorBoundary.Fallback(fun err reset ->
                    div () {
                        span () { (err () :?> exn).Message }

                        button (
                            onClick =
                                fun _ ->
                                    setValue 0
                                    reset ()
                        ) {
                            "Reset"
                        }
                    }))
        ) {
            Fragile value
        }
    }

Press Increase until the value passes 2. The memo throws, and the boundary shows the message. Reset sets the value back and re-renders the children.

Errored also has fallbackFn and fallbackEle setters, typed shortcuts for the two kinds of fallback. They set fallback without the !^.

Loading

Loading replaces Suspense. While any async read inside it is pending, it shows fallback. Once everything has settled the first time, later changes keep the old content on screen until the new data arrives. Use isPending to show that a refresh is in progress.

[<SolidComponent>]
let GreetingLoader () =
    let name, setName = createSignal "Ada"

    let greeting =
        createMemo (fun (_: string option) -> resolveAfter 800 ("Hello, " + name ()))

    let stale =
        createMemo (fun (_: bool option) -> isPending (fun () -> box (greeting ())))

    div () {
        button (onClick = fun _ -> setName "Ada") { "Ada" }
        button (onClick = fun _ -> setName "Grace") { "Grace" }

        Loading(fallback = p () { "Loading..." }) {
            p (style = (if stale () then "opacity: 0.5" else "")) { greeting () }
        }
    }

Everything inside Loading is replaced by the fallback on the first load, so wrap only the part that depends on the data, not the whole page.

With on set, the boundary only shows its fallback again for changes caused by writes to that source. Other changes keep the old content.

Loading(fallback = Skeleton (), on = route ()) { Page () }

Repeat

Repeat renders its child function count times, passing the index. It needs no array. from sets the first index, and fallback shows when count is 0.

[<SolidComponent>]
let RepeatDemo () =
    let count, setCount = createSignal 3

    div () {
        button (onClick = fun _ -> setCount (count () + 1)) { "More" }
        button (onClick = fun _ -> setCount (max 0 (count () - 1))) { "Fewer" }

        p () {
            Repeat(count = count (), fallback = em () { "No stars" }) {
                yield fun i -> b (title = string i) { "*" }
            }
        }
    }

Reveal

Reveal coordinates when sibling Loading boundaries show their content. It replaces SuspenseList.

orderBehaviour
Reveal.Order.Sequential (default)Boundaries reveal in order. A later one waits for the ones before it.
Reveal.Order.TogetherNothing reveals until every boundary is ready, then all reveal at once.
Reveal.Order.NaturalEach boundary reveals when its own data is ready. Useful when nested in another Reveal.

collapsed = true hides the fallbacks of boundaries that are still waiting their turn. It only applies to Sequential.

Reveal(order = Reveal.Order.Sequential) {
    Loading(fallback = span () { "..." }) { Profile () }
    Loading(fallback = span () { "..." }) { Posts () }
}

Hydration and NoHydration

Hydration(id = ...) and NoHydration() are unchanged from Solid 1. NoHydration renders its children on the server and skips them when hydrating.

Portal and Dynamic

Both come from @solidjs/web, so they are in Partas.Solid.Web. Portal(mount = element) { ... } renders its children into another part of the document. Dynamic renders a tag or component chosen at run time.

Dynamic<obj>(componentAsString = props.tag) { "content" }

The dynamic (fun () -> ...) function returns a TagValue whose tag follows its source. Render it with Field.render () or Field % {| ... |}, where Field is the bound value.

Context

type Context<'T> = 'T -> ContextProvider

createContext makes a context, optionally with a default value. useContext reads the nearest value. tryUseContext returns a Result<'T, ContextNotFoundError> instead of throwing when there is no provider and no default. Calling the context with a value provides it to the children.

let ThemeContext = createContext<string> "light"

[<SolidComponent>]
let ThemedLabel () =
    let theme = useContext ThemeContext
    span () { theme }

[<SolidComponent>]
let ThemeApp () =
    div () {
        ThemedLabel ()          // "light", the default

        ThemeContext "dark" {   // provides "dark" to its children
            ThemedLabel ()
        }
    }
export const ThemeContext = createContext("light");

export function ThemedLabel() {
    const theme = useContext(ThemeContext);
    return <span>
        {theme}
    </span>;
}

export function ThemeApp() {
    return <div>
        {untrack(ThemedLabel)}
        <ThemeContext value={"dark"}>
            {untrack(ThemedLabel)}
        </ThemeContext>
    </div>;
}

To share state, put accessors and functions in the context value, for example a [<JS.Pojo>] type with a count: Accessor<int> and an increment: unit -> unit.

Async

An async memo, an async derived store and a lazy' component all suspend their readers, and Loading catches them.

isPending and latest

isPending (fun () -> box (source ())) is true while the source is refreshing after it first settled. It does not trigger Loading, so you can use it to dim old content, as in the Loading example.

latest (fun () -> source ()) reads the most recent value, even while a newer one is pending.

refresh

refresh target re-runs an async memo or derived store, and returns a promise of the new value. The target is an Accessor<'T> (a memo) or a Refreshable<'T>.

button (onClick = fun _ -> refresh user |> ignore) { "Reload" }

until

until (fun () -> condition) returns a promise that resolves once the condition is truthy. timeout rejects it with a TimeoutError after that many milliseconds, and signal takes an AbortSignal. Do not call it inside a tracking scope.

promise {
    let! _ = until ((fun () -> count () >= 3), timeout = 5000)
    console.log "reached 3"
}

action

action wraps a JS generator function in a transition. Each yield waits on a promise. Writes to optimistic state inside it show immediately, and are replaced by the real values when it finishes. It returns a function that returns a promise.

action (genFn: 'Args -> 'Gen) : 'Args -> JS.Promise<'R>

F# has no generator syntax, so the generator must come from JS or be written by hand as an object with next and throw.

createOptimistic and createOptimisticStore

createOptimistic makes a signal whose writes are temporary. Inside an action, a write shows at once and is reverted when the action ends, unless the real source has changed to match. createOptimistic<'T> (fun () -> source ()) tracks a source. The type argument is required.

let likes, setLikes = createSignal 10
let shownLikes, setShownLikes = createOptimistic<int> (fun () -> likes ())

createOptimisticStore is the store version, with the same overloads as createStore.

affects and resolve

  • affects source tells Solid that the current action or effect writes to source, so readers show as pending. For a store, affects (store, "key") or affects (store, fun s -> s.key) names one property.
  • resolve (fun () -> ...) returns a promise of the function's result once everything it reads has settled.

Owners and roots

FunctionUse
createRoot (fun dispose -> ...)Makes an owner that is not disposed with its parent. Call dispose to clean it up. createRoot (fun () -> ...) works when you do not need it.
getOwner ()The current owner, as an Owner option.
runWithOwner (owner, fun () -> ...)Runs the function under that owner, for example after an await.
createOwner ()Makes a new owner under the current one.
isDisposed ownerWhether the owner has been disposed.
getObserver ()The current tracking scope, if any. It was getListener in Solid 1.

Utilities

children

children (fun () -> props.children) resolves a component's children once and memoises them. It returns a ChildrenReturn: call .Invoke () to render them, or .toArray () to inspect them.

let resolved = children (fun () -> props.children)
let hasChildren = fun () -> resolved.toArray().Length > 0

merge and omit

merge (a, b, ...) combines prop objects, with later sources winning. omit (props, "a", "b") returns the props without those keys. omit (props, fun key -> ...) omits every string key the predicate accepts, and never omits symbol keys; use omitKeys (props, fun (key: obj) -> ...) to see those too. They replace mergeProps and splitProps. You rarely call them yourself, because the plugin generates them for [<SolidTypeComponent>] members.

lazy'

lazy' (fn: unit -> JS.Promise<'T>, ?options: LazyOptions, ?moduleUrl: string) : LazyComponent<'T>

lazy' loads a component on first render. LazyOptions (export = "Name") picks a named export instead of the default. .preload () starts loading early. Rendering it before it loads suspends, like an async memo. It takes Fable's importDynamic in place of the removed importComponent.

let Settings = lazy' (fun () -> importDynamic "./Settings.fs.jsx")

createUniqueId

createUniqueId () returns an id that matches between server and client, for id/for pairs.

mapArray and repeat

These are the functions behind For and Repeat, for use outside JSX. Each returns an accessor of the mapped array.

FunctionMap function
mapArray (list, fun item index -> ...) or mapArrayKeyeditem: 'Item, index: Accessor<int>
mapArrayUnkeyed (list, fun item index -> ...)item: Accessor<'Item>, index: int
mapArrayKeyedFn (list, (fun item index -> ...), keyed)item: Accessor<'Item>, index: Accessor<int>
repeat (count, fun i -> ...)i: int

The map functions are curried F# lambdas (fun item index -> ...), compiled to a two-argument JS function. mapArray replaces both mapArray and indexArray from Solid 1.

Boundaries as functions

createErrorBoundary, createLoadingBoundary and createRevealOrder are what Errored, Loading and Reveal are built on. Use them to build your own boundary components.

let content =
    createErrorBoundary ((fun () -> riskyView ()), fun err reset -> errorView (err ()) reset)

Directives

Solid 2 has no use: directives. createDirectiveFactory turns a function of the element into a ref, which you attach with .ref. The function runs once the element has settled, and returns a cleanup (ignore when there is nothing to clean up).

let autofocus = createDirectiveFactory (fun (el: Browser.Types.HTMLInputElement) ->
    el.focus ()
    ignore)

input().ref autofocus

Removed APIs

These Solid 1 APIs are gone from Solid 2, and from the bindings. Use the replacement instead.

RemovedReplacement
createResource, SolidResource, ResourceFetcher, ...An async createMemo, read under Loading. refresh re-runs it.
SuspenseLoading
SuspenseListReveal, or createRevealOrder
ErrorBoundaryErrored
catchErrorcreateErrorBoundary, or an error handler on createEffect
IndexFor.NonKeyed
indexArraymapArrayUnkeyed
For with a single child signatureFor.Keyed, For.NonKeyed or For.KeyedFn
onMountonSettled
createEffect (fun () -> ...)createEffect (compute, effect), or createTrackedEffect
createComputedA memo, or a writable derived signal (createSignal (fun () -> ...))
createDeferredNone. Derive the value outside the reactive graph.
createSelectorcreateProjection
batchflush. Writes are always batched.
startTransition, useTransitionaction, with isPending for the pending state
onThe compute half of createEffect names the sources
solid-js/storecreateStore from solid-js
produceThe store setter itself: mutate the draft and return it
unwrapsnapshot
SolidStoreSetter, SolidStorePathStoreSetter<'T>, an updater
mergeProps, splitPropsmerge, omit
importComponentimportDynamic with lazy'
getListenergetObserver
render, renderToString, isServer on Partas.SolidThe same names in Partas.Solid.Web

For the full list of what changed in Partas.Solid itself, see Migrating to Solid 2.

Edit this page