Phoenix Liveview Design question: Should all logic be in components?

I have a design question for experienced Phoenix LiveView developers. I’m designing an app so that LiveViews aren’t heavy, but i keep running into a choice between keeping the logic in the LiveView or moving all logic into components.

I’ll give a basic example. Would appreciate any mentoring on this.

I’m designing a Calendar of Events. The user looks at a list of events and can “save” the event to their calendar. When they manage their calendar, they can “remove” the event.

CHOICE 1:

Manage_Calendar_LiveView

  • “Save Event to Cal” logic inside LiveView in apply_params
  • “Remove Event from Cal” logic inside LiveView in apply_params

OR … CHOICE 2:

Manage_Calendar_LiveView

  • apply_params loads up assigns and then everything goes to components

  • render calls components depending on user actions:

    Component 1: Save Event to Cal
    Component 2: Remove Event to Cal

Both require accessing the authenticated user, so the downside of Choice 2 is that I have to pass the User ID to the component. I’ve been avoiding passing the User ID to the client for security reasons. Not sure it matters, but seems safer not to be passing around the user ID.

That said, passing the User ID between the LiveView and a Component should all be on the server side, so that shouldn’t matter.

Is there an advantage or disadvantage between Choice 1 or 2?

As with all design choices, the answer is probably: It depends :stuck_out_tongue:

Do you expect your LiveView to grow significantly in complexity?
If yes, proactively modularising your code into LiveComponents might help you to organise the complexity better. In your case however, I would probably structure my LiveComponents around my UI components. So, if you represent an event with a “card” for example, which offers a save this event or remove this event button, then I’d create a LiveComponent for that card and call it something like EventCardComponent. It would then only receive the save or remove events and delegate actually saving or removing the event to e.g. a context or application service.

Will your LiveView have functionality for different use-cases?
A use-case would be e.g. Save an event to the user's calendar.

If your LiveView tries to handle many use-cases, it will have many functions which do many different things. Your code inside your LiveView has then a low cohesion, which makes it harder to understand what your LiveView actually does. You can improve this by moving code “that belongs together” (i.e. applies to the same use-case, schema, context, etc.) into their own module. That module can also be a LiveComponent if the moved code isn’t needed by any other module in your system (like handle_event-calls) and if it manages your UI somehow. If the code must be reusable or is not connected to your UI, consider moving it to a context or application service instead.

Do you want to reuse the EventComponent in other views?
Maybe you want to offer the same representation of the event in other views as well. So, you don’t only want to show the event in the ManageCalendarLiveView-View, but in other views of your application. If so, then extracting the logic for saving&removing events from a user’s calendar to its own LiveComponent is advisable. You have to watch out though that you don’t extract logic which is special for this particular view and isn’t used anywhere else. Such logic should then rather stay in e.g. the LiveView instead of the LiveComponent, so that you don’t import the unnecessary logic in other views as well.

So, for example, if you need the save&remove logic only in the ManageCalendarLiveView, but you want to include the EventComponent in other views as well, then rather keep the save&remove logic in your ManageCalendarLiveView since it won’t be needed anywhere else.

I drew a quick overview of what I mean with my comments above. It shows a situation where you want to offer the save&remove logic in other views (like the UserDashboardLiveView) as well. I hope it’s understandable:

Untitled Diagram (1)

15 Likes

This is AWESOME! Thank you so much! I’ve read this a couple of times and learn something every time I read over it. The diagram is great! I just diagramed my flow over the weekend and this helps me compare the approaches. I’m pretty close to this, so I think I’m on track in terms of what I’m pulling out into components.

I’m finding that I do need to reuse quite a bit of logic because some functionality requires authentication and some does not. So ShowEvent will be in one LiveView and SaveEvent in another, simply because I need to put the “save event” logic in the “authenticated” section of my routes. I had not even looked at components until I had to straddle the authenticated and unauthenticated LiveViews. Understanding how others design their apps helps me set things up correctly so that I’m not struggling (and rewriting) down the line.

I’m super new to Phoenix so I really appreciate the mentoring. Thank you again for such a detailed reply!

1 Like

You’re welcome :slight_smile: I’m glad that I could help you.
Please let me know if you have any further questions :muscle:

Wonder if I could ask you one more question that relates to the authentication issue. I keep running into this same issue and I’m not sure if I’m addressing this correctly.

EventIndex calls ListEventsComponent and anyone can see all Events saved in the db. So it is unauthenticated. As soon as someone clicks on SaveEvent, they must be redirected to an authenticated path. I tried to redirect to CalendarLiveview but that doesn’t list all events. It lists only the events in a user’s calendar. And I was unable to redirect back to EventIndex.

So I ended up with EventIndexAuth. It also calls ListEventsComponent and lists all available events, BUT it is authenticated and allows the user to Save events to their Calendar. Once saved, it redirects them right back to IndexAuth.

There’s obviously duplication because I essentially have two EventIndex liveviews that are identical except that one is authenticated and allows saving. I tried to figure out a way around this authentication issue but could not. Am I missing something?

Of course! So, as I understand, you have two LiveViews, EventIndex and EventIndexAuth, which both use the ListEventsComponent to show a list of all events.

