How do you organize your components with Phoenix 1.7?

Are there any downsides, like perf issues, to putting all functional components in CoreComponents as long as you prefix it with the context for organization, i.e. blog_navbar, app_navbar ?
Previously I’ve sectioned them out by context into their own modules, but I think this may not have many benefits as I often prefix the function with its context.

What’s your approach?

2 Likes

I don’t like the CoreComponents module approach. To me it feels like that messy drawer in your house where you keep that extra battery, plastic sunglasses and all sort of random things.

What I like to do, is create more topical modules such as Layout, Input or Modal. Then you can alias them where you’d import CoreComponents and then call from anywhere <Layout.sidebar />, <Input.select />, <Modal.toast /> etc etc.

Maybe at first the benefits aren’t that great, but consider these case:

<Layout.sidebar /> v/s <.sidebar_layout />

How do you know where sidebar_layout is coming from? You’d need to check for an implementation in the current module, inside any of the imports, inside your use call or any of it’s imports. That can get really tricky as the project grows. Layout.sidebar stays O(1) for definition retrieval.

Another complexity CoreComponent introduces is the question to the developer: Should this component be a CoreComponent? That is not always easy to answer and ultimately meaningless. It’s the worst kind of decision to make.

14 Likes

I break them up by type under a Components namespace and just import everything everywhere.

While one of my favourite things about Elixir is the locality—I very, very rarely alias and only import if there is one per module so it’s obvious where it’s coming from—this isn’t important to me at all when it comes to components because it’s dead obvious that <.foo /> is a component so I know where to look/grep. The additional module provides no further context, I just grep in live/my_app_web/components/. You also learn very quickly what you own and what comes from Phoenix.

I move stuff from CoreComponents into MyAppWeb.Components.Form, MyAppWeb.Components.Modal, etc and create others. Then my Components module looks like:

defmodule MyAppWeb.Components do
  defmacro __using__(_) do
    import unquote(__MODULE__).{
      Form,
      Modal,
      Links,
      # ....
    }
  end
end

Then use MyAppWeb.Components in live_view/0 in lib/my_app_web.ex and bam, all my components are available everywhere.

The drawback here is that you can’t use MyAppWeb, :component in your components as you’ll obviously get circular dependencies and it won’t compile. If I do need to reference other components in my components, I just use the fully qualified version, but of course you can also alias or whatever.

4 Likes

So are there actually perf improvements by using Module.component or by definition retrieval you meant navigating the codebase?

In either case I understand the appeal, especially for large Phoenix projects.
What I’m proposing in the OP would be a nightmare on a team with more than a couple devs.
Interesting way to go about it, thanks!

Sectioning them out but still importing them all seems interesting. I think the main appeal to one large CoreComponents is an ability to reference every single component in another one: can that be achieved here? I.e. you can use all the Links components in components defined within the Form or Modal

Yes but you have to full qualify them. The big drawback ehere is that it’s easier to introduce cyclical references. This can be avoided if you sort of think about your components in “layers” (there’s a correlation to Tailwind’s layers there) or actually they could be metaprogrammed into one big module. Or they could just be one big module, of course. I’ve learned to accept larger modules since getting comfortable with FP but the way I navigate codebases is still suited to smaller files—for example, if I’m working on a new file with no component calls yet and I want to use a form component, my first instinct is to fuzzy search for comp/form then scroll through the file to see what I got, so that’s why I break them up like that. I could always improve my editor tooling, of course.

CoreComponents is 100% my junk drawer and I’m ok with it… :slight_smile:

That’s not to say we don’t have ClientComponents, AcaComponents, WhateverFeatureComponents

But I don’t think having a junk drawer is bad until it overflows and then you start organizing.

1 Like

