How would you render heterogenous data in a liveview?

Here’s something I’ve been struggling with for a while. I have an “activity” feed, which can contain different types of data - received message, sent message, logged in, etc etc. They’re structs and come from various places in the application, and I want to render them all differently according to type.

I’d like to render them into a timeline using liveview and I’m having a very hard time figuring out a good way to do it. Outside of HeeX I’d iterate through and match on the struct type, and maybe render a partial or something based on that. Inside the liveview though I don’t have those tools and find myself resorting to very ugly deconstruction methods plucking out the struct in question, doing a match on it, then sending that to a bespoke render function, with a new “assigns”, and so on. It doesn’t feel right.

To be honest, I feel like once received and emplaced, the activity data doesn’t need to be “live” at all but I also can’t figure out how I can render “dead” code into my “live” view, although that’s all that is really needed. All the liveview is doing is inserting data - it doesn’t need to be tracked after insertion, with all the overhead that entails.

So I’m wondering how others would do it and if I’ve been missing something really obvious?

thanks!

PS update: Coming back to this problem after a couple months away, I’m thinking defimpl and a render_string or something, then slamming that into the list raw. But I don’t like rendering anything raw, and it feels too brute force. Is there something more… gentle?

You can match on the assigns in a function component definition:

defp render_thing(%{item: %StructForFirstPossibleThing{}}=assigns) do
  ...

end
2 Likes

Yeah, that’s one of the approaches I’ve tried. It feels like going against the grain if I have to construct a special map to “trick” the function like that. And you end up with a lot of overloaded functions that are hard to follow and very fragile variable names.

I guess I was wondering if there’s a more elegant feeling pattern for what seems like a pretty common task.

Why do you have to construct a “special map” for this?

Ah, I simply meant dispatching to a render function with a single-item map with a single element to satisfy the assigns requirement feels a bit hacky and against the grain. It’s not that the map is special, it’s that you have to pass in a map at all.

But you’re passing it into the component anyway via the assigns anyway, right?

  <.render_thing for:={item <- @list_of_items} item={item}>

Edit: Assuming you want to do it the typical function component way.

You might try to use a protocol.

defprotocol MyApp.Activity do
  def render(item)
end

defimpl StructForPossibleThing, for: MyApp.Activity do
  def render(thing) do
    # builds either a common struct or html directly
  end
end

If there are some commonalities that need to be extracted, you might want more function in the protocol. Let’s say that timestamp is a common thing to display, but it is a different field in different structs.

defprotocol MyApp.Activity do
  def timestamp(item)
  def render_body(item)
end

I have a hunch that protocol is a better solution to your problem than one function matching on structs. Why?

You’ve mentioned that those structs are from different parts of the application. I assume there might be more coming in the future. With protocol approach, new structs will throw Protocol not implemented for MyApp.Activity which is clearer than case clause (especially when there is a lot of clauses for structs).

render_body does not need to return raw string. It can return Phoenix.Component (maybe even a different one for each struct).

If I did this I then need to have the render_thing function do a type match on @item and re-dispatch to a sub-render function. It works and I’ve done it, but it just feels really awkward and that usually triggers my “i must be doing it wrong” sense.

They would be functional components yes. I guess i’m trying to see if there’s another option.

I don’t see why you’d have to do a redispatch, unless of course the rendering is not done by that part of the code, but somewhere closer to where the struct is created.
If all you have to do is render a string, and not more markup, then you could indeed follow the above suggestion. Else

def render_thing(%{item: %StructOne{}} = assigns) do
  ~H”””
  “””
end

def render_thing(%{item: %StructTwo{}} = assigns) do
  ~H”””
  “””
end

And so on…

I have a dashboard where I use this, as it is the responsibility of the dashboard to make sense of the info in the different structures.

2 Likes

Yes - I’ve been thinking along the same lines - think this is the approach to try next.

Something like this works:

defimpl StructForPossibleThing, for: MyApp.Activity do
  use MyAppWeb, :live_view

  def render_to_string(assigns) do
    ~H"""
    <%= Jason.encode!(@body, pretty: true) %>
    """
  end
end

And I can then just call <%= StructForPossibleThing.render_to_string(item) from the main render. It still feels like I’m working against the framework, though. Maybe it’s just me…

I don’t think it’d work as you expect. Protocol functions always expect the struct as a first argument and LiveView expect assigns to be of certain shape.

This is honestly the answer that is “working with the framework” rather than against it. The function component is just a function so you can have multiple function heads matching on different structs. You don’t need to redispatch anywhere else. The only thing you have to do is destructure the assigns so you can match on the right struct as shown in the example.

5 Likes

You can do

<%= case item do %>
  <% %A{} = a -> %>…
  <% %B{} = b -> %>…
<% end %>

I think that using protocols to deal with this should be last resort, as they have other quirks you really don’t want to think about.

Using pattern-match should be more than enough to solve this problem.

3 Likes

A solution I’ve used before:

  • make a lookup function that takes a single item of the heterogenous data, and returns a component name. This could be as simple as looking up the __struct__ key in a map, or something more complicated if you need data-sensitive UI
  • display that value using live_component/1.
  • Extra credit: have the lookup function return {ComponentName, [extra: args, go: here]} to pass additional properties to the component

From the range of different approaches suggested here - thanks! - it’s clear to me there’s no one “right” way of approaching this. I think I reluctantly agree that the

def render_thing(%{item: %StructOne{}} = assigns) do
  ~H”””
  “””
end

approach is the least friction/most idiomatic. I still don’t like it - passing a map, and matching on its internals, when you only need a single object is a code smell. The top level magic “assigns” variable and its magic local @item isn’t good either. There’s too much code and too much indirection. But the other options seem even worse.

Kind of surprised this use case (I’ve done this many times before in other languages, and in eex) is so hairy in liveview!

This would be my preferred option also. Keeps the decision making close to the rendering.

And for each case clause you just render a component.

The magic assigns map is exactly the specification for Phoenix function components:

From Phoenix.Component — Phoenix LiveView v0.20.17

A function component is any function that receives an assigns map as an argument and returns a rendered struct built with the ~H sigil:

If you want the benefits of using the heex templating engine and components, it expects an assigns map as the only input variable in the function.

The only other liveview “idiomatic” approach I can think of is as per @LostKobrakai’s comment

~H"""
   ...
  <%= for item in @list_of_items %>
    <%= case item do %>
       <% %StructOne{} -> %>
          <.struct_one_drawing_component item={item} />
       <% %StructTwo{} -> %>
          <.struct_two_drawing_component item={item} />
       ...
    <% end %>
  <% end %>
   ...
"""

In this case each of the function components would still need an assigns map as per the spec.

defp struct_one_drawing_component(assigns) do
  ~H"""
     <p><%= @item.description %></p>
   """
end

In essence you’re just moving the matching around between a case statement and a function head. Which you pick is likely to depend on context. For simple cases I’d veer towards matching via function heads, and for more complex cases (e.g. a component hierarchy rendering something deeply nested) I’d veer towards the case statement approach dispatching to a struct specific component, possibly in a different module depending on the complexity.

1 Like

Looks like you have an OOP background. Matching on a map’s fields is not matching on internals, it’s not an object and there are no private members.

You can think of the ~H sigil as an unhygienic macro as it captures an identifier from its environment. No magic here, just a feature which comes from macros.

2 Likes

This is exactly what EEx does to allow definition of dynamic assigns, but this is just an implementation detail that the end user should not care about as it is a hack around the limitation of code evaluation at runtime with values from other contexts.

1 Like