Introduction
I’ve begun work on a library I think will dramatically change (and, I hope, improve) how data is loaded in live views. I wanted to get this post up now to facilitate early design feedback.
The Problem
As I work on larger and larger live views I’ve found an issue appears every time, without fail. Live views are not open to extension. If you want to add a live component to a live view that needs live data (data that is updated in response to changes, usually pub sub events) you need to touch everything from the live view down to that new component. This is okay for small live views, but as live views grow this gets worse because a) you have to do more work per change (prop drilling) and b) collaborative development becomes increasingly painful since you’re increasingly updating the same things risking the chance for change conflicts (and botched manual conflict resolutions).
This is not a new problem for view engines, though it is exacerbated in our case by live views being the only components capable of directly subscribing to messages (live components and functional components don’t have handle_info), meaning all live data needs to kept at the very top level even if its only used in a small deeply nested sub-tree of the UI. In theory we could use nested live views to store live data for a sub-tree, but that ends up causing more issues than it solves since they have no process to synchronously re-render in response to new data.
The Solution Space
Anyway, like I said, this is not a new problem. Luckily, as an old problem, it has some established solutions.
The first is component composition. This is great for a bunch of reasons, but it’s not a total solution because composition comes at a cost. You can no longer use the data passed into you for conditional rendering purposes. E.g. you can’t have a component which takes a user struct and only renders the first name when the first + last is > 50 characters. This logic has to be moved into the caller. At the extreme you end up with a live view that not only loads all your live data, but also contains all the logic which works with that data! This obviously doesn’t scale.
The other solution is data dependency injection. Instead of giving up control over rendering (component composition) to the caller, components invert their data dependencies. They take data, rather than being given data. In react this might look like a global store, which a component can subscribe to a slice of. The component takes the data it needs from a well known place, rather than being given data by its parent. This relieves the caller of the responsibility to provide data to its child. This leaves your component tree open to extension, so long as you store has (or can get) the data the component being added needs, you can add that component without the rest of your component tree being updated (or even knowing).
This second approach is how all modern large (50k+ LoC) UI applications are built. Different frameworks have their own libraries and flavors (React/Solid/Vue/Angular-Query, Redux, React’s Context API, Vue’s Context API, Zustand, Jotai, MobX, etc.), but they all function off of the same principle: let components take the data they need
LiveQuery’s Approach
LiveQuery does (will do) this for live views. Unfortunately, LiveQuery will be relatively inefficient, at least initially, because live views lack some key requirements for efficient dependency injection (there’s no way for a live component to subscribe to an external store). However, trading rendering efficiency for development speed has historically proven to be a good bet (see the UI = f(state) movement).
The idea is fairly simple. When you add LiveQuery to a live view, all your live data gets moved out of your assigns and into an external store (probably somewhere in the process dictionary, unfortunately). In place of all your data, you get a store reference that you keep in your assigns. This reference contains 2 things: 1) the info required to find your store and 2) a version number which is incremented every time any data is updated in your store. You then pass this store reference as an assign everywhere. It should be ubiquitous.
Data loading then works like this: you take your store reference and either grab the data that’s already there, or if it hasn’t been loaded yet, you load the data. When you render your component tree, everything gets the data they need, and they all share the same data, even different components, since they’re all loading from the same store.
The store is really just a fancy key-value map where keys indicate the query performed to get the data, and values are the data. In addition to just storing data, the store also allows queries to defined pub sub subscriptions (which it dedupes) + pub sub event handlers. The idea being that a query not only defines how to load some data, but also how to invalidate/update that data over time.
The downside of this approach is obvious, whenever any live data changes the entire component tree must be re-rendered since there’s no way to know which sub-trees need which data. However, if this pattern catches on, as I expect it will, live view could add some very simple callbacks to allow more granular re-rendering behavior (specifically an unmount callback for live components).
Finally, while I’m initially targeting this library for single process use, one could imagine sharing a store between many different live views on the same node dramatically reducing memory usage and database performance. In effect, this is a bespoke cache. No reason it can’t be used by multiple processes. This is, of course, a down the road idea though.
Development
I’ve already started working on this library and should have a beta release out in a week or so. I’m making this thread now to gather:
- Feedback about the design. Can you think of a more efficient implementation given live view’s API’s current limitations?
- Feedback about the pattern as a whole. Have any of you built very large live views (50k+ lines)? If so, how did you solve this scaling issue?