I’ve been trying to figure this out too and have come up with the following guidelines for my own projects. Anyone see anything I’m not thinking of here?

  • Core Components – aka components that the generators rely on – live within components/core_components.ex
    - These are imported into view_helpers in the whatever_web.ex file so are accessible through any view, live component, or component by default
    - Aside: My main concern with customizing core_components.ex is having there be a major update that it’s then tedious to update the styles per component. I also think for this reason that this file should only have the functions it ships with within it.
  • Other global components, such typography or additional form elements, should be set up as modules in /components/ directory and imported into view_helpers
  • ALL other non-live components should live within /components/ too, but if they’re specific to certain views, they should only be imported as needed
  • Live components, such as a form component that you’re using in one specific liveview, should be colocated in the same directory as the corresponding liveview and template files and imported only into files as needed
2 Likes

My 2 cents (feeling adventures)

flash_components.ex:

defmodule ProjectWeb.FlashComponents do
  @parent __MODULE__ |> Module.split() |> Enum.drop(-1) |> Module.concat()
  @core Module.concat(@parent, CoreComponents)

  defdelegate flash(assigns), to: @core
  defdelegate flash_group(assigns), to: @core
end

form_components.ex:

defmodule ProjectWeb.FormComponents do
  @parent __MODULE__ |> Module.split() |> Enum.drop(-1) |> Module.concat()
  @core Module.concat(@parent, CoreComponents)

  defdelegate simple_form(assigns), to: @core
  defdelegate button(assigns), to: @core
  defdelegate input(assigns), to: @core
  defdelegate label(assigns), to: @core
end

modal_components.ex:

defmodule ProjectWeb.ModalComponents do
  @parent __MODULE__ |> Module.split() |> Enum.drop(-1) |> Module.concat()
  @core Module.concat(@parent, CoreComponents)

  defdelegate modal(assigns), to: @core

  alias Phoenix.LiveView.JS
  defdelegate show_modal(js \\ %JS{}, id), to: @core
  defdelegate hide_modal(js \\ %JS{}, id), to: @core
end

show_components.ex:

defmodule ProjectWeb.ShowComponents do
  @parent __MODULE__ |> Module.split() |> Enum.drop(-1) |> Module.concat()
  @core Module.concat(@parent, CoreComponents)

  defdelegate header(assigns), to: @core
  defdelegate table(assigns), to: @core
  defdelegate list(assigns), to: @core
  defdelegate back(assigns), to: @core

  alias Phoenix.LiveView.JS
  defdelegate show(js \\ %JS{}, selector), to: @core
  defdelegate hide(js \\ %JS{}, selector), to: @core
end

error_components.ex:

defmodule ProjectWeb.ErrorComponents do
  @parent __MODULE__ |> Module.split() |> Enum.drop(-1) |> Module.concat()
  @core Module.concat(@parent, CoreComponents)

  defdelegate error(assigns), to: @core

  defdelegate translate_error(params_tuple), to: @core
  defdelegate translate_errors(errors, field), to: @core
end

And certainly project_web.ex will have to rewire:

  defp html_helpers do
    quote do
      # ...
      # import ProjectWeb.CoreComponents
      import ProjectWeb.FormComponents
      import ProjectWeb.FlashComponents
      import ProjectWeb.ModalComponents
      import ProjectWeb.ShowComponents
      import ProjectWeb.ErrorComponents
6 Likes

There are a couple of tools that will show you the diff between versions.

This file is full of examples to be modified by the user. We don’t want another bootstrap situation where all the liveview sites look the same do we :upside_down_face:?

2 Likes

Tis is how I do it too.

2 Likes

So succinct, loving it!

Mhm I might non really see any gain on this. I still have a huge core_compenents and import it through separate modules. So more code for same implementation?

best

Minor correction to this which I had to do to make it work:

defmodule MyAppWeb.Components do
  defmacro __using__(_) do
    quote do
      import unquote(__MODULE__).{
        Form,
        Modal,
        Links,
        # ....
      }
    end
  end
end

Plus I added it to html_helpers/0 since this is unquoted into live_view/0 anyways.

1 Like

To simplify comparison of between version bug fixes, I ended up shortening my proxy to just one component file that delegates to either core or fixed components, which helps me visualize what changes I have brought in. Now when a new version hits, I’ll be able to perform a 3-way diff, in which what I really want to focus on, is bringing any bug fixes from core_component v2 into what becomes my fixed v2, while simply overwriting the core components file.

