Why not single-component modules in LiveView?

I briefly discussed this with José in an issue a while back and got to thinking about it again recently. (I don’t work with Phoenix day to day.)

He made the case that single-component modules are discouraged.

I still don’t quite understand/agree with the reasoning, so I thought this forum could be a good place to continue the discussion and solicit more opinions. I’m hoping to get a better idea of the idiom, or even to convince someone that perhaps it’s not such a bad approach :slight_smile:

To me it seems quite reasonable to use single-component modules, for a few reasons:

  1. You only need to name one thing, not two. MyWeb.NoUsernameComponent.render vs MyWeb.NameThisModule.no_username. This assumes a conventional, generic function name like render that you don’t need to think about. The framework could make that function call implicit.
  2. If the component makes use of private functions, those have high cohesion, as opposed to a module where component A uses two private functions and component B uses two other private functions.
  3. It’s intuitive and easy to navigate to some desired component when you can find it by file name. (Some editor setups also let you navigate by function, but I believe finding by filename is rather more common.)

José mentioned that compilation will be slower with more files – that’s fair, but perhaps a secondary concern, unless the app has a very high number of components? I suspect people generally don’t optimise for a minimal number of modules, but use them as a way to organise the code.

He also argued that functions and not modules are the building blocks of Elixir, and e.g. Enum has a bunch of functions, rather than having Enum.Map.call and so on. My counter-argument would be that modules also play some roles. A co-worker made the case that GenServers are very module-centric. LiveViews themselves are modules with a conventional render function, called implicitly when you do live_render(@conn, MyApp.MyLiveView).

Would love to hear more arguments and opinions.

To make it less abstract – I’d love to hear what you’d do with components like NoUsernameComponent and NoContentComponent. What module/function names would you use?

cc @thiagomajesk (who seemed to also favour single-component modules in another GH issue).

I can’t decide whether it’d be more polite to at-mention José or not here, since he must be exceptionally busy. I’ll err on the side of fewer notifications.

7 Likes

I would say that about 80% of my function components are hosted in an exclusive Elixir module, so even if I totally understand Jose’s point, I also find that compilation time is a bit less important than code readability & navigability.

3 Likes

Phoenix does not dictate one way or the other; There is more than one way to do it!

One drawback of a module based component is that it cannot contain another instance of itself without jumping through some hoop.

1 Like

I agree with you. It’s almost always a bad idea to sacrifice code organization, cohesion, and loose coupling for improved compilation time.

I feel that the argument: “fewer files mean faster compilation” is a double-edged sword. By that logic, wouldn’t you put all your functions in the same module? Also, while initial compilation time will be faster, incremental compilation time will increase (and dev experience degrade) as all your tightly coupled components that are now located in the same file need to be recompiled because you’ve changed only one of them.

It makes sense to put components in the same module if they’re logically related, otherwise they should be separate modules. Most of my components end up in their own module, simply because they don’t have enough in common with other components to warrant a single shared module.

You only need to name one thing, not two. MyWeb.NoUsernameComponent.render vs MyWeb.NameThisModule.no_username. This assumes a conventional, generic function name like render that you don’t need to think about. The framework could make that function call implicit.

I would love to have that implicit function call: <NoUsernameComponent />. I have so many components with only a render function :slight_smile:

1 Like

Hi @henrik, long time no see!

Yes, because a GenServer requires multiple callbacks in order to function.

Let’s flip it around: imagine if the second argument to Enum.map/2 (which is also a callback) was required to be a module! All of your arguments apply:

  1. Instead of &SomeMod.fun/1, I only need the module name
  2. If my mapper function has private functions, it has high cohesion
  3. You can navigate by filename

But now you have to write:

defmodule UsernameMapper do
  def call(user), do: user.name
end

Enum.map(users, UsernameMapper)

And that would be quite annoying, wouldn’t it?

In other words, the reason why “Function components” are functions is because they only need to be functions. In fact, I think it would be counter-productive to force them to be modules, for organization purposes, because we would end-up restricting its usage, instead of improving it (similar to the Enum.map/2 above).

