DEV Community

Cover image for Horizontal and vertical events
Mike Solomon
Mike Solomon

Posted on • Updated on

Horizontal and vertical events

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:

  1. 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.
  2. 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:

  1. The slow performance of components with dense models.
  2. 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>);
}
Enter fullscreen mode Exit fullscreen mode

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" ]
    ]
Enter fullscreen mode Exit fullscreen mode

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.

Try it here

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
      }
Enter fullscreen mode Exit fullscreen mode

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.

Try it here

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!")
  ]
Enter fullscreen mode Exit fullscreen mode

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.

Try it here

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))
  ]
Enter fullscreen mode Exit fullscreen mode

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.

Try it here

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
  ]
Enter fullscreen mode Exit fullscreen mode

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.

Try it here

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)
  ]
Enter fullscreen mode Exit fullscreen mode

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.

Try it here

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)
        ]
  )
Enter fullscreen mode Exit fullscreen mode

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.

Try it here

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)
    ]
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Try it here

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
                )
        ]
  )
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
xvaldetaro profile image
Shanji

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