I will still end up fixing 80% of components with my styling, so I’m not really happy with how much work there will be eventually, but I can’t influence core_compoment file organization to separate purely visual styling from any functional ones.

  @parent __MODULE__ |> Module.split() |> Enum.drop(-1) |> Module.concat()
  @core Module.concat(@parent, CoreComponents)
  @fixed Module.concat(@parent, CoreModComponents)

  alias Phoenix.LiveView.JS

  # Modal Related

  defdelegate modal(assigns), to: @fixed

  defdelegate show_modal(js \\ %JS{}, id), to: @core
  defdelegate hide_modal(js \\ %JS{}, id), to: @core

  # Form Related

  defdelegate simple_form(assigns), to: @fixed
  defdelegate button(assigns), to: @core
  defdelegate input(assigns), to: @fixed
  defdelegate label(assigns), to: @fixed

  # Form Fields Related

  defdelegate header(assigns), to: @fixed
  defdelegate table(assigns), to: @fixed
  defdelegate list(assigns), to: @core
  defdelegate back(assigns), to: @core
  defdelegate icon(assigns), to: @core

  defdelegate show(js \\ %JS{}, selector), to: @core
  defdelegate hide(js \\ %JS{}, selector), to: @core

  # Flash Related

  defdelegate flash(assigns), to: @core
  defdelegate flash_group(assigns), to: @core

  # Error Related

  defdelegate error(assigns), to: @core

  defdelegate translate_error(params_tuple), to: @core
  defdelegate translate_errors(errors, field), to: @core

@aiwaiwa I really like your approach in your first comment. It seems to me that presents a really stable interface for future development in that you can adjust those core components by bringing them into your separate modules without having to change how they’re called. For example, if you start out using ProjectWeb.ShowComponents.header and then want to change it you can simply bring over most of the code from CoreComponents and create your own version in ProjectWeb.ShowComponents without having to update all of your views. Almost abstracting out from the default components to taste.

Thanks for the description. I am curious why you moved to a single file approach from that, what were the limitations you ran into?

@asenchi Honestly, that is a matter of taste mostly. I just felt it was too much of splitting for something that simple. I wanted to see an overall picture immediately and maintain it at one place.

But here’s a little fly in the ointment. Technically, it’s a naming clash. Core components use themselves, and some of what they will import will be among the fixed, and some among original. I ended up doing import only:, which is an extra housekeeping. In fact it’s only helping to resolve dependency issue within the fixed CoreModComponents. For example.

For the above mentioned configuration, I would have to do something like this:

defmodule ProjectWeb.CoreModComponents do
  use Phoenix.Component

  alias Phoenix.LiveView.JS, warn: false
  import ProjectWeb.Gettext, warn: false

  import ProjectWeb.CoreComponentsDelegate,
    only: [show_modal: 1, hide_modal: 1, icon: 1, translate_error: 1, error: 1]

Let’s say, I want to add my own fixed version of the <.icon> component and retain its name.

From the core_component perspective, it’s still seeing its own local icon function as component to use in such components as flash and flash_group. So the naming issue is not the only issue here. I would have to bring all the functions that are using icon and also “fix” them.

I see. I am relatively new to elixir and phoenix, but how do people typically manage upgrades with changes to important files like this? My take was that previously “default” work was confined to the PageController and then people moved that out of the way. I’m interested in understanding the upgrade path between versions if I start using these CoreComponents (please let me know if I am moving off topic here).

I’m also relatively new! Just about a couple of months. Plus I’m always curious of all the changes between the versions, not being able to track bug fixes at github.

core_components is one of those files that have to be diffed, to patch own core components if needed.

To help with that, I made a little script just to pull a desired Phoenix version, then create a project, then add extra steps, like mix phx.gen.live Accounts User users name:string, so that the generated output for those can be also compared. Online comparison tools tend not to disallow any such final steps, understandably.

Link to the script: Compare Phoenix Versions with extra custom steps like mix phx.gen.live Accounts User users name:string · GitHub

2 Likes

One question – what do these two lines do?

Thank you so much for this!