On the other side, if you have two components that need the same private functions (which is not that uncommon), then you now need a third module so they can share their code. The current approach at least gives you flexibility to choose.

Furthermore, most of my LiveView modules have their own private function components. Forcing them to be modules would force me to extract them to separate modules, going directly against the high cohesion you suggest. :slight_smile: As a concrete example, here is Livebook Home page, it has two private function components that are specific to the home page. Forcing them to be modules would make refactoring large templates harder, rather than easier.

Hopefully addressable via tooling: you should be able to click a function component and take to its definition. I believe it already works on VS Code and we want to bring it to GitHub navigation too!


Yes, the compilation time was mentioned as a bonus. I don’t think we should focus on the compilation time as the main trade-off. :slight_smile:

7 Likes

I don’t think @henrik or anyone else here is suggesting forcing function components to be modules. I think we’re saying that single-component modules shouldn’t be discouraged.

3 Likes

Yes! I was just writing an edit but you were faster. So I will reply here instead:

Having a single function component per module is fine. But I don’t think it should be default modus operandi, for the reasons mentioned above. You will paint yourself into corners. :slight_smile:

4 Likes

Hi José! Nice to be back and good to see you here :slight_smile:

I’ll digest your thoughtful comments and write more later, but I wanted to clarify immediately that I didn’t mean to suggest that single-component modules should be the only way - just that it could be a blessed way; perhaps even with conveniences like the implicit render call.

I would also prefer multiple components in a module, if they share private functions or otherwise have high cohesion.

4 Likes

What I tend to do in Elixir land since we have had LiveView and components, is to separate them by UI functional logic in the respective Functional Components, that is:

  • Layout
  • Buttons
  • Forms
  • Navigation
  • Data
  • Typography
  • Feedback
  • Misc

So this could become for example MyApp.Components.Forms, alias this and a text_input could just become
<Forms.text_input>…</Forms.text_input>.

When I’ve created component libraries in the past in Node land I would organize them in directories or packages even, so not that much different at the end of the way when consuming them.

If I really wanted single file components, one could import all of the single file components in parent module using a using macro, and get best of both worlds (well maybe not compilation time)?

4 Likes

Reading this thread I was gearing up to write the same thing but here you’d already said it more eloquently than I would have!

@henrik, having mostly React experience when working in component-based design, I had similar thoughts to you, but I’ve long gotten over them. If you organize in the way described by @greven, this largely takes care of your concerns around private functions. If you start to group your related components into modules, you start to find that the private functions will serve multiple components. This is basically just the same idea of extracting common functions in react and then importing them, though I think the HEEx way is superior since the functions are actually private (and as far as I’m concerned, the fewer imports the better). In your example I would stick the no_username component in a module along side other username-related components.

Otherwise, the only thing you’re losing is the ability to fuzzy search a specific component, though for me this is a good thing since I always found component polluted my fuzzy results. Now I just grep for def component.

The other thing to mention that you are probably well aware of is that if you want this functionality it’s available in Surface! Since Surface is still going strong, I’d personally prefer that HEEx keep going the way it’s going.

One thing I would maybe like is a pretty way to expose a LiveComponent “constructor”. Recently, all my live components have a function component that that calls <.live_component module={__MODULE __} {@args} /> for me. I’m not sure what that would look like tho and my current solutions feel just fine to me. Maybe something like a deflive?

1 Like

I like the idea of a deflive! And, maybe even better, you could implement this for all live components by default by adding the function component to the live_component helper in your web module:

@doc """
Renders the #{inspect(__MODULE__)} live component.
"""
def live(var!(assigns)) do
  ~H"<.live_component {assigns} module={__MODULE__} />"
end

Then, for any live component that has use MyAppWeb, :live_component, render it like this:

~H"""
<MyComponent.live id="my-component" other_arg={@my_arg} />
"""
1 Like

Yep, that’s essentially what I was talking about deflive being (although I always name the function after the module and import it). I may end up trying something like that but I haven’t quite felt the pain as I don’t do this every live component and I don’t have too many of them.