Signals, computed properties and other reactivity patterns in Hologram

I’m fairly new to the Elixir community but my sense of it and the language itself is more along the lines of “pragmatic”. I hadn’t really considered “explicit” as a defining characteristic, particularly when it invokes macros in common places.

I do think keeping the “magic” to a minimum is a good idea and macros should be used sparingly and with good reason. IMO, this is a great opportunity to introduce some “magic” that provides a much better DX and has a relatively straightforward rule that can be digested and understood quickly.

1 Like

Hi Bart,

We just worked on something vaguely similar with @venkatd, for spreadsheet-like DAG computation definitions. We chose to eliminate the macro DSL we had prototyped because it went in the way of explicitness and prevented using function captures without adding a lot of workaround code. Some cells need to call the world, some cells need to be cached, some do not, and this is up to the user.

Now the API looks like :

defmodule MyDummySheet do
  use ExSheet.FunctionDsl

  def build_sheet() do
     new()
      |> input(:first_name, description: "A person's first name")
      |> input(:last_name, description: "...")
      |> input(:tenant_id)
      |> input(:age)
      |> cell(:full_name, inputs: [:first_name, :last_name], compute: fn (f, l) -> "#{f} #{l}" end, cache: true)
      |> cell(:tenant_age_rule, inputs: [:tenant_id], compute: &MyApp.AgeRule.get_for_tenant/1)
      |> cell(:passes_age_check, inputs: [:age, :tenant_age_rule], compute: fn age, rule -> rule.(age) end)
  end
end

There is a bit more, but the core is a DAG of computation which you can selectively memoize or not. Usage of the given sheet is simple :

sheet = MyDummySheet.build_sheet
  |> set(:first_name, "foo")
  |> set(:first_name, "bar")
  |> set(:tenant_id, 1)
  |> set(:age, 21)

{passes, sheet} = get(sheet, :passes_age_check)

We use it for very different purposes. I use it to model UI state. We chose to keep the core API very small, but building on top of it is quite simple. For example, I’ve added a few helpers for sheet composition (nested computed state cleanly defined in separate modules) in my app that will not be in the core library.

The core of it is reactive calculations, but not linked to any framework or library. We have a few convenience helpers for collecting inputs to a map, and do not force a solution on how to hold a sheet in a process.

I see it as a way to organize an otherwise ad-hoc big “with” statement with laziness, dependency tracking, partial querying, and optional caching ?

My point is that maybe computed properties are interesting to be consumed by an UI layer or anything else, and might benefit of not being directly linked to Hologram ? Despite being super useful if you provide them.

2 Likes

Hi Lucas,

Thank you for sharing! The ExSheet API looks nice and clean. I can see how the explicit pipeline approach with selective memoization works well for spreadsheet-like computation graphs.

I think these are kind of two different use cases that probably have different optimal solutions, and your approach makes sense for what you’re building.

Different problems, different constraints

Component-scoped computed properties vs. general computation DAGs have fundamentally different needs. Spreadsheet DAG computations often need external I/O, side effects, and selective execution - exactly what your library handles. UI component derived state, on the other hand, should be pure functions, synchronous, always safe to recompute, deterministic, and locally scoped. Your solution is more flexible and general-purpose, which fits your use case.

Why tight framework integration makes sense for UI components

The architecture of modern web frameworks has kind of converged to the same patterns: component-based, composition pattern, and reactive/signal patterns. Users currently expect these reactive/signal patterns to be provided by the framework, and that makes sense because the reactivity system needs to be tightly coupled with the rendering engine. Frameworks can optimize the entire update cycle when they control reactivity.

When reactivity is built-in you get one consistent mental model throughout the app and possibly better tooling/debugging/documentation because the framework can understand the reactive state.

Hologram-specific considerations

If Hologram used a separate library for component computations:

  1. Transpilation overhead: The computation machinery would have to be transpiled from Elixir to JS. There’s always some overhead (boxed types, proxies for function calls, data cloning due to immutability, etc.). For critical performance paths that run on each render, it’s better to implement things by hand in JS. This is where compile-time analysis comes in - it can extract dependency information, build the computation graph, determine topological sort order, and generate optimized metadata at compile-time, then feed all of this to hand-written JavaScript code that executes efficiently at runtime without the transpilation overhead.

  2. Framework integration: The macro pattern helps take business logic out of templates. With a separate library, templates would need something like {MyComputedState.get(@computed_state, :full_name)} rather than just {@full_name}.

  3. Orchestration: Each action would need to imperatively update the computation state whenever dependencies change. When using macros, that orchestration happens automatically. This becomes even more valuable when Hologram introduces reactive queries from local-first data sources.

  4. Performance optimizations: When the framework understands the reactivity model, it can apply sophisticated optimizations like offloading computations to micro-tasks, batching updates, or scheduling work based on priority. These kinds of optimizations are much harder when reactivity is external to the framework and the computation is just an opaque function call.

