UI.Next is a client-side library providing a novel, pragmatic and convenient approach to UI reactivity. It includes:
- A dataflow layer for expressing user inputs and values computed from them as time-varying values. This approach is related to Functional Reactive Programming (FRP), but differs from it in significant ways discussed here.
- A reactive DOM library for displaying these time-varying values in a functional way. If you are familiar with Facebook React, then you will find some similarities with this approach: instead of explicitly inserting, modifying and removing DOM nodes, you return a value that represents a DOM tree based on inputs. The main difference is that these inputs are nodes of the dataflow layer, rather than a single state value associated with the component.
- A declarative animation system for the DOM layer.
A full documentation for UI.Next is available on GitHub. Samples can be browsed here.
To get a taste of UI.Next's concepts, here is a very basic example: an input box, and a label that is automatically updated with the uppercased content of the input box.
using WebSharper;
using WebSharper.UI.Next;
using WebSharper.UI.Next.Client;
using WebSharper.UI.Next.CSharp;
using static WebSharper.UI.Next.CSharp.Client.Html;
namespace MyWebsite
{
public class MyWebsite
{
[SPAEntryPoint]
public static void Main()
{
Var<string> rvContent = Var.Create("");
View<string> vUpperContent = rvContent.View.Map(t => t.ToUpper());
div(
input(rvContent),
label(vUpperContent)
)
.RunById("main");
}
}
}
-
First, we create a reactive variable
rvContent
, of typeVar<string>
. It is initialized with an empty string, and will be updated whenever the user inputs text. -
Then, we create a view on
rvContent
, which we callvUpperContent
. A view is a value that cannot be set explicitly, but instead is set automatically based on the current value of one or several other values. In this case,vUpperContent
is updated wheneverrvContent
is updated by applying the methodToUpper()
. -
Finally, we render the page. The functions
div
andlabel
create HTML elements.Doc.Input
creates an HTML input element whose value is always synchronized with the current value ofrvContent
.textView
creates a text node whose value is always synchronized with the current value ofvUpperContent
.
The flow of time-varying values in UI.Next is represented as a dataflow graph. When the value of an input node (aka Var
) is set, it is propagated through the internal nodes (aka View
s) down to the output node (aka Sink
).
The following classes and methods require the following using
statements:
using WebSharper.UI.Next;
using WebSharper.UI.Next.CSharp;
A value of type Var<T>
is an input node to the dataflow graph. Its value can be imperatively read or set using the Value
property, or the functions Var.Get
and Var.Set
. It can also be associated with an element in the DOM layer. In the basic example, rvContent
is a Var<string>
.
Var<string> rvContent = Var.Create("initial value")
rvContent.Value = "second value";
string secondValue = rvContent.Value;
Var.Set(rvContent, "third value");
string thirdValue = Var.Get rvContent;
A Var<T>
is essentially equivalent to a classic F# 'T ref
, except for the fact that it can be reactively observed by View
s.
A value of type View<T>
is an internal node in the dataflow graph. It is not possible to explicitly get or set the value of a View<T>
. Instead, at any time its value is determined by the value of the nodes that precede it in the dataflow graph, and can be observed by other View
s. In the basic example, vUpperContent
is a View<string>
.
-
The simplest way to create a
View<T>
is by using theView
property of aVar<T>
, which creates a view whose value is always the current value of theVar<T>
.Var<string> rvContent = Var.Create("initial value"); View<string> vContent = rvContent.View; // vContent's current value is now "initial value" rvContent.Value = "new value"; // vContent's current value is now "new value"
-
A
View<T>
can also be created using one of the various static methods in theView
class:View<T> Const(T value)
creates a view whose value never changes.
View<int> vThree = View.Const(3); // vThree's value will always be 3
View<T> FromVar(Var<T> rv)
creates a view whose value is always the current value ofrv
. It is equivalent torv.View
.
Var<string> rvContent = Var.Create("initial value"); View<string> vContent = View.FromVar(rvContent); // vContent's current value is now "initial value" rvContent.Value = "new value"; // vContent's current value is now "new value"
View<U> Map(this View<T> v, Func<T, U> f)
creates a view whose value is always the result of callingf
on the current value ofv
.
Var<string> rvContent = Var.Create("initial value"); View<string> vContent = rvContent.View.Map(t => t.ToUpper()); // vContent's current value is now "INITIAL VALUE"
-
View<U> MapAsync(this View<T> v, Func<T, Task<U>> f)
creates a view whose value is always the result of callingf
on the current value ofv
. Note that ifv
is updated before the previous asynchronous call returns, then this previous call is discarded. -
View<V> Map2(this View<T> v1, View<U> v2, Func<T, U, V> f)
creates a view whose value is always the result of callingf
on the current values ofv1
andv2
.
class Person { public string Name { get; set; } public int Age { get; set; } } Var<string> rvName = Var.Create("John Doe"); Var<int> rvAge = Var.Create(42); View<Person> vPerson = rvName.View.Map2(rvAge.View, (n, a) => new Person { Name = n, Age = a }); // vPerson's current value is now { Name = "John Doe", Age = 42 } rvName.Value = "Jane Doe"; // vPerson's current value is now { Name = "Jane Doe", Age = 42 }
View<U> Bind(this View<T> v, Func<T, View<U>> f)
is an important function because it allows a subgraph to change depending on its inputs. For example, in the following code, whenrvIsEmail
's value is true, the graph containsrvEmail
as a node, and when it is false, it containsrvUsername
as a node instead.
abstract class UserId { } class UserName : UserId { public string Name { get; set; } } class Email : UserId { public string Value { get; set; } } Var<bool> rvIsEmail = Var.Create(false); Var<UserId> rvEmail = Var.Create<UserId>(new Email { Value = "" }); Var<UserId> rvUsername = Var.Create<UserId>(new UserName { Name = "" }); View<UserId> vUserId = rvIsEmail.View.Bind(isEmail => isEmail ? rvEmail.View : rvUsername.View );
View<T> Join(this View<View<T>> v)
"flattens" a view of a view. It can be used equivalently toBind
, as the following equalities hold:
v.Bind(f) == v.Map(f).Join() v.Join() == v.Bind(x => x)
Dynamic composition via
View.Bind
andView.Join
should be used with some care. Whenever static composition (such asView.Map2
) can do the trick, it should be preferred. One concern here is efficiency, and another is state, identity and sharing (see Sharing for a discussion).Submitter<T> Submitter.Create(View<T> v, T init)
creates aSubmitter
, a related and useful class.Submitter
has a.Trigger()
method and an output.View
which, at any point in time, has the value thatv
had the last time it wasTrigger
ed. When it has never beenTrigger
ed yet, the outputView
's value isinit
.
Submitter
s are typically used to bring events such as submit buttons into the dataflow graph.public class LoginData { public string Username { get; set; } public string Password { get; set; } } var rvUsername = Var.Create(""); var rvPassword = Var.Create(""); var vLoginData = rvUsername.View.Map2(rvPassword.View, (username, password) => new LoginData { Username = username, Password = password }); var sLoginData = Submitter.Create(vLoginData, null); var vSubmittedLoginData = sLoginData.View;
In the above example,
vSubmittedLoginData
is initialized withnull
, and is updated with the current login data wheneversLoginData.Trigger()
is called. It is then possible to map aView
or aSink
onvSubmittedLoginData
that performs the actual login.-
View<IEnumerable<U>> MapSeqCached(this View<IEnumerable<T>> v, Func<T, U> f)
maps views on sequences with "shallow" memoization. The process remembers inputs from the previous step, and reuses outputs from the previous step when possible instead of calling the converter function. Memory use is proportional to the longest sequence taken by the View. Since only one step of history is retained, there is no memory leak. Requires equality onT
. -
View<IEnumerable<U>> MapSeqCached(this View<IEnumerable<T>> v, Func<T, K> key, Func<T, U> f)
is a variant onMapSeqCached
above that uses akey
function to determine identity on inputs, rather than an equality constraint on the typeT
itself. -
View<IEnumerable<U>> MapSeqCached(this View<IEnumerable<T>> v, Func<View<T>, U> f)
is an extended form ofMapSeqCached
where the conversion function accepts a reactive view. At every step, changes to inputs identified as being the same object are propagated via that view. Requires equality onT
. -
View<IEnumerable<U>> MapSeqCached(this View<IEnumerable<T>> v, Func<T, K> key, Func<View<T>, U> f)
is a variant onMapSeqCached
above that uses akey
function to determine identity on inputs, rather than an equality constraint on the typeT
itself.
Once a graph is built out of Var
s and View
s, it needs to be run to react to changes.
The function void Sink(this View<T> v, Action<T> f)
is the output node of the dataflow graph. This function calls f
with the current value of v
whenever it is updated. It is highly recommended to have a single Sink
running per dataflow graph; memory leaks may happen if the application repeatedly spawns Sink
processes that never get collected. See Leaks for more information.
public class LoginData
{
public string Username { get; set; }
public string Password { get; set; }
}
var rvUsername = Var.Create("");
var rvPassword = Var.Create("");
var vLoginDataInput =
rvUsername.View.Map2(rvPassword.View, (username, password) =>
new LoginData { Username = username, Password = password });
var sLoginData = Submitter.Create(vLoginData, null);
var vSubmittedLoginData = sLoginData.View;
vSubmitted.Sink(async login =>
{
await Rpc.Login(login); // An [Rpc] function that logs in the user
})
It is relatively rare to call View.Sink
directly. Instead, views are generally connected to the DOM layer, which itself calls Sink
when inserted into the document.
In UI.Next, the type Doc
represents a DOM snippet, i.e. a sequence of HTML or SVG elements, with possibly reactive content. A Doc
can be empty, a single element or several elements. The Doc
API is mostly generative: it is not advised to imperatively insert nodes or change their contents. Instead, dynamic nodes are generated based on a dataflow graph.
Most of the following functions are located in the namespace WebSharper.UI.Next
. Convenience functions such as individual HTML elements and attributes require using static WebSharper.UI.Next.CSharp.Client.Html;
. Dynamic functions that involve Var
s, View
s or Dom.Element
s are under WebSharper.UI.Next.Client
.
The main way to create Doc
s is to use the static methods from WebSharper.UI.Next.CSharp.Client.Html
named after HTML elements. These functions take any number of arguments which will be inserted as attributes and child elements on the currently created element. The arguments can have the following types:
-
string
: this will insert a text node. -
Doc
: this will insert a reactive node. -
Attr
: this will insert an attribute. -
View<T>
orVar<T>
: this will insert a node, whose value will be live-updated from the given View or Var. When that value is astring
,Doc
,View<T>
orVar<T>
, it gets inserted accordingly, for all other types (including anAttr
), its.ToString()
method called and inserted as a text node. -
any other object will have its
.ToString()
method called and inserted as a text node.
You can find SVG elements as static members of WebSharper.UI.Next.CSharp.Client.Html.SvgElement
.
There are more strongly typed Doc
construction options which do not use client-side type checking, but requires a longer syntax.
These are either members of WebSharper.UI.Next.Doc
for unvarying Docs that ate usable on both client and server side, and WebSharper.UI.Next.Client.Doc
for reactive Docs.
The following static methods in the Doc
class create Docs:
text(string text)
creates aDoc
composed of a single text node with the given contents.text(View<string> v)
creates aDoc
composed of a single text node whose contents is always equal to the value of the givenView
.Doc.Element(string tag, IEnumerable<Attr> attrs, IEnumerable<Doc> children)
creates aDoc
composed of a single HTML element with the given tag name, attributes and children.Doc.SvgElement(string tag, IEnumerable<Attr> attrs, IEnumerable<Doc> children)
creates aDoc
composed of a single SVG element with the given tag name, attributes and children.doc(Dom.Element el)
creates aDoc
composed of a single element.Doc.Empty
creates an emptyDoc
, ie. aDoc
composed of zero elements.doc(Doc doc1, Doc doc2)
creates aDoc
composed of twoDoc
s in sequence.Doc.Concat(IEnumerable<Doc> docs)
creates aDoc
composed of severalDoc
s in a sequence.doc(View<Doc> view)
allows the actual DOM structure of aDoc
to depend on the dataflow graph.
The following static methods of WebSharper.UI.Next.CSharp.Client.Html
create elements which can be used to set the value of a Var
based on user input.
-
**
input(IRef<string> var)** creates an
text box synchronized with the given
IRef. The
Varis updated on user input, and the text box is updated when the
Varchanges. You can also specify any number of attributes as a
paramsargument. Overloads exist for
Varand
Varfor creating number imputs. One overload does not take an
IRef`, this is not a auto-synchronizing input.var rvText = Var.Create("initial value"); var myDoc = input(rvText); // myDoc HTML equivalent is now: <input type="text" value="initial value" /> // User types "!"... // myDoc HTML equivalent is now: <input type="text" value="initial value!" /> rvText.Value = "new value"; // myDoc HTML equivalent is now: <input type="text" value="new value" />
-
textarea(IRef<string> var)
and its overloads are similar, but creates a<textarea>
. -
passwordBox(IRef<string> var)
is similar, but creates an<input type="password">
. -
button(string caption, Action callback, params Attr[] attrs)
creates a<button>
that calls the given callback on click. If you have aSubmitter<T>
, then its.Trigger
method is a suitable callback to make a button click capture a current state that you want the button click handler to operate with.Here is the login box sample from
SnapshotOn
above, with a document to render it:var rvUsername = Var.Create(""); var rvPassword = Var.Create(""); var vLoginData = rvUsername.View.Map2(rvPassword.View, (username, password) => new LoginData { Username = username, Password = password }); var sLoginData = Submitter.Create(vLoginData, null); var vLoginResult = sLoginData.View.MapAsync(async login => await Rpc.Login(login)); div( input(rvUsername), passwordBox(rvPassword), button("Log in", sLoginData.Trigger), vLoginResult.Map(res => Doc.Empty) );
-
link(string caption, Action callback, params Attr[] attrs)
is similar toButton
, but creates an<a>
link instead. -
checkbox(IRef<bool> var, params Attr[] attrs)
creates a checkbox whose checked state reflects the value of the given booleanVar
. -
checkbox(T value, IRef<IEnumerable<T>> var, params Attr[] attrs)
creates a checkbox whose checked state reflects the presence or absence of the given value in the given enumerableVar
. For example, givencheckbox("test",rvMyVar)
, ticking the checkbox will add"test"
to the list held byrvMyVar
and unticking the checkbox will remove it. No ordering of values in the list is guaranteed. -
select(IRef<T> var, IEnumerable<T> options, Func<T, string> text, params Attr[] attrs)
creates a selection box from the given enumerable. It requires a function to get the text for each item, and a variable which is updated with the currently-selected item. A dynamic version is available, taking aView<IEnumerable<T>>
foroptions
argument. Overloads exist that also take a placeholder text. -
radio(IRef<T> var, T value, params Attr[] attrs)
creates a radio button whose checked state reflects whether the givenVar
's value is currently equal to the given value.
There are several ways to insert a Doc
in a document by extension methods available with WebSharper.UI.Next.Client
:
-
.Run(Dom.Element el)
extension method renders theDoc
as the children of the given DOM element. -
.RunById(string id)
is similar toDoc.Run
, but takes an element identifier to locate the container element. -
The type
Doc
implements the interfaceWebSharper.Html.Client.IControlBody
; it is therefore possible to use it as the body of aWeb.Control
or to pass it to the functionClientSide
fromWebSharper.Html.Server
.using WebSharper; using WebSharper.UI.Next; using WebSharper.UI.Next.Html; public class MyControl : Web.Control { public override IControlBody Body { get { var rvText = Var.Create(""); return doc( input(rvText), label(rvText) ); } } }
Just like Doc
, the Attr
type is monoidal: it represents zero, one or many DOM attributes. Client-side only (reactive) static methods presented here assumes having using CAttr = WebSharper.UI.Next.Client.Attr;
. DOM attributes can be created with the following functions:
-
attrib(string name, string value)
represents a single attribute with the givenname
andvalue
.- For convenience, standard attributes such as
href
are available asattr.href
when havingusing static WebSharper.UI.Next.CSharp.Client.Html
. A few of them need to be prepended with a@
character to avoid collisions with keywords such asclass
ordefault
.
- For convenience, standard attributes such as
-
attrib(string name, View<string> value)
represents a single attribute with the givenname
and varyingvalue
.- For convenience, when
using WebSharper.UI.Next.CSharp.Html
andusing WebSharper.UI.Next.CSharp.Client
, standard attributes such ashref
are available asattr.hrefDyn
.
- For convenience, when
-
handler(string name, Action<Dom.Element,Dom.Event> callback)
specifies a handler for a DOM event, such as click event for a button. Thename
of the event doesn't include theon
prefix, for example:handler("click", (el, ev) => { })
.- For convenience, when
using WebSharper.UI.Next.CSharp.Html
andusing WebSharper.UI.Next.CSharp.Client.Html
, standard event handlers such asonclick
are available ason.click
.
- For convenience, when
-
handler(string name, View<'T> view, Action<Dom.Element,Dom.Event,T> callback)
specifies a handler for a DOM event, and additionally the handler receives the current value of the given view. Thename
of the event doesn't include theon
prefix, for example:Attr.Handler "click" callback
.- Standard event handlers are also available with this method signature on the
WebSharper.UI.Next.CSharp.Client.Html.on
static class.
- Standard event handlers are also available with this method signature on the
-
attrib(params Attr[] attrs)
concatenates multiple collections of attributes into one. -
Attr.Empty
is the empty collection of attributes. -
class(string name)
specifies a class attribute. Classes are additive, so:attrib(class("a"), class("b")) == attrib("class", "a b")
-
class(string name, View<T> view, Predicate<T> pred)
specifies a class that is added whenpred
is true and removed whenpred
is false. -
style(string name, string value)
specifies a CSS style property, such asstyle("background-color", "black")
. -
style(string name, View<string> value)
specifies a varying CSS style property. -
attrib(string name, View<string> view, Predicate<T> pred)
specifies an attribute with the givenname
and varyingvalue
whenpred
is true, and unsets it whenpred
is false.
UI.Next allows you to create animations declaratively, which are then run when a View
changes. There are three main types involved in animation:
-
Anim<'T>
is an animation for a type'T
. It represents a function from time to'T
. -
Trans<'T>
defines a transition for a type'T
. It indicates theAnim<'T>
that should be played on enter, change and exit. -
And finally
Attr
, via the functionsAttr.Animated
andAttr.AnimatedStyle
, attaches a transition to a given attribute or style of an element. For convenience, when havingusing static WebSharper.UI.Next.CSharp.Client.Html
, standard attributes has overloads that take aConverter
delegate to define an animation on the attribute.
Here is an example for an element that enters from the left and leaves to the right using a cubic animation, and otherwise moves linearly according to rvLeftPos
:
// define animations that can be parametrized by start and end times
Func<double, double, Anim<double>> linearAnim =
(start, end) => Anim.Simple(Interpolation.Double, new Easing(x => x), 300, start, end);
Func<double, double, Anim<double>> cubicAnim =
(start, end) => Anim.Simple(Interpolation.Double, Easing.CubicInOut, 300, start, end);
// define the transition with a cubic in and out and linear in between
var swipeTransition =
new Trans<double>(linearAnim, x => cubicAnim(x - 100, x), x => cubicAnim(x, x + 100));
var rvLeftPos = Var.Create<double>(0);
var animatedDoc =
div(
style("position", "relative"),
style("left", swipeTransition, rvLeftPos.View, pos => pos + "%"),
"content"
);