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.