This is in continuation to
Let's take a look at a simple concrete example: the beginnings of a shopping cart application. The full source code for this example implemented on top of WPF can be found here and is based on an earlier example on top of React that can be found here.
So, how does one create a Shopping Cart UI?
In this example we don't care about the visual styling of our components. So, for convenience, let's first define a little helper for creating a stack of elements:
let stackElems orientation (children: IROL<_>) =
UI.elem StackPanel [
UI.orientation orientation
UI.children children
]
The stackElems
component, written with our
UI
helpers, takes an orientation
and a
read-only list
of children
and creates an element that instantiates a
StackPanel
.
But, of course, the first thing that our Shopping Cart UI really needs is the classic counter component:
let counterElem count =
stackElems Orientation.Horizontal [
UI.elem Button [
UI.content "-"
UI.onClick (Atom.modifyAct count ((+) -1))
]
UI.elem Label [UI.content (UI.lift1 string count)]
UI.elem Button [
UI.content "+"
UI.onClick (Atom.modifyAct count ((+) +1))
]
]
The counterElem
component displays a count
, which is supposed to be a view
of state that contains an integer, and buttons labeled -
and +
that
decrement and increment the count
.
The type of count
is IAtom<int>
, which is also an IObservable<int>
. An
"atom" is a first-class reference to or a view of state that can be both
observed for changes and whose update may be requested. Note that an atom does
not necessarily store state — it is typically just a referentially
transparent view of some existing state. The name "atom" refers to the idea that
state updates are performed using a read-modify-write style operation.
The UI.lift1
helper function "lifts" an ordinary function to operate on
observables. In this case we lift the string
function and then pass the
count
to the function.
As you probably know, a counter component such as the above is a typical first example that the documentation of any respectable front-end framework will give you. Until now you may have mistakenly thought that those are just toys.
The next thing is to write a component that can remove itself:
let removableElem removable =
UI.elem Button [
UI.content "🗑️"
UI.onClick (Atom.removeAct removable)
]
The removableElem
component gives you a button labeled 🗑️
that requests
removal of the removable
state given to it when clicked.
At this point it might be good idea to point out that all of the previous
component functions, namely stackElems
, counterElem
, and removableElem
are
referentially transparent
aka pure functions. The UI.elem
helper does not perform any immediate effects.
It is only later when we wish to show a complete component hierarchy that the
described UI elements get instantiated. The UI.onClick
handlers that we
specified are not immediately called either. We can think of the native UI
framework as being the interpreter of our UI computations and one could just as
well implement the same approach in a purely functional language.
That is not all. Instances of components are essentially stateless meaning that the instances contain no local state except for values implicitly cached from state stored outside of the instances and for the state used by the native components. While we may sometimes want to control the state of native components, such as which UI element has the focus, for most intents and purposes we can consider the components as stateless.
This actually applies to all components in this example and most components in real-world applications using this approach can also be pure functions whose instantiations are stateless. First-class, decomposable, and observable state makes it easy to store state outside of components and make the components themselves pure and stateless.
As we have actually already seen, we can compose or combine user-defined
components as easily as native WPF UI elements. As a reminder, let's compose a
removableCounterElem
from removableElem
and counterElem
:
let removableCounterElem count =
stackElems Orientation.Horizontal [
removableElem count
counterElem count
]
We simply pass the same view of state, count
, to both components and get a
composition of them.
We use the term "component" to refer to both the view and the the view model. So
far the view models of our components have been trivial enough that their
functionality has just been included inside the view functions. Take another
look at the counterElem
component. If we wanted to, we could
move the actions built with Atom.modifyAct
into separate functions, but the
model is so simple that there is hardly any benefit in doing so.
We haven't actually written anything shopping cart specific yet. So, what should we put into a cart?
A CartItem
just holds an Id
and a Count
:
type CartItem = {Id: int; Count: int}
To manipulate CartItem
s we define a corresponding module with some helper
functions and optics:
module CartItem =
let id t = t.Id
let count =
Optic.lens (fun t -> t.Count) (fun v t -> {t with Count = v})
<< Optic.removeEqL 0 << Optic.rewriteI (max 0)
// ...
You may find the above count
lens interesting. A special feature of
the optics implementation
is that it allows one to request removal of any focus. The Optic.removeEqL 0
lens used in the composition makes it so that in case the count becomes 0, then
removal of the element is requested.
An InventoryItem
contains an Id
and some further information on the item:
type InventoryItem = {Id: int; Name: string; Price: float}
In this simple example, the inventory is just a mapping from ids to inventory
items. So, to display an item in our shopping cart, we find the item from the
inventory to get to the labeling information and pass a view of the Count
to
the removableCounterElem
to allow control of the shopping cart contents:
let cartItemElem inventory id cartItem =
let inventoryItem = Map.find id inventory
stackElems Orientation.Horizontal [
removableCounterElem (Atom.view CartItem.count cartItem)
UI.elem Label [UI.content inventoryItem.Name]
]
The contents of the whole shopping cart
is just a read-only list of
CartItem
s. So, to display the whole cart
, which is a view of the list state,
the below cartElem
component decomposes the state, using Atom.mapByKey
, into
views of states of individual items and displays them using the above
cartItemElem
.
let cartElem inventory cart =
let total =
cart
|> UI.lift1 (
Seq.sumBy <| fun (item: CartItem) ->
let info = Map.find item.Id inventory
info.Price * float item.Count
)
stackElems Orientation.Vertical [
UI.elem Label [UI.content "Shopping Cart"]
UI.elem StackPanel [
UI.orientation Orientation.Vertical
cart
|> Atom.mapByKey CartItem.id (cartItemElem inventory)
|> UI.children
]
UI.elem Label [
UI.content (UI.lift1 (sprintf "Total: %.2f") total)
]
]
Now we have an editable shopping cart.
The view model of a cart element is complex enough that one might want to start
introduce a separate view model for it. As an exercise, you could extract
total
as a separate function outside of cartElem
and call it from
cartElem
. How would you test it? Could you refactor the code a bit further to
make testing it even easier?
We also want to display an inventory from which items can initially be added to
the cart. For that purpose we add a byId
optic that tries to find an item from
the shopping cart
and if none is found, can insert an item, with appropriate
default values, to the cart:
module CartItem =
// ...
let byId Id =
Optic.findL (id >> (=) Id) << Optic.defaultsI {Id=Id; Count=0}
Now to display an inventory item, we can just use byId
to get a view of either
an existing item or an item to be added and we can use the same
removableCounterElem
component in our inventory item component:
let inventoryItemElem cart (inventoryItem: InventoryItem) =
stackElems Orientation.Horizontal [
removableCounterElem
<| Atom.view
(CartItem.byId inventoryItem.Id << CartItem.count)
cart
UI.elem Label [UI.content inventoryItem.Name]
UI.elem Label [UI.content inventoryItem.Price]
]
The whole inventory is pretty much just a label and a stack of the items in the inventory map:
let inventoryElem cart inventory =
stackElems Orientation.Vertical [
UI.elem Label [UI.content "Inventory"]
inventory
|> Map.toArray
|> Array.map (snd >> inventoryItemElem cart)
|> stackElems Orientation.Vertical
]
We now have all the components to put together our shopping cart application:
[<EntryPoint; STAThread>]
let main _ =
let cart = Atom.create<IROL<_>> [||]
let inventory =
Map.ofArray << Array.map (fun t -> (t.Id, t)) <| [|
{Id = 1; Price = 1.0; Name = "Toilet paper"}
{Id = 2; Price = 2.5; Name = "Bread"}
{Id = 3; Price = 2.0; Name = "Butter"}
{Id = 4; Price = 3.0; Name = "Milk"}
{Id = 5; Price = 2.5; Name = "Coffee"}
{Id = 6; Price = 1.5; Name = "Cheese"}
|]
UI.run <| Application (
MainWindow = UI.show (
UI.window Window [
UI.title "Shopping Cart"
UI.width 400.0
UI.height 300.0
UI.content (
stackElems Orientation.Horizontal [
inventoryElem cart inventory
cartElem inventory cart
]
)
]
)
)
I wouldn't say it is pretty, but there we have it!
For the purposes of this example we are done. Here are some key points to remember:
-
Each component is basically just one referentially transparent function that takes some parameters, possibly first-class views of state represented as "atoms", and returns an UI element constructor.
-
We composed views together as expressions — mostly as nested stacks of elements in this case.
-
We decomposed views of state, or view models, into views of substates to match the view models expected by components such as
counterElem
andcartItemElem
. Those components do not need to know where and how the state they are viewing is actually stored. -
We used
counterElem
andremovableElem
, viaremovableCounterElem
, with two differently defined views of state. Corresponding cart and inventory item views remain in sync by construction as they are merely referentially transparent views of shared external state.
To be, or not to be — continued...