On automatic vs. explicit dependencies

Both approaches are DSLs with different trade-offs:

# automatic dependency tracking
derived :full_name do
  "#{first_name} #{last_name}"
end

# explicit version
|> cell(:full_name, 
    inputs: [:first_name, :last_name], 
    compute: fn (first_name, last_name) -> "#{first_name} #{last_name}" end, 
    cache: true)

The explicit version gives you fine-grained control over caching and execution (great for general computation graphs where some cells may need I/O or shouldn’t be cached). The automatic version reduces boilerplate and eliminates the “missing dependency” bug class. Since Elixir provides compile-time analysis through macros, and Hologram has the call graph available, automatic tracking leverages those strengths.

For UI components where you typically want everything memoized by default, opt-out memoization creates a “pit of success” - good performance without profiling every decision.

Question for you

Can you explain a little bit more about this problem? “It went in the way of explicitness and prevented using function captures without adding a lot of workaround code.” I’d love to understand what specific challenges you encountered - it would be helpful to think through those edge cases.


So as you noticed yourself, these are different use cases. Your ExSheet library solves a more general problem with appropriate flexibility, while component-derived state has narrower constraints that benefit from tighter framework integration. Both approaches make sense in their respective contexts!

Thanks again for sharing your work!

2 Likes

One more thing I forgot to mention: the explicit dependency approach has challenges with nested state structures.

If you have state like %{user: %{profile: %{name: "...", avatar: "..."}, settings: %{theme: "..."}}} and want to derive a value from just state.user.profile.name, you’d have to list :user as the input dependency. This means the computation would re-run even when unrelated nested fields change (like state.user.settings.theme).

With automatic compile-time dependency tracking, the framework can analyze the code (thanks to Hologram’s call graph) and determine the exact nested path dependencies, avoiding unnecessary recomputations. This becomes increasingly important as component state grows more complex.

2 Likes

This is an excellent and thorough response. I want to say again that I really appreciate the effort you are putting in here! It will probably take me a few replies just to respond to this.

There are parts of this I agree with and parts I don’t. I’m going to first just get a couple things out of the way to clear some room for the interesting parts :slight_smile:

You can write immutable and functional code in a language like JS. It’s just not enforced.

That is really what React apps are doing if implemented properly. Even the React engine, which abuses global state for hooks, could be implemented with algebraic effects in a functional language that actually had them. Unfortunately we don’t have them either, but we have global state (process dictionary) so these lines are blurry anyway.

The point being: you could write perfectly idiomatic React-style components in Elixir, and actually they are probably more idiomatic in Elixir than in JS.

This is a limitation of React but not of React’s model. Actually Steven Wittens, whose writing I linked above, is the author of use.gpu and its Live React clone has hooks and no-hooks which can be executed conditionally (but only in pairs). Definitely check that out if you’re not familiar; I think they’re quite clever.

I don’t think it’s more or less declarative, but I agree it’s a good idea that would be nice to have in React’s model. There is no reason it couldn’t be added! There are times when you want to manually specify dependencies, but this is rare and automatic would be a great default.

2 Likes

This is a very good counter to what I wrote. Honestly the few sentences of feedback I tacked on there were probably not deserving of such a clear response, so I will try to make up for it.

The problem is not so much with what you have right now, but what it will become. You are going down the path of forcing the user to build up an explicit computation graph in init() in order to write their application logic. Try to picture what this is going to look like in a complex app once you’ve filled in all the blanks.

component
|> state(:user, fn %{id: id} -> load_user(id) end)
|> derive(:name, fn %{user: user} -> "#{user.first} #{user.last}" end)
|> derive(:theme, fn %{user: user} -> user.settings.theme || "light" end)
|> derive(:expensive_thing, fn %{user: user} -> user.cached_thing || compute_thing(user) end)
#... 500 more lines of code like this

This will become, essentially, the Ecto.Multi problem taken to the extreme (sorry in advance to Multi enjoyers). This is not Elixir code anymore. This is a dataflow graph modeled in Elixir. You are not getting the “benefits of Elixir” by mangling your code in this way. With transact() at least we seem to have finally learned better.

And your response cannot be “it won’t get that bad, it’s just a few computed properties here and there”: yes, it will get this bad once you have to actually build real apps.

