Complex components lead to us always calling `detach_hook/3` before `attach_hook/4`

Greetings, comrades.

At work, we have been converting some legacy LiveComponents into functional components. This typically involves moving much of the update lifecycle function logic into a handle_params hook which is hooked using attach_hook/4.

Now we have encountered a situation that I will capture using a simple, fictional example.

Imagine a functional component SelectColorComponent that defines a helper function to attach hooks for events (from phx-click or phx-submit or similar).

defmodule SelectColorComponent do
  def select_color_component(assigns) do
    ~H"""
      <div> something something </div>
    """
  end

  def attach_hooks(socket) do
    attach_hook(socket, :select_color_handle_event_hook, :handle_event, &handle_event_hook/3)
  end

  def handle_event_hook("color-selected", params, socket) do
    # do stuff
    {:halt, socket}
  end

This works great if used directly in a LiveView. We just render the component in the HTML as usual (<SelectColorComponent.select_color_component some_assign={"something"} />) and in the mount function we make sure to call

def mount #...
   socket
  |> #...
  |> SelectColorComponent.attach_hooks()
  |> then({:ok, &1})

No problems.

However, we then write 2 new components, both of which use the SelectColorComponent.

Let’s say one is an AvatarComponent and another is a ProfileBackgroundComponent.

Both of these components render the select color component in their html and therefore have to ensure the hooks are attached