Do I understand it correctly that you handle the events from the ListEventsComponent in your LiveViews, so in EventIndex and EventIndexAuth? If yes, that’s smart since you keep the ListEventsComponent generic and let its parent handle the save&remove events.

As I see it, you will have some duplication inevitably since you try to offer the same feature to two user groups (authenticated and unauthenticated). However, let’s just brainstorm on some solutions so that you can pick the most suitable one for your use-case.

  1. You let the ListEventComponent handle authenticated vs. unauthenticated behavior. So, whenever a save or remove event comes in, the LiveComponent checks whether it has a user_id and either executes the action or redirects to the login-page.
    Pro: Everything is in one place and it’s easy to understand the checking-logic. You can reuse the component everywhere without having to worry about whether a user is logged in. You can use a single LiveView and don’t have code duplication.
    Con: The LiveComponent receives the responsibility of authentication management, which you’d preferably move as much up the chain as possibly (i.e. into the router). Imagine you’d have many of these components, then you need to check this in every single component.

  2. You keep your current solution with two LiveViews which handle the authentication.
    Pro: You can customise the authenticated and unauthenticated parts of your website better. Maybe you want to have a helper text underneath each event saying: “You’ve gotta login to use all the features”. You only have to handle the authentication in the mount-function of each LiveView and don’t have to worry about it in the other functions or in the LiveComponent. You keep your LiveComponent generic, but have to handle its events always in its implementing LiveView. You cleanly separate the authenticated from the unauthenticated part of your website.
    Con: Duplication of the event-handler, possibly of the templates and views. If the authenticated LiveView does not differ much from the unauthenticated LiveView, this feels redundant and you will forget why you’ve done this down the road.

  3. You move the authentication management further down into e.g. the Context. So, you always call the context and let it check whether the user is logged in (if you somehow have a user_id even if the user isn’t logged in) and possibly has the permissions to execute the action. If not, then you return an error and the LiveView has to handle that error by e.g. redirecting to the Login-page with an error flash message.
    Pro: By checking the authentication in your context you will make sure that the user will always have the permission to execute this action. If you do this “further up” e.g. in your LiveView, then you might forget to check for it, creating a security hole.
    Con: You probably won’t have a user_id if the user isn’t logged in, so this solution can only check for permissions but not for authenticated vs unauthenticated state.

So, again this is a judgement call. I’d prefer solution 1 (checking the auth-status inside the LiveComponent) if you don’t expect to have many components where the behaviour differs between the authenticated vs. unauthenticated user group. So, if it’s only this single component, then only have a single LiveView with this LiveComponent, and check inside the component whether to execute the action or redirect to the login-page.

If, however, you expect to have many components of your event list where the behavior depends on the logged-in status of the user, then I’d prefer solution 2 (have two LiveViews use the same LiveComponent). This way you can easily always redirect to the login-page from inside your unauthenticated LiveView, which prevents you from creating “security holes” (i.e. features where unauthenticated users can execute actions they aren’t supposed to). Also, you can then focus on implementing these actions in your authenticated LiveView without having to worry about checking the logged-in status in every event handler.

What do you think? Does this help you further? :slight_smile:

Edit: I assumed that you use a LiveView for this, but if you use a Phoenix Controller for handling these events, there might be an easier solution by defining an action_fallback-Controller, which redirects to your login-page if a user tries to execute an action without being authenticated. So, you’d simply match against a user_id in your controller event handler. If the user isn’t logged in, they won’t pass in a user_id parameter, so no event handler matches and the request goes to the action_callback controller. This is like solution 2, but you don’t have to write too much duplicate code in order to achieve it :slight_smile:

1 Like

Once again … you are amazing. Thank you so much! Do you have a blog where you post your advice on Phoenix? If not, you should really create one! You’ve got a great way of explaining things … especially to beginners.

Yes … I had created a ListEventComponent and had moved all that logic over to it because it actually gets reused by other Liveviews. So that is a well used component! The reason I had never thought of checking for authentication in the component is because “update” only passes in a socket and not the session. So I didn’t think I had any way to check whether the user was authenticated. That said, I suppose I could have the Index Liveview check to see if there is an authenticated user and pass the user_id to the component via an assign.

I’m going to ponder your three options carefully and see which one makes the most sense. Being such a beginner at Phoenix, Option 2 is the easiest for now because I let the Router handle the authentication for me. But as I advance, I’ll want to keep duplication at a minimum.

I’m currently struggling with how to set session data on the server side. I’m following the advice from this article: https://pentacent.com/blog/phoenix-live-sessions. Let me know if there are other good resources for learning about session data.

Thank you again for the detailed reply! You’re helping me learn Phoenix and develop clean code! MUCH appreciated. :slight_smile:

1 Like

Yes he does :slight_smile:

4 Likes

Haha, well thank you very much for that compliment :slight_smile: I do indeed talk and write occasionally on different topics. As @kokolegorille was so kind to point out already, you can find my writing and links to my talks on my personal website: peterullrich.com :slight_smile: Also, I try to add more content to my YouTube channel, but every video takes me around 10h to produce, whereas blog posts usually take 1 to 4 hours. That’s why there are more blog posts than videos as of now :man_shrugging:

I see that you created another thread for your phoenix-live-sessions issue. I’ll read through it and see whether I can add some value to the discussion. I’m happy that I could help you with the LiveComponent issue here though. Keep up being so inquisitive and you’ll become an Elixir wizard in now time :muscle: :slight_smile:

3 Likes

Hope you don’t mind me following up on something in your post above. I am in a situation where it would be better for me to move the authentication check into the component, but I’m struggling with something.

You wrote:

You let the ListEventComponent handle authenticated vs. unauthenticated behavior. So, whenever a save or remove event comes in, the LiveComponent checks whether it has a user_id and either executes the action or redirects to the login-page.

How do I check to see if a user_id is available? In my Index which is not behind authentication in the router, I tried doing the following code to assign the current user if there is one:

    socket = assign_current_user(socket, session)

Where that method in my LiveHelpers is defined as follows:

def assign_current_user(socket, session) do
    assign_new( socket, :current_user,
      fn -> Users.get_user_by_session_token(session["user_token"])
    end )
  end

It goes into an endless loop and errors out presumably because there is no user_token since the user has not logged in yet. I can’t find a function that I could call to just check to see if the user_token is available or if there is a :current_user. If I could check the :current_user status, then I could call assign_current_user if the user exists or redirect to login if the user does not exist. Is there an easy way to check the user’s login status?

Here’s how I’ve done this in the past:

In mount/3:

case Users.get_user_by_session_token(session["user_token"]) do
  nil ->
    # User is logged out
    {:ok, socket}

  user ->
    # User is logged in
    {:ok, socket}
end

And then I added an extra guard to Users.get_user_by_session_token/1 like:

def get_user_by_session_token(token) when is_nil(token), do: nil
def get_user_by_session_token(token) do
  {:ok, query} = UserToken.verify_session_token_query(token)
  Repo.one(query)
end
1 Like

Perfect! Thank you!

I’m still getting errors when I try to move the authentication check into a component. My update looks like this (I’m setting the session_token inside the LiveView since a component doesn’t have access to “session”:

def update(assigns, socket) do

    case Users.get_user_by_session_token(assigns.session_token) do
      nil ->
          {:ok, socket
              |> put_flash(:error, "You must log in to Save Resources")
              |> redirect(to: Routes.user_session_path(socket, :new))
        }

      user ->
        {:ok, socket
            |> assign_new(:current_user, user)
            |> assign(assigns)
        }
    end
  end

And the error I get is this:

(RuntimeError) cannot redirect socket on update/2

I don’t want to move this logic into the main LiveView because users will be allowed to search for resources without being logged in. They only need to log in when they want to save a resource. So it would be more efficient to redirect an unauthenticated user to the login page from within the component. But I can’t seem to make that work.

You need to move this into the main LiveView. But this should not stop you from meeting your authentication goals. If you only require log-in for a “save_resource” event, then you will only include this check inside the handle_event/3 function for the “save_resource” event. I have noticed in my own experience with Live apps, that even if you put everything into Components, much of the work must still be done in the parent LV.

This will also allow you to reduce Users.get_user_by_session_token/1 calls, which require a DB query each time. Move the call into mount/3 of the parent LiveView, assign the %User{} to :current_user (even if it is nil), and then pass it to your component like:

<%= live_component @socket, MyResourceComponent, current_user: @current_user %>
1 Like

Thank you! That worked!!! YAY! I finally have it checking for login and redirecting to the component. I still have one problem, though …

It completely loses the action that invoked the login. So the user clicks on “Save Resource” and it redirects to login and then returns to the Home page … completely forgetting that it was in the middle of a save.

This may be caused by two things. First, I’m using a link in a table to invoke the save. So I used a live_patch to send it back to the liveview:

<span><%= live_patch "Save",
                      to: Routes.resourceform_index_path(@socket, :save, resourceform) %></span>

I then field the “save” using handle_params. Inside handle_params, I send the user to the login page using this code:

redirect(to: Routes.user_session_path(socket, :new))

I suspect that the redirect is causing the state to be lost. Should I be using something else to invoke the login?

Thank you also for saying that the parent LV still gets heavy. I was noticing that too and thought I was doing something wrong. It’s just heavy with a lot of handle_params and handle_info. It’s the only way I can keep the state across pages.

Since the login is handled by a standard Phoenix controller, and therefore you use redirect/2: This shuts down the LiveView entirely, and after the login, a brand new LV process will be mounted. Therefore, if you want some state to persist, you’ll probably need to save it to the DB or some other type of cache. But maybe this opens a security issue where logged-out users can still save stuff, but it just isn’t displayed publicly yet. This approach seems to have various complications.

I had this issue as well with one of my LiveView apps, and the simple solution I went with was to check authentication BEFORE allowing the user to input any work that might be lost. (e.g. If the user can click "New Post, then type a post, and then click “Save” - the check happens as soon as they click “New Post”. This way they don’t waste their time typing something that might be lost. If they are not logged-in, they won’t even be able to see the post form).