The tl;dr of reactive programming is events happen, stuff changes. And the hardest part to get right is the "stuff" part. This is hard because, based on the events that transpire, whatever constituted "stuff" five minutes ago may no longer be the stuff of here-and-now.
Phil Freeman's You Might Not Need the Virtual DOM proposed a radical and revolutionary idea for "stuff" management:
- Separate each morsel into two distinct parts: its address and its properties. For example, I am addressable, whereas my properties are my hair color and shoe size.
- Use events to modulate only the properties, keeping addresses static.
Freeman called his system SDOM, or the "static document object model." This is in contrast to the VDOM, or the "virtual document object model" used by frameworks like React.
This simplification makes reactive programming much easier to orchestrate, as we don't need to manage the presence and absence of addressable objects. In Freeman's system, objects are always present, so we just need to shepherd events to their destinations.
I think that SDOM is a dramatic improvement over VDOM-based approaches like React, and I've enjoyed getting to know Freeman's library as well as some other libraries, like SolidJS and Svelte, that use a variation on the SDOM approach [1].
Recently, I set out to make some small tweaks to the SDOM model. In doing so, I found myself using the terms "horizontal" and "vertical" events to describe solutions to two different problems that arise in SDOM:
- The slow performance of components with dense models.
- The difficulty of creating dynamic objects like lists.
This article defines and gives examples of horizontal and vertical events in hopes that they'll be useful to those of you that are building reactive apps.
Horizontal events
Horizontal events push the notion of a model
so far to the boundary of a system that it becomes an event. I use the spatial metaphor "horizontal" because we wind up spreading events to all the nooks and crannies of our DOM that need updating.
Models are ubiquitous in DOM programming: they serve as an abstraction for the underlying data that a UI represents. Traditionally, models are hooked up to views that are modulated by controllers, also called the MVC pattern. React and Solid create a model through a combination of props
and hooks, and in SDOM, the model is a first-class citizen of the type system.
React example
In React, our model is a mixture of properties that come from the external environment, often called props
, and ad hoc members of an internal state created using hooks.
function Example(props) {
const [age, setAge] = useState(1);
return (<div>
<p>Hello {props.name}. I'm guessing your age is {age}.</p>
<button onClick={() => setAge(age + 1)}>Guess again</button>
</div>);
}
SolidJS has a similar syntax, and Svelte uses a similar approach, separating out the model into a <script/>
tag.
SDOM example
SDOM does not make a distinction between inherited and mutable properties: all initial properties are passed in at the moment of instantiation of an element, and all of the properties are mutable.
example =
E.div_
[ E.p_ [ text \_{ name, age } -> "Hello " <> name <> ". I'm guessing your age is " <> show age <> "." ] ]
, E.button
[]
[ Events.click \_ _ -> pure (over (prop (Proxy :: _ "age")) (add 1)) ]
[ text_ "Guess again" ]
]
The hidden cost of models
The issue with models in both React and SDOM is that they can easily create spurious re-renders. In the case of React, you may update several useState
hooks frequently, causing a whole component to re-render when in fact a much smaller render might be needed. In the case of SDOM, every time an event is triggered, it is broadcast as a model to the entire component. This can be a really expensive operation for components with lots of elements.
Svelte and SolidJS, with their dedicated compilers, go a long way towards addressing this issue. The method that I'm proposing in this article, horizontal events, is a different approach to managing these performance tradeoffs that brings with it some neat additional properties, including ad hoc internal states and fixed points over reactive systems.
Horizontal events to the rescue
Let's look at the same example using horizontal events. I'll use PureScript Deku, a web micro-framework that I wrote to explore the concept of horizontal and vertical events.
example name = bus
\setAge -> lcmap (bang 1 <|> _) \age -> plant $
( Proxy:: Proxy
"""
<div>
<p>Hello ~name~. I'm guessing your age is ~age~.</p>
<button ~setAge~>Guess again</button>
</div>
"""
) ~~
{ name: nut $ text name
, age: nut $ text $ show <$> age
, setAge: attr D.OnClick <<< setAge <<< add 1 <$> age
}
Here, both name
and age
are events, with the only difference being that age
has a pusher created via the bus
function. In this way, "hooks" vs "props" is recast as "events to which I can push stuff" and "events that I'm just listening to." It's slightly more flexible than hooks in that one can bypass the initial state or create one using bang
.
Performance
In the example above, the text
node with name
will only update whenever name
updates and the click
of button
and the text
node with age
will only update when the age
changes. SolidJS uses a similar approach to reactivity, only updating the parts of a component that need updating instead of pinging all of the listeners when a change happens. The performance gains are substantial, and make experiments like this possible. Of course, few apps need this level of performance, but it's good to know you have the headroom in case you need it!
Filtering
One advantage of models is that they represent conceptual units that are easy to work with, like a full user profile. Even with a large, monolithic model, it's still possible to gain the performance benefits of events. Because Event
from purescript-event
implements the Filterable
typeclass, we can use filterMap
or filter
to focus on specific parts of an event and only react to changes in that specific part.
In the example below, an event is only fired for the bottom-most text when the age is above 5.
example model = D.div_
[ D.div_ [ text_ "Name: ", text (_.name <$> model) ]
, D.div_ [ text_ "Age: ", text (_.age >>> show <$> model) ]
, text
(filter (_.age >>> (5 < _)) model $> "They sure do grow up fast!")
]
Composition
Functional programming is all about composition, and events are no exception. In PureScript, the Event
type provides two primitives for composition: sampleOn
and keepLatest
.
sampleOn
is used to combine together two events but only inherit the temporality of the first.
example counter = bus \push event -> plant $ D.div_
[ D.button (bang (D.OnClick := push unit))
[ text_ "Show current counter" ]
, text (sampleOn (show <$> counter) (event $> identity))
]
keepLatest
takes a nested event and flattens it, keeping the temporality of the most recent inner event. If the inner event has immediate temporality via bang
, keepLatest
effectively is flatMap
from Scala, allowing us to take one event and turn it into several.
padMe :: Int -> String
padMe i =
"margin: " <> show (mod i 256) <> "px;"
example counter = D.div_
[ text_ "Hello margin"
, D.input
( keepLatest $
( \i -> oneOfMap bang
[ D.Style := padMe i
, D.Xtype := "checkbox"
, D.Checked := show (if mod i 2 == 0 then true else false)
]
) <$> counter
)
blank
]
Composition exists in other SDOM-family frameworks as well. For example, the Svelte compiler allows you to perform certain operations, like addition and string concatenation, directly on two reactive values. I personally like Event
because the composition primitives are defined in terms of the temporality of events, as we saw in both the sampleOn
and keepLatest
examples.
Behaviors
Events can also be composed with Behavior
-s. Behaviors are continuous functions over time, meaning that they can be sampled and are guaranteed to produce a value. In this way, they are isomorphic to events that fire all the time. Behavior
-s are ideal for things that should always exist, like the time of day, a random number generator or an access token. Behaviors have a more rich and nuanced set of composition combinators, like those of Apply
and Applicative
, because their temporality is well-defined at every instant.
random = behavior \e ->
makeEvent \k -> subscribe e \f ->
Random.random >>= k <<< f
example counter = D.div_
[ text_ "Here are some random numbers"
, text (show <$> sample_ (Tuple <$> random <*> random) counter)
]
Behaviors provide a powerful way to compose effect systems that, for me, is more ergonomic than working with closure-based logics like useEffect
from React.
State
Using fold
from event
, we can enrich our events with internal states. Fold is a great way, for example, to create an event that remembers its previous value.
main :: Effect Unit
main = runInBody1
( bus \push event -> plant do
let
top =
[ D.input
( oneOfMap bang
[ D.OnInput := cb \e -> for_
( target e
>>= fromEventTarget
)
( value
>=> push <<< Right
)
]
)
blank
, D.button
(bang $ D.OnClick := push (Left unit))
(text_ "Finalize text")
]
events = partitionMap identity event
current = sampleOn events.right (events.left $> identity)
previous = compact $ mapAccum (\a b -> Just a /\ b) current Nothing
D.div_
[ D.div_ top
, D.div_ $ text (("Current value: " <> _) <$> current)
, D.div_ $ text (("Previous value: " <> _) <$> previous)
]
)
Fixed-points
Fixed points are events whose outputs are fed back into their input. We've already seen this in the very first example, where the output of the age
event was used as its own input via setAge
. Fixed points can also be used for "closed" events, meaning events we cannot push to, like timers. This is done using the fix
operator from purescript-event
. In the example below, we create a simple debounce using a fixed point operator. The original implementation of this debounce comes from Phil Freeman's event library.
debounce
:: forall a b
. (Event a -> Event { period :: Milliseconds, value :: b })
-> Event a
-> Event b
debounce process event = fix \allowed ->
let
processed :: Event { period :: Milliseconds, value :: b }
processed = process allowed
expiries :: Event Instant
expiries =
map
(\{ time, value } -> fromMaybe time (instant (unInstant time <> value)))
(withTime (map _.period processed))
comparison :: forall r. Maybe Instant -> { time :: Instant | r } -> Boolean
comparison a b = maybe true (_ < b.time) a
unblocked :: Event { time :: Instant, value :: a }
unblocked = gateBy comparison expiries stamped
in
{ input: map _.value unblocked
, output: map _.value processed
}
where
stamped :: Event { time :: Instant, value :: a }
stamped = withTime event
example counter = do
let
myCounter = debounce
(map (\x -> { period: wrap (toNumber x), value: unit }))
(fold (const (add 300)) counter 0)
D.div_
[ D.div_ (text_ "I get slowwwwwerrr")
, text (show <$> fold (const (add 1)) myCounter 0)
]
For me, fixed points are the crown jewel of reactive programming. I'm sort of obsessed with them, and I use them to create audio feedback loops in the browser (I've put an example at the end of this article). But I also use them for more protean tasks, like debouncers, and working with them is quite natural in the horizontal events model.
Going horizontal
By replacing model
from SDOM
and props
/hooks
from React with Event
, we can get amazing performance without sacrificing the push/pull mechanism of hooks or the expressiveness of models. We can also compose together events, allowing us to express rich functional relationships and make fine-grained performance decisions. Furthermore, we have the possibility to develop arbitrary stateful logic for any event using fold
and fix
, opening the door to ad hoc state machines nestled within any part of a component.
Vertical events
If horizontal events fan out to leaves of a DOM, vertical events are how we modulate the leaves themselves as a function of time.
Different frameworks have different terminology for this: React uses a special property called key
for each element of an array, SolidJS uses a primitive called For
, and Freeman's SDOM uses the incremental lambda calculus (he published a second package, called purview
, that explores this idea further).
React example
In React, key
is needed to make sure the list is well sorted. Otherwise, it would be costly (if not impossible) to know what elements changed and what elements stayed the same.
function MyList() {
const numbers = [1, 2, 3, 4, 5];
const listItems = numbers.map((number) =>
<li key={number.toString()}>
{number}
</li>
);
}
Solid example
SolidJS accomplishes the same feat with For
, using referential equality to compare nodes.
<For each={cats()}>{(cat, i) =>
<li>
<a target="_blank" href={`https://www.youtube.com/watch?v=${cat.id}`}>
{i() + 1}: {cat.name}
</a>
</li>
}</For>
It also has a similar system to react via a component called Index
.
Purview example
purescript-purview
uses incremental functions to accomplish a similar goal. The following creates an ordered list (ol
) in purview:
element_ "ol" $
IArray.mapWithIndex (\i x ->
let changeAt i_ change_ c = change_ (IArray.modifyAt i_ c)
delete = Atomic.lift2 (\i_ change_ -> eventListener \_ ->
change_ (IArray.deleteAt i_)) i change
in element_ "li" $ IArray.static
[ component (Atomic.lift2 changeAt i change) x
, element "button" (constant (wrap mempty))
(IMap.singleton "click" delete)
(IArray.singleton (text (constant (wrap "Remove"))))
]
) xs
Here, the functions modifyAt
and deleteAt
act as CRUD operations on an incremental structure.
What's in a name?
All of these approaches use indices to track changes over a data structure. In the case of React, the index key
is explicit. In the case of For
in Solid, the index is referential equality. For incremental functions, instead of providing the full data structure on each render, we only provide diffs that resemble CRUD operations.
The difficult thing to get right in all of these approaches are the indices. Essentially, we need to give things names so that, when one needs to sort a collection or delete an element from a collection, we know exactly which index we're talking about.
Vertical events are a solution to this problem that, like horizontal events, pushes data so far to the boundary that we effectively make it synonymous with an event. But here, instead of pushing a model to the boundary and spreading it horizontally across many elements, we push an index to the boundary of its event until it goes one level deeper and becomes a nested event (thus the vertical).
Getting vertical
Let's see a small example of a vertical event in purescript-deku
. We'll use the most classic dynamic structure of them all: the todo list.
data MainUIAction
= AddTodo
| ChangeText String
data TodoAction = Prioritize | Delete
main :: Effect Unit
main = runInBody1
( bus \push event -> plant do
let
top =
[ D.input
( oneOfMap bang
[ D.OnInput := cb \e -> for_
( target e
>>= fromEventTarget
)
( value
>=> push <<< ChangeText
)
, D.OnKeyup := cb
\e -> for_ (fromEvent e) \evt -> do
when (code evt == "Enter") $ do
push AddTodo
]
)
blank
, D.button
(bang $ D.OnClick := push AddTodo)
(text_ "Add")
]
D.div_
[ D.div_ top
, D.div_ $
( \txt -> keepLatest $ bus \p' e' ->
( bang $ Insert $ D.div_
[ text_ txt
, D.button
( bang
$ D.OnClick := p' SendToTop
)
[ text_ "Prioritize" ]
, D.button
( bang
$ D.OnClick := p' Remove
)
[ text_ "Delete" ]
]
) <|> e'
) <$>
filterMap
( \(tf /\ s) ->
if tf then Just s else Nothing
)
( mapAccum
( \a b -> case a of
ChangeText s -> s /\ (false /\ s)
AddTodo -> b /\ (true /\ b)
)
event
mempty
)
]
)
Here, the list of todo items has the type Event (Event Child))
. The outer event is used to add elements to our MVC. The inner element are instructions with respect to the element: in our case, Insert
, SendToTop
and Remove
. And Child
itself holds an element when Insert
is used. This element has its own internal event logic and may itself have vertical events, which may have their own elements, which may have their own vertical events, which... You get the idea!
Vertical events solve the indexing problem by transferring the indexing mechanism to a hierarchy of events. Every time the outer event fires, a new inner event with its own logic is created.
Vertical events are very forgiving structures because they decouple emission and interpretation. If I were more hand-wavey, I'd even say that the Event
is the free incremental structure (ok, fine, I said it...). With an incremental data structure, the CRUD operations that are emitted have a semantic meaning that is fixed by the data structure. For example, deleteAt
for an incremental array can only mean one thing. Vertical events, on the other hand, emit instructions that are interpreted by the rendering engine, very much like a Free Monad is interpreted by an interpreter. For example, SendToTop
from our Todo
example may mean multiple things in multiple contexts, and its meaning can be determined by the framework on an element-by-element basis. This allows one to create rich incremental algebras of vertical effects and to even, in the most exotic cases, have multiple levels of event nesting for phenomena that have multi-dimensional indices.
Recursion, and fixed points
As mentioned before, vertical events can go arbitrarily-many levels deep depending on the dimensionality of the use case. In many cases, the semiotics of depth will be fixed. For example, Event (Event Child)
means one thing, Node
means something else, but Event (Event (Event (Event Child)))
means nothing so our framework (in my case Deku) can simply not admit it. However, functional patterns open up a much larger range of possibilities. One that we've already seen are recursively defined nested structures, which is how we create a dynamic DOM tree. We can also have:
- arbitrarily deep recursion via
Free Event a
- generative events via
Nu Event
(be careful if you do this to unsubscribe old events, otherwise your computer will fry!!) - infinitely deep empty events via
Mu Event
. I have no idea why one would do this, but it feels sort of profound, so why not!
Shared events and providers
Vertical events and horizontal events can draw from the same source, which solves some classic problems in reactive apps. For example, we can use the same counter to increment a text node (horizontal) and increment a user list (vertical).
Shared events also pave the path to provider systems, which allow us to (for example) propagate a token across an entire application. Providers can be implemented as a distributive application monad, allowing us to pick of the elements we need when we need them à la React Providers.
Horizontal and vertical events in anger
I've given several examples in this article showing horizontal and vertical events being used in purescript-deku
, but the impetus for this experiment was a rewrite of purescript-wags
. On the front page of the wags documentation, there is a single slider that emits events that are consumed five-layers deep of a nested feedback loop. Its performance is much better than the original wags, and it is less lines of code and less type-level machinery. Using this idea of feedback loops, I've started a project called Machines of Infinite Fortune that take a single impulse and uses analyzer nodes to shape the resulting sound ad-infinitum (or at least until you close your browser window, which for me is never).
I'm pretty stoked about this concept of horizontal and vertical events as alternative to, and hopefully simplification of, the more traditional concepts of "model" and "dynamic collection. I hope that they serve as useful tidbits of thought for you in your own reactive projects as they scale horizontally and vertically to new heights!
[1] It is unclear which frameworks inspired which frameworks as SDOM ideas permeated the develo-sphere in the mid-to-late 2010s, but I assume they all borrowed from each other to a certain extent.
Top comments (1)
Amazing article! The detailed explanation of complex concepts with clear examples made a lot of things click.
Small nit in the vertical event example is the unused:
data TodoAction = Prioritize | Delete
, which confused me for a bit as I thought they were used instead of the Deku instructions