defmodule AvatarComponent do
  def attach_hooks(socket) do
    socket
    |> SelectColorComponent.attach_hooks()
   |> attach_hook(:avatar_component_handle_event_hook, # etc

and similar for ProfileBackgroundComponent.

If we use both the Avatar and Background components in our LiveView, then we need to call their attach_hooks in our mount, like so:

 def mount #...
   socket
  |> #...
  |> AvatarComponent.attach_hooks()
  |> ProfileBackgroundComponent.attach_hooks()
  |> then({:ok, &1})

This will then cause a crash (raise) because we will be calling SelectColorComponent.attach_hooks() twice.

This has led us to conclude that it is often good to simply call detach_hook before calling attach_hook since it is a no-op if the hook does not exist and it avoids raising. This way we can nest our components arbitrarily, which lets us play with them like lego.

TL;DR we have found it safest/easiest to often attach hooks like this:

def attach_hooks(socket) do
  hook_name = :select_color_handle_event_hook
  lifecycle_function = :handle_event

  socket
  |> detach_hook(hook_name, lifecycle_function)
  |> attach_hook(hook_name, lifecycle_function, &handle_event_hook/3) 
end

I am, as usual, asking any and all of you fine people to respond to this with whatever pops into your brain-tank when you see it. Is it neat? Is it hacky? Are we blind to a a more elegant approach to keep our functional components composable?

3 Likes

You know, I really have to be honest here: I’m really starting to worry this entire pattern is a big mistake. I know there was some, uh, emotional posting going on in response to Jose suggesting this pattern a few months ago. Which is unfair, because obviously Jose has probably seen as many LiveView apps as anyone can have seen and I’m sure he only posted about it because he had seen good results with the pattern.

Personally, I realized shortly afterwards that I had no business commenting on the topic further because I had no idea what I was talking about, and I’ve spent the last couple of months studying various frontend framework implementations and reading through old articles and posts to see why certain decisions were made, particularly around React hooks.

And what I’ve noticed, frankly, is that they had the same problems. The React community went through nearly exactly the same set of problems with components, and state, and composability, last decade, and through a lot of trial and error (and mistakes, and cleverness) eventually came up with React function components and hooks as a solution.

The problem is that most components, whether you like it or not, are not properly expressed as pure functions. Real components often need to allocate state, and the allocation of that state makes the component idempotent rather than pure.

If you are ideological about purity, you can cheat by putting the state allocation in the mount of a parent component (perhaps the root LiveView) and then “pretend” that you now have pure functions. But now you have a new problem: this approach does not properly compose.

In React-land, back in the day, people started to solve this problem by allocating stateful components to handle business logic because the components actually composed properly. But this makes things messy, because you end up with components in the tree that aren’t actually components. We could do similar ugly things with LiveComponents if we wanted to, although incidentally LiveComponents do not compose properly either because they have global ids, which is another problem.

Anyway, there were a number of ideas for how to make these things compose properly. Here is a wonderful article from the time detailing many of them. The solution they landed on, quite (in)famously, was to rely on call order to allocate hooks in a resumable way.

So, getting to the point: the mistake here is that we are allocating our hooks (and our state, via assign) with flat keys. For state, this composes mostly fine as long as you use components (global ids notwithstanding). But once you try to compose things which are not components, like those LiveView hooks, you are running into collisions. Because using a flat key structure doesn’t compose.

One solution would be to do what React did and rely on call order. Another potentially workable solution from that article is to namespace the allocations somehow, perhaps automatically. Unlike JS, we have macros (and total control of the language), so there are probably things we can get away with that they could not.

But what I’m really trying to drill home here, fundamentally, is: this is not a new problem. So we would do well to look at how others have solved it in the past.

3 Likes

is this the post you’re talking about?

not only because of this comment from Jose, but also motivated by discussions here with my team, we started to investigate how to refactor parts of our app moving away from LiveComponents (which, in our case, I do think are overused) towards function components + hooks. I’m using a lot of the insightful suggestions we can find in this forum, but I must confess I’m having a hard time to find a design that feels “right”, for multiple reasons (e.g. these collisions caused by “global” flat keys in assigns you mentioned).

right now, I’m somewhat confused about what are good scenarios for LiveComponent vs function components. as a not so seasoned developer like you guys (and as someone coming from a non-technical background), I think more practical examples/guides would be very helpful. but I understand that, for this specific topic, this is something that may be going through some changes in terms of best practices; e.g. the new Phoenix 1.8 generators does not use LiveComponents anymore — altough it came with some changes in the UX, dropping the modal form (what if I wanted to reuse the form in different views, would LiveComponents be a good option, or is there a way to implement it using function components?)

thank you for sharing how you’re trying to solve those problems and for bringing up such important discussions!

I use LiveComponents when I want opaque behaviour and state that largely doesn’t affect crucial business logic. @slouchpie’s SelectorColor is one such case I would likely use one for. Generally auto-complete dropdowns, search boxes, stuff like that. I use the function component + hooks if I want to reuse a business CRUD form (though in practice I rarely do this as this is hardly ever a need). In this case I do want the state explicitly in the mount/handle_params so I can actually see what’s going on without jumping down a rabbit hole of nodes.

Of course there are probably a lot of scenarios I’ve just never hit where this wouldn’t be so cut and dry (and not that it is cut and dry but it’s mostly worked for me).

2 Likes

That was the post I was referring to, yes. There was another discussion more recently where Jose mentioned it again; I don’t remember where but you could probably find it by going through his posts :slight_smile:

I’m not sure I’m going to give you a satisfying answer (someone else will probably post something more helpful), but what I intended with my reply above was to convey that I think there is something fundamentally wrong with how components and other component-like things are composed in LiveView. Before (in that thread you linked, for one) I thought this could be papered over with some sort of reactive “memo” mechanism. I had also posted about this in one of the Ash threads and Zach had replied with a very nice example of how it could be implemented as a simple wrapper around assign(), without changing the engine.

However as I have learned more (like I said I really had no business commenting on the topic tbh) I’m not so sure there is an easy fix. I think the correct solution would be to substantially refactor or redesign the LV engine with something that looks a lot more like React+Hooks - where components are mounted based on their implicit position in the tree (like React), and state/effects can be properly composed (like React hooks).

Now obviously that would be a pretty serious thing to go around proposing, and I would probably want to spend a lot of time working through a proof of concept, etc before I would even consider arguing for something like that. But that’s how I’ve been feeling about things lately.

Also: like I mentioned I don’t think the design would have to be exactly like React’s components/hooks. I never liked the call order thing either, although I have come to accept that they were probably right that it was the best solution for them. I really feel like there’s some way we could make automatic namespacing work, but I’m not entirely sure what the API would look like. This is definitely something that would require a lot of exploratory programming to figure out!

I use LiveComponents to break up large LiveViews. I am perhaps somewhat unique on here in that I have been experimenting with highly interactive LiveViews, i.e. things that in the past many probably thought LV can’t do. But it can, actually, because the rendering model is declarative like React’s. There are just some thorns preventing components from composing properly; in particular, that they are mounted with global ids.

A lot of my LiveComponents are singletons within the LiveView, but they’re split off because they are > 1000 lines of functionality and I don’t want a 10,000+ line LiveView because my brain isn’t big enough to work on that :slight_smile:

Another thing I use LCs for is to optimize diffs over the wire. LV does not properly diff collections (i.e. for comprehensions in your template) because there is no key to work with. However it looks like a fix for this might land in 1.1, so this point will finally no longer be relevant. Which is wonderful progress!

I do this too but I’ve noticed that a lot of these are actually happier as hooks with the state stored client-side, because they are often actually client-side functionality. I think the new co-located hooks are going to make this a lot easier to ship in a library, which is fantastic.

Edit: I meant JS hooks in the above paragraph. I’m starting to think we may have overloaded the word “hook” a little too much lol

LOL yes I got that fairly quickly :slight_smile: These have JS hooks but I store some state in the backend. I suppose it could be frontend too but, while I don’t dislike JS, I prefer have less of it. For example with an auto-complete that makes an API call store the current results in the LC’s state and render with HEEx. Maybe this isn’t what you’re saying, though?

Also I never thought about the library implications for co-located hooks… that is fantastic.

1 Like

Oh yeah, there are definitely some that have server state, and those have to be LiveComponents. But a lot of “control” type components only have client-side state, like maybe a date picker or something.

Autocomplete is a good example of server-side state, especially if the list of things you’re completing comes from the LiveView. On the other hand, if it’s a short list you might be better off rendering them all into the HTML and then selectively showing/hiding with JS. That sort of thing would be more elegant with a proper JS framework (i.e. React), but for simple stuff imperative vanilla JS can get the job done, especially if most of your app’s complexity is actually server-side and handled (declaratively) by LiveView!

Some sort of magic integration between server- and client-side declarative libraries would be the holy grail. I know there are many working on this from different angles, e.g. LiveSvelte/Vue/React, Hologram, etc. Personally I plan to roll a client library from scratch and see where that gets me.

2 Likes

I have never found a good use case for LiveComponent that a function component cannot do better, and lead to an improved design. If you can remove abstractions, do it.

Im curious about this take. Would you mind addressing the issues of OP, then?

I’m genuinely curious what you would suggest about how to do what OP is asking.

Function components have no lifecycle; they are unable to mount/unmount state and side effects. LiveComponents can be mounted declaratively in the template, whereas if you want to mount state or effects for a function component you have to do it imperatively in an event handler somewhere. At scale it gets very messy.

Furthermore, as demonstrated here in the OP, state (assigns) and effects (here, lifecycle hooks) cannot be composed because they have flat keys, so it is impossible to abstract anything out.

It’s worth pointing out that React Hooks did something funny: instead of maintaining two separate classes of component (Class Components and Function Components), one stateful and one stateless (pure), they instead came up with a way for function components to optionally register state.

That’s very clever because it side-steps the need to train developers on which type of component to use. You just use function components every time, and if you need state, you call a hook. If you don’t, the function is pure! Perhaps going down that path would help new devs learn LiveView, seeing as overuse of LiveComponents does seem to be a big problem.

1 Like

I mean, it just means they needed to be trained in a different way :sweat_smile: My first real experience with React was on a greenfield project that started roughly two weeks before hooks where officially released (which we immediately switched to) and we ended up creating a rabbit hole of side-effects (though possibly that was always a problem with React). I suspect a lot of confusion around LiveView’s model is largely from people being used to React. Conversely, one reason LiveView clicked quickly was having a single parent that is in control and I’m sure that has everything to do with using a similar concept back in my PHP days (“page controller” that fetched data and passed it down to “blocks”). That was a framework created by a co-worker… there was even a way to do JS without writing any JS :sweat_smile:

1 Like

Oh yeah for sure, in particular you still have to be trained about when to lift state up and so on. But I think it makes it easier since there is no “component dichotomy” to worry about. And it’s nice to be able to switch between a stateless/stateful component just by adding/removing a line of code. I believe this was a design goal for them.

I would imagine this is mostly orthogonal to which framework you’re using. Having a “single parent” as you say is more a hallmark of a simple app. Of course it always feels better to work on something simple, before things spiral out of control :slight_smile:

It would be perfectly valid to design a React app with a single parent holding all the state, where all other components are pure function components. But as the app gets larger this is impractical, and it’s the same way in LiveView.

Really LiveView is very similar to pre-hooks React, with a few unfortunate warts that make it compose a bit worse. I do think LiveComponents having global ids is a mistake, now. I’m not sure if it’s something that should be corrected, or if we should create a new type of component, or if we should leave LiveView as it is and build something else.

One path would be to add something like React’s hooks to function components and get rid of LiveComponents. Essentially do exactly what they did. That’s what I was alluding to, there.

You would have to define “simple” here as I personally wouldn’t characterize anything I’ve worked on professionally as simple. I don’t mean “single parent” for the whole app (ie, not “SPA”) but single parent per “page.” At the same time, I’ve worked on an SPA-style LiveView that was pretty massive and had all the callback handlers in one place. It’s a whole other conversation around notions of file size but I personally really liked that. In my spaghetti PHP days we used to have an actions.php which just every single side-effect the app did in one file (delegating to functions calls, of course, just like handle_event) I may not be properly addressing what you’re saying here, though.

But ya it certainly does depend on what you’ve worked on and how you like to structure things. The React app I worked on for 2.5 years was a very complex gantt chart for scheduling shift work (the chart itself was off-the-shelf thankfully but there was a lot more to it). It was components calling components calling components with side-effects at all levels, and too much stuff shoved into context (which is something I’m hoping HEEx never gets). Everyone on the team was pretty competent so it wasn’t as bad as it could have been, but still not fun to work with. I would have much rather had the giant parent controlling everything with properly composed components at the top level to reduce (or completely remove) the need for react context.

But ya again, I’m sure many are side-eyeing the heck out of me right now.

1 Like

I don’t mean that there’s some sort of “simple cutoff”, just that as complexity increases it becomes much harder to keep all of your state and business logic “in one place”, without factoring it out into abstractions. I think we all have a good idea of with simplicity and complexity feel like in our code, even if we can’t give an exact definition, though I know some have tried.

It’s a bit different when you can cleanly factor things out into “pages”, as that is of course one way to break up an app. The sort of “desktop app experience” I personally covet (as you have probably noticed) doesn’t quite work this way, though, as more interactive apps tend to have the same components appear in multiple places, or all at once. There is still a notion of “screens” of course, but you need the ability to compose components. Generally in an old-school form-driven webapp you don’t compose pages (iframes notwithstanding…); it’s a different UX.

When you mention using LiveComponents for a search/select input, for example, that is a component you’re composing. But imagine you want to include an entire “new comment” form inline under a post, but also inline replying to another comment. You could split it off to an entire new page, but that’s a clear UX compromise.

You could use a function component, but let’s say it renders markdown (on the server, this is LiveView after all). So it needs state, and event handlers, and the ability to mount and unmount itself declaratively based on the template.

In the limit, if you want highly interactive apps that feel like desktop apps used to, you need components that can compose, declaratively. LiveComponents almost can except for the global id thing. Function components definitely cannot, as the OP demonstrates.

Definitely not, your experience is still valid! But I think you have to keep in mind that you could just as easily recreate a spaghetti React app in LiveView if you wanted to. It would be, mostly, the same. It’s on the craftsman, not the tools :slight_smile:

1 Like