Skip to content

Components

A component in OCaml's JSX, its props, its children, and how the compiler checks them.

A component is a function from its props to an element, as in Solid. It runs once; what changes afterwards is the thunks inside it, which Solid reruns. Every module of solidtype binds one part of Solid, named as Solid names it: Solidtype.Solid is solid-js, Solidtype.Web is solid-js/web, Solidtype.Show is <Show>.

A component another component’s JSX names is written with let%component. Its props are its labelled parameters, and it ends in ():

Hello, Ada!

Welcome, Grace!

solid_greeting.mlx
module Jsx = Solidtype.Jsx
module Greeting = struct
let%component make ~(name : string) ?(greeting : string = "Hello") () =
<p>(fun () -> Jsx.string (greeting () ^ ", " ^ name () ^ "!"))</p>
end
let make () =
<div class_="flex flex-col gap-1">
<Greeting name="Ada" /> <Greeting name="Grace" greeting="Welcome" />
</div>

Greeting is written with let%component; the make at the bottom is not. The difference is who calls them.

The bottom make is a root. Nothing passes it props: a page mounts it, or Web.render renders it, and it is an ordinary OCaml function, let make () = .... No JSX tag names it.

Greeting is named by a tag, <Greeting name="Ada" />, and Solid calls it the way it calls every component: with one JavaScript object of props, { name: "Ada" }, not with OCaml’s labelled arguments. Each field of that object is a getter that Solid tracks when it is read, so a prop whose value changes is seen by the code that reads it then – which is why, in TSX, destructuring props in the parameters loses updates. A plain let make ~name () = ... could not be given that object, and reading name once at the top would be that same lost update.

let%component bridges the two. From the labelled parameters, the ppx writes:

  • Props, the module that describes the props object: its type, a getter for each field, check, which the compiler applies to every <Greeting ... /> to catch a prop left out or misspelt, and omit and others, for a spread;
  • make, a function of that object, in which each parameter becomes a read of its field: name () reads props.name at the moment it runs, inside a thunk, where Solid tracks it;
  • component, make as the value a JSX tag names.

So in the body a value prop is read as a call, name (), never as a plain value. An optional parameter with a default, ?(greeting : string = "Hello"), reads its default while the prop is left out; one without, ?(note : string option), reads an option.

Written by hand, Greeting would be its Props module, a getter and a check per prop, kept in step with the body by a reader. let%component writes them from the one place the props are named, so they cannot drift.

A component is a value, Greeting.component, and the tag <Greeting /> names it. The module is the component: <Greeting.Header /> is the component Greeting.Header.component.

Each element of a component applies the component’s Props.check to its props, which the compiler checks and Melange drops. The check takes one labelled argument per prop, optional where the prop is, so the compiler says what TSX’s types would:

1 | let make () = <Label />
^^^^^^^^^
Error: This expression has type text:string Solidtype.Check.value -> unit
but an expression was expected of type unit
Hint: This function application is partial, maybe some arguments are missing.
1 | let make () = <Label text=(fun () -> "x") colour="red" />
^^^^^
Error: The function applied to this argument has type
?count:int Solidtype.Check.value -> ?props:Label.Props.t -> unit
This argument cannot be applied with label ~colour

A value prop is a Check.value: a literal, or a thunk that reads what changes. A thunk is never taken for a function prop, which a component would call; a function prop is the function itself, on_close=(fun _ -> ...). Two props typed by one type variable must agree, as in TSX.

Children are a prop, children. As Jsx.content, they are any JSX and are read where they go, (fun () -> children ()):

Notes

A panel's children are any JSX: these two paragraphs.

Close it, and a button takes its place.

solid_children.mlx
module S = Solidtype.Solid
module Jsx = Solidtype.Jsx
module Show = Solidtype.Show
module Panel = struct
let%component make ~(title : string) ~(on_close : unit -> unit)
~(children : Jsx.content) () =
<section
class_="flex w-72 flex-col gap-2 rounded-xl border border-border p-4">
<header class_="flex items-center justify-between">
<strong>(fun () -> Jsx.string (title ()))</strong>
<button
class_="button button--sm button--tertiary"
type_=`button
on_click=(fun _ -> on_close ())>
"Close"
</button>
</header>
(fun () -> children ())
</section>
end
let make () =
let is_open, set_open = S.create_signal true in
<Show
when_=(fun () -> S.get is_open)
fallback=(<button
class_="button button--sm button--secondary"
type_=`button
on_click=(fun _ -> S.set set_open true)>
"Open the notes"
</button>)>
<Panel title="Notes" on_close=(fun _ -> S.set set_open false)>
<p>"A panel's children are any JSX: these two paragraphs."</p>
<p class_="text-sm text-muted">
"Close it, and a button takes its place."
</p>
</Panel>
</Show>

A component may also take a function as its only child, which it calls, as For does: ~(children : item -> Jsx.element). A check names which kinds it takes: [ nodes ]for elements,[ render ] for a function, or both.

A function prop that takes nothing is written (fun _ -> ...), as on_close is above, which hides the panel and shows a button in its place: (fun () -> ...) is a thunk, which would be read as a value.

Besides a root, one kind of component is written without let%component: a component that passes its props on whole, as a spread, writes its Props by hand, its check’s labels named as solidtype names a prop, and spreads them with the attribute props:

let make (props : Props.t) = <input class_="field" props />

Solid.merge_props gives props defaults, live, and Props.omit, which let%component writes, leaves some out of a spread by a variant of the component’s own prop names, so a misspelt one is a compile error. Props.others leaves out all of them: what is left is what the component’s check admits besides them, its data- attributes, which it passes on to its element, props=(Props.others props). A component’s spread is a rest of its own props, or that rest of any component’s.

Dynamic renders whichever component a thunk gives, its props given as one value typed by that component, so a prop it needs is not left out:

<Dynamic component=(fun () -> pick ()) props=(Greeting.Props.make ~name:"Ada" ()) />

Dynamic.Element does the same for an element whose tag is a string. Solid.lazy_ makes a component loaded when it is first rendered, which suspends until it arrives.

Solid solidtype
children(() => props.children) Solid.children, then Solid.to_array
mergeProps Solid.merge_props
splitProps Solid.omit and Solid.others, or Props.omit and Props.others
createUniqueId Solid.create_unique_id
lazy Solid.lazy_
<Dynamic> Solidtype.Dynamic, Dynamic.Element
<Portal> Solidtype.Portal

Each module’s .mli is its reference, and says where OCaml writes a form Solid’s TypeScript has differently.