6 min read

Zero Hooks or Signals

Zero Hooks or Signals

This is part 9 of a 12-part series called Distilling the Web to Zero. The web is far from done improving - particularly as it pertains to building rich web applications that are, to users, developers, and businesses alike, more desirable than their native-app counterparts. This series is prefaced by defining the biggest challenges on the road towards that goal and the 12 essays that follow explore potential solutions using concrete examples.

Zero Hooks or Signals


"Life is 10% what happens to us and 90% how we react to it."
– Charles Swindoll

On properties

Some UI frameworks take an object-oriented approach for constructing a UI’s view hierarchy. When your components are just classes (or structs), it’s a natural fit to use instance properties to hold the state of your UI. SwiftUI is one example and results in clear, familiar code.

On hooks

Some languages, like Kotlin, don’t have the luxury of complex value types, only reference types, which leads to excessive GC-pressure during hot paths such as rendering/composing. Sometimes a language’s implementation of classes is awkward and confusing, like JavaScript. For these reasons (and more) frameworks like Android’s Compose have chosen a functional approach where the hierarchy of components is constructed by way of functions calling other functions. Interestingly, React supports both OOP and functional paradigms however they strongly recommend the latter.

Many developers insist that since UI is merely just a function of state, then a functional approach is better for modeling your view hierarchy. However doing so still requires emulating object oriented principles in awkward ways – mainly hooks.

The purpose of both hooks and properties goes deeper than just holding state. They also serve to isolate the scope of invalidations. Since rendering (and diffing) can be a very expensive operation, being able to isolate changes to smaller portions of the UI is necessary for optimizing performance and avoiding jank.

On signals

Signals improve on hooks by offering more granular reactivity, creating fine-grained dependencies and improving performance by preventing unnecessary, expensive renders.

When a signal is changed, it issues a command to modify the DOM thus circumventing the expensive render-and-diff step.

On state for web4

Which of these state management techniques fits best in a web4 context?

None of them.

Declarative instead of imperative code

There’s a dissonance between imperative and reactive code. By design, imperative code is very “temporal.” This makes it very easy to reason about because it’s inherently a list of operations to perform one step at a time. Do this. Next, this. Then this. Imperative languages offer a very natural way to represent business logic.

A reactive paradigm does not follow such a linear path. Take Ryan Carniato’s example from his article The Quest for ReactiveScript for a good example of the non-linear nature of a reactive paradigm.

Both paradigms are very powerful, one is not necessarily better than the other. The difficult part is managing the boundary where they meet. web4’s ZeroScript leans on HTML’s declarative nature to avoid this no-man’s land.

Expressions instead of derived state

So much of the gymnastics you see with hooks, signals, and derived state exist for the purpose of avoiding, limiting or isolating renders since they are so expensive. However, if rendering was free, how would that change the way we approach state management?

Since web4 can take advantage of compile-time rendering (covered previously here and here), both rendering and diffing operate on a scale of nanoseconds, instead of milliseconds. This is the web’s “quartz moment.” Quartz flipped its game upside down by offloading the operating system’s job of rendering windows on your screen to the GPU. Suddenly every window was painting itself all the time, no more "regions" complexity needed. Yesterday's UI frameworks use a similar optimization by trying to isolate what components need re-rendering based on what state has changed. This leads to a world of complexity around state-management when it comes to things like derived state or deeply nested state.

When every render is a full render, this means there’s no more need for unnatural rain dances around state management, especially derived state. Just use an expression

In the example above, any state changes will trigger a full render-and-diff which also executes any inline expressions. If the expressions’ output doesn't change the DOM is not unnecessarily updated. In the example above if count is an integer that is changed from 6 to 7, due to proper integer-math, both result in an integer of 3 and therefore benefit from skipping any expensive DOM updates.

Massively shared state

Since web4 is a reactive model that runs exclusively server-side, this means it’s finally unshackled from the restrictions of a single-threaded scripting runtime. Don’t be misled, though, using languages capable of fully utilizing dozens (or hundreds) of cores is about more than just raw speed. What’s more valuable than a 12x boost in available cores? Answer: a 5000x speed boost due to a shared address space in memory.

RAM is 5000x faster than network round trip times within the same datacenter. But more important than that, when your data is held in RAM instead of on a separate dedicated caching server, suddenly a whole new world of “active functionally” becomes possible. Cache is no longer inert and simply waiting to expire. Now any cache can be as reactive as the state stored in your hooks.

Put differently, sharding our services, whether across multiple machines or even multiple processes has made our data inert and dumb. Multithreaded runtimes means a single process operates on active data, not inert data. This is a game changer.

There’s another step function unlock waiting to be harnessed when simple changes in state can impact the experience of multiple users simultaneously. Granted, creating real-time apps like this has already been possible for decades, but it’s never been easy, and it certainly has never been the default outcome. Web4 makes real-time experiences table stakes, the default outcome.

The specific format of the src attribute isn’t terribly important. It just serves as a lookup key for the same state object that is to be shared with potentially thousands of concurrent users. This example mimics a RESTful sort of URI to help a human reason about the uniqueness of the resource.

Zero typing

While declarative code is a great tool for hiding implementation details, the rubber must meet the road eventually. Behind ZeroScript is a choice of several multithreaded, compiled languages such as C#, Java, Kotlin, Swift, Go, Rust, etc, and they all need static types. Fortunately, with the magic of source generators, each language can infer complex, deeply-nested types from ZeroScript itself. No need for upfront, manual definitions though. Instead, these definitions can be inferred from the declarative code. For example, the following ZeroScript

Can infer and code-gen the following type definitions for this state:

There’s much more to this topic, particularly as it pertains to formatters, but it will be covered in depth in an essay later this year.

Deeply-nested

One advantage to declarative state and automatic type inference is that it becomes trivial to represent your app with a single monolithic viewmodel. To those who prefer a separation of concerns by encapsulating logic inside multiple components, this might sound like an antipattern. However, one new opportunity this opens up is reusability at a higher level – instead of reusing components, entire screens can be constructed as the atomic primitive of reusability. This is the secret to true cross platform UI, but that, also, is an essay for another day.

Contextual diffing

The biggest benefit from a state management system that embraces full-rendering is the ability to do contextual diffing. This is where the system needs to know about more than just the before and after values of a single state-change event but also how it relates to the surrounding views which may or may not have changed.

Virtual DOM implementations lose this since they require sub-tree diffing for performance reasons. Signals lose this since they replace the render-and-diff step with precompiled mutation instructions.

When you can reason about your UI holistically instead of myopically, this opens the doors to far richer interactions, particularly animations. Animations need more than just “new string goes here.” For example, before-and-after context might be required to determine whether the change was an increase or decrease in order to determine whether an “in” or an “out” animation is preferred. Further, animating between two views requires that both before and after representations of that same view exist at the same time in the DOM for a brief moment.

Animating lists and grids are another great example. Perhaps one row in a list is moving from position 2 to position 7 while, at the same time, position 1 is being removed with a new row being added to position 5. Animating that requires more than just replacing a large chunk of HTML. In fact, doing it properly requires using specialized diffing algorithms such as the Eugene W. Myers difference algorithm. Android accomplishes this by way of RecyclerView and DiffUtil with breathtaking results.

Competing with native

Having framework-level support for such things is necessary to make the web competitive with native apps.

Artifacts

Source code: github.com/xui/xui