There is another problem with this approach, by the way: it does not compose. The React team evaluated a number of solutions before settling on call order because it can compose. If you are relying on the property names (:user, :name) to key off your rewindable state then they will collide if you try to factor them out into functions. Note that LiveView actually has this problem in several places, which is probably the biggest problem with LiveView.

I will note, also, that I actually proposed this sort of API for LiveView earlier this year, and others told me they thought it would end badly (and I was annoyed by this). I am arguing in their favor, now, because it is a bad idea. I just had no concept of how deep this problem actually runs, or what it takes to arrive at a proper solution.

(On the other hand, this trick could actually be retrofitted to LV, whereas a proper solution probably requires throwing out LV’s entire engine and API and starting over (like React did with Fiber and Hooks). But that’s what you’re doing!)

2 Likes

This is true, and I want to discuss this specifically.

Hooks give you about as much freedom as is realistically possible. Each component is like a “stack frame” in a program, with the unique property that they survive the function call and can be used again. This gives you a place to do the caching and memoization needed to make declarative programming performant.

The idea here is that you want to define a program which is written as though all of the components are rendered from top to bottom whenever anything changes. That’s the declarative part.

What is so beautiful about React’s model is they managed to find a way to produce a uniform interface for this with few concessions. The “rules of hooks” are the concessions, and they exist because there has to be some restriction to the program’s execution or the cache will not “line up”. React performs this “lining up” at two different levels: at the component level, where they are lined up by the key path, and at the hook level where execution order is used. Hooks exist specifically as a tool for factoring out reusable code within a component, to avoid using “components for everything”. But they don’t have the key trick so they can’t be conditional; there you would use components.

In practice that approach has worked well. It was evolved from a long sequence of mistakes and failed attempts over many years. Nobody gets this right the first time. React certainly did not!

The problem that I have with your approach here is not really about “compile time” (I should not have mentioned this at all). It’s really that you have split init and render, and everything else is just downstream of that.

The goal of programming in a declarative style is to write code that rebuilds itself each time. A React engine is a tool for making this style performant, but it preserves the style of the underlying programming language (be it JS or Elixir or whatever). The component is called each time, not once at the beginning (init).

What you are doing is effectively inventing a new programming language, similar to what I showed above, and defining an init function written in that language to build up an execution graph to produce intermediate state for the template. The graph is taking care of the “lining up” (you call this automatic memoization), and actually it probably could be as expressive as React/Hooks if done right. But it’s not Elixir anymore.

In React, the component is the execution trace. The component is what you call on render. Not an execution graph. This is a substantial stylistic difference.

If you were to attempt something closer to React’s model you would end up with more idiomatic Elixir. The example of Ecto.Multi vs Repo.transact() is a helpful analogy, I think, except that it will be much worse here because frontend is generally a lot more intricate than backend.

1 Like

Thanks for the detailed response! I think we might be talking past each other a bit here - let me clarify what I’m actually proposing…

You’re not building up computation graphs manually in init()

The scenario you described - piping through hundreds of lines of DSL in init() - is not what I’m advocating at all. You’re right that a long pipeline of DSL calls in init() would be unwieldy - similar to the concerns you raised about Ecto.Multi.

What I’m proposing is defining derived values through macros in your component module, not building graphs imperatively. The macros extract dependency information at compile-time, and Hologram automatically constructs the computation graph and handles recalculation. Here’s the difference:

# NOT this (what you're worried about):
def init() do
  component
  |> state(:user, fn %{id: id} -> load_user(id) end)
  |> derive(:name, fn %{user: user} -> "#{user.first} #{user.last}" end)
  # ... 500 more lines
end

# But THIS (declarative macros):
defmodule MyComponent do
  use Hologram.Component
  
  derived :full_name do
    "#{@first_name} #{@last_name}"
  end

  # Component logic continues normally...
end

This is implicit computation graph construction through compile-time automatic dependency resolution, not forcing users to explicitly build graphs. The rest happens at compile-time.

This is not some novel dataflow framework

I’m not trying to create a dataflow framework where everything is computed this way. I want something similar to Svelte’s $derived - a feature that Svelte has had much success with and which has been generally praised. You don’t put everything in it, but only things you want to memoize and/or where it makes sense to extract business logic from templates.

On the complexity concern

I hear your concern about real-world complexity and the “500 lines” scenario. The question is whether the complexity comes from the pattern itself or from trying to do everything in one place. I believe proper component boundaries would keep each component’s derived state manageable.

On composability

You raise an important point about composition and naming collisions. However, remember that Hologram has compile-time macros (unlike React) and a call graph (unlike LiveView). The composability problem could be solved through these capabilities.

For example, here are some directions that could be explored:

defmodule UserDerived do
  def full_name(first_name, last_name) do
    "#{first_name} #{last_name}"
  end
end

defmodule MyComponent do
  use Hologram.Component
  
  # Most explicit - manually specify the call
  derived :full_name do
    UserDerived.full_name(nested.user.name, nested.user.surname)
  end
  
  # Or using function capture
  derived :full_name, &UserDerived.full_name/2
  
  # Or import with automatic integration
  import_derived UserDerived
  
  # or with options, here import only the full_name/2
  import_derived UserDerived, only: [full_name: 2]
  
  # or map to nested state
  import_derived UserDerived, map_to: [:my, :nested, :user]
  
  # or with prefix (use @user_full_name in template)
  import_derived UserDerived, prefix: :user
end

Since Hologram has compile-time analysis and the call graph available, something even cleaner might be possible. React doesn’t have these tools at its disposal.

React’s solution and its trade-offs

React wanted to solve composability and key collision issues, so it went with explicit call ordering. But that created its own set of problems and constraints (the Rules of Hooks). Every solution has trade-offs.

The key difference: Hologram’s compile-time capabilities open up different solution spaces that weren’t available to the React team working within JavaScript’s runtime constraints.

I’m not dismissing React’s lessons - they’re invaluable. But I think there’s room to explore how compile-time metaprogramming and static analysis might address the same problems differently.

2 Likes

Perhaps this isn’t literally the API you are proposing, but they are equivalent. Whether you define them in init() or via a macro in the body of the module is not relevant (this is why I regret mentioning compilation).

What matters is that you are deviating from the component as code and turning it into a DSL for building up graphs, and my prediction is that at scale this is going to get ugly.

Let me try something different: why is there an init() function? What does it do? Do you see how it might relate to React’s class component initialization, which they went to enormous lengths to remove?

It could, yes, and this is an opportunity to “do better” than React. This is actually a direction mentioned in the article I had linked, and I think it’s quite promising.

You can do all sorts of cool things at compile time! Just be sure to mind the graveyard.

2 Likes

Hi Bart, thanks for your thorough response. I guess the best way to describe my use case would be to standardize how I build up state for discrete parts of my backend, or before it reaches an UI framework. I see that I have started to use it like I use Pinia in the Vue world, or maybe like I would use Ash calculations if I was using Ash. This explains why I do not replace automatic dependency tracking up to sub-fields or list elements : if this backs an UI, there still is a reactivity layer afterwards which is the UI framework. My problem is just composable state DAGs.

Showing this example, I was mostly thinking about the compile-time graph definition via macros : I did not manage to build something satisfying there - as it was meant to be an optional alternative way of writing the graph declarations, I needed to duplicate the runtime checks at compile time (which was feasible) but did not find a satisfying way to allow inline function expressions, &Mod.fun/arity references, and referencing local functions.

But this is a smaller footprint and only looks similar at a glance to the DSL options you were discussing. You must have greater expressive freedom with macros than I do, both from writing Hologram itself and having an explicit compiler !

1 Like

@bartblast

To avoid talking past each other, maybe you can clarify the following for me.

Is main purpose of computed properties to be performance optimization?

Meaning if things were rendering instantly, there would be no need for computed properties. We would prefer plain functions and re-render after each change?

It would be good to discuss the “what” (public API) and the “how” (implementation/optimizations) separately or discussions could get mixed up.

Maybe I can phrase the question this way: if performance was not an issue, what would your preferred API look like?

1 Like

The purpose is efficient derived value management - automatic dependency tracking and selective memoization of computed values during re-renders.

But I think you’re absolutely right that we should think about this differently.

After thinking through this discussion more deeply, I realize it probably makes sense to take a step back and think about generic composability patterns first. The reactive patterns we’ve been discussing (derived values, memoization, etc.) would need to work within the constraints of whatever composability architecture we settle on.

React’s hooks are praised precisely because they enable composability – extracting and reusing stateful logic across components. If Hologram is going to have reactive patterns, they should compose just as elegantly (even if the implementation looks different due to Elixir’s functional nature).

I’ve just created a new thread specifically about composability patterns for Hologram

@garrison @jam @Lucassifoni @venkatd – I’d love to get your input on this broader composability question. The insights you’ve shared here have been incredibly valuable, and I think they’ll be just as relevant to thinking through how actions, state operations, and any eventual reactive patterns should compose together.

2 Likes

I don’t want to beat this topic to death but I wrote a lot of words here without providing any “visual” examples so I just want to rectify that before moving on. I’m intentionally using JS to avoid syntax bikeshedding.

Here is a function written in the “declarative” style:

function Dashboard({id}) {
  const user = db.loadUser(id);
  const name = user.first + ' ' + user.last;
  const theme = user.settings.theme || 'light';
  const summary = computeExpensiveSummary(user.subscriptions);

  return <div class={theme}>Hello {name}, your summary: {summary}</div>;
}

Note that this is just code. It’s not intended to be any particular framework. This component would be re-run on each “render”, whatever that happens to mean. To make it faster, you might memoize a couple of lines:

- const user = db.loadUser(id);
+ const user = memoize(db.loadUser, [id]);
- const summary = computeExpensiveSummary(user.subscriptions);
+ const summary = memoize(computeExpensiveSummary, [user.subscriptions]);

Note that memoizing trivial things like the name or theme is probably not worth the overhead. This is why it’s important that memoization is a choice.

Imagine that instead of providing tools like memoize we wrote a framework where you can define a computation graph which handles it automatically:

function Dashboard(props) {
  const graph = newGraph(props);
  graph.addNode('user', ({id}) => { return db.loadUser(id) });
  graph.addNode('name', ({user}) => { return user.first + ' ' + user.last });
  graph.addNode('theme', ({user}) => { return user.settings.theme || 'light'});
  graph.addNode('summary', ({user}) => { return computeExpensiveSummary(user.subscriptions) });

  graph.addTemplate(({theme, name, summary}) => {
    return <div class={theme}>Hello {name}, your summary is: {summary}</div>;
  });
  return graph;
}

I hope it’s clear why React did not opt for an API like this. Perhaps “inexpressive” was a poor choice of words, as I think you could technically make this “graph language” as expressive as React+Hooks. But it’s completely divorced from “actual code” in a way that makes me uncomfortable.

If nothing else at least I’ve finally found a way to describe why I don’t like Ecto.Multi lol

I use Ecto.Multi all the time. Yes, it is not as expressive as Repo.tranact/2 but it is good enough for simple transactions, like 2~3 database ops. That’s 90% of all my database transactions. And it make potential foot gun impossible.

2 Likes

Svelte 3 was glorious in terms of ergonomics. IME, it just worked and was a joy to work with.

Regarding the reasons cited for the move to Svelte 5 in that link:

  1. Writing svelte code outside of svelte components had a different syntax
    This was a little annoying but unless you were writing a library, you’d rarely encounter it.
  2. The compiler couldn’t see dependencies that were moved outside of the $:
    I never experienced this issue but I could see how this would be annoying, not a dealbreaker imo.
  3. Signals
    It seems Rich was heavily influenced, rightly or wrongly, by the performance Solid’s signals achieved. Svelte 3 was plenty fast and already more performant than React but Solid’s benchmarks were certainly much faster. IMO, Svelte lost some of its simplicity and value proposition in this transition but maybe it was the right move. Beyond the performance gains, they do seem to be an excellent pair for sync engines: https://youtu.be/YQT26cnCKqo?si=JKkfKgokzpK_0gTO

Bart can correct me if I’m wrong, but I suspect Hologram can avoid #2 with macro + call graph. Maybe #1 is a non-issue as well with macros. If Hologram’s ultimate vision is to be local-first, then maybe taking a hard look at #3 makes sense.

I think React’s approach was fine for its time but the state of the art has moved beyond that. I don’t think it’s a mistake that Solid, Vue, and Svelte have all converged to essentially the same solution with a different syntax.

2 Likes

I will again dodge the JS framework debate because I am only trying to share a perspective here and you should of course use whatever tools make you happy. I do not agree the SOTA has moved beyond React, though.

Yes, Svelte specifically converged towards runtime and away from compile-time, which was the important bit in context.

But if you feel you had a good experience with Svelte’s compile-time stuff then your perspective is of course appreciated (I have not used Svelte).

1 Like

I would suggest rephrase as:
”The purpose is reactivity - enabling automatic re-rendering when values change”

I deleted all mentions of implementation details to focus on the ideal API/DX.

(If we talk about memoization and such things in the problem statement, then we we allowing the implementation details to leak into the core design.)

Anyway, just a suggestion :slight_smile:

1 Like

I don’t want to derail this into some sort of Ecto discussion (it was just an analogy).

What you may have noticed is that, in general, I am extremely avoidant of solutions that only handle simple cases. The reason, fundamentally, is that I think these solutions push you away from trying to do complex things, and I think that’s a big part of why “software” has become such a sad rigid husk of what it once was.

One little Ecto.Multi here, one little derived property there… no big deal.

But it’s death by a thousand cuts.

It depends on how the API will be designed, but generally, macros combined with a call graph will provide new possibilities in this regard.

2 Likes