The LiveView request lifecycle runs twice when a connection is first made to your application. It runs once to render static content for web crawlers, search engines, and other non-javascript-enabled clients. The second pass occurs when the browser establishes the websocket connection that sends events and data back and forth between the application and the browser.

Let's walk through the processes and arguments of the request and response lifecycle to see what we can learn about LiveView. Recently, Kernel.dbg/2 was added to Elixir in version v1.14. The Output from dbg is verbose but helpful in understanding what is happening in our codebase. We will limit the output here to the most relevant information, but you can follow along by generating a project.

Create a new Phoenix LiveView project with mix phx.new live_view_lifecycle. Create a new Phoenix LiveView mix phx.gen.live Accounts User users name:string age:integer. Now that we have the basic plumbing, let's start our server and explore this canyon twice.

The three behaviors that LiveView passes through when a request is made:

  • Phoenix.LiveView.mount/3
  • Phoenix.LiveView.handle_params/3
  • Phoenix.LiveView.render/1

Take your time to play around with the generated code and add a few users to the application.

Now let's add query parameters to the URL we will trace. Point our browser to http://localhost:4000/users?binary=noggin and start to look into how our application loads.

The first stop on our adventure train is mount/3. This is the first function that is called when the user makes a request. There is a default implementation in LiveView, so you don't have to implement your own. We’ve implemented our own to control how and when data is sent over the socket to the front-end.

def mount(_params, _session, socket) do
  dbg()

  if connected?(socket) do
    {:ok, assign(socket, :users, list_users()) |> dbg()}
  else
    {:ok, assign(socket, :users, []) |> dbg()}
  end
end

The first thing of note is connected?(socket). This function checks if the socket is connected. On the first pass, the socket is not connected and is used for static rendering. The first call to dbg/0 prints out our function's binding, including incoming arguments.

[lib/live_view_lifecycle_web/live/user_live/index.ex:8: LiveViewLifecycleWeb.UserLive.Index.mount/3]
binding() #=> [
  ...
  _params: %{"binary" => "noggin"},
  _session: %{},
  socket: #Phoenix.LiveView.Socket<
    ...
    assigns: %{
      __changed__: %{}
    },
    transport_pid: nil,
    ...
  >
]

Our incoming arguments include our query parameters as a map of string keys to string values. Keeping the keys as strings instead of atoms keeps malicious users from filling up all of our memory with bogus parameters. Our session is empty, but in a real application, it may be filled with interesting information about the user. Our assigns have no changes, and no data, yet.

Since we are not connected, we move to the else clause and assign users to an empty list. Getting a list of users is time-consuming, and we only want it to happen once when someone is connecting. We will get that information on the next pass. An interesting thing to remember is that LiveView begins tracking what has changed in the assigns. __changed__: %{users: true} LiveView tracks these changes to determine what new information needs to be sent across the websocket. Only sending updated data that changed reduces the load on the connection.

[lib/live_view_lifecycle_web/live/user_live/index.ex:13: LiveViewLifecycleWeb.UserLive.Index.mount/3]
assign(socket, :users, []) #=> #Phoenix.LiveView.Socket<
    ...
    assigns: %{
      __changed__: %{users: true},
      users: []
    },
    transport_pid: nil,
    ...
  >

On the second pass, the websocket is now connected, and we get to a real list of users. When we enter mount/3, we notice one change: the socket now has a transport_pid. This is how connected?/1 determines if the socket is connected.

[lib/live_view_lifecycle_web/live/user_live/index.ex:8: LiveViewLifecycleWeb.UserLive.Index.mount/3]
binding() #=> [
  ...
  _params: %{"binary" => "noggin"},
  _session: %{},
  socket: #Phoenix.LiveView.Socket<
    ...
    assigns: %{
      __changed__: %{},
      users: [],
      open_modal: true
    },
    transport_pid: #PID<0.111.0>,
    ...
  >
]

Since the socket is now connected, the users are fetched from the database and added to the assigns. Once again, LiveView marks the users as changed, so it knows the user list needs to be sent across the socket.

[lib/live_view_lifecycle_web/live/user_live/index.ex:11: LiveViewLifecycleWeb.UserLive.Index.mount/3]
assign(socket, :users, [%User{name: “Johnny”, age: 39}]) #=> #Phoenix.LiveView.Socket<
    ...
    assigns: %{
      __changed__: %{users: true},
      users: [%User{name: “Johnny”, age: 39],
      open_modal: true
    },
    transport_pid: #PID<0.111.0>,
    ...
  >

The second stop on our adventure is handle_params/3. The handle_params/3 callback is a great place to process different URLs or parameters that may change how the page needs to display, such as opening or closing a modal. handle_params/3 gets called any time that our parameters change, and we are already connected to this LiveView. This often happens when we call push_patch/2 to keep our current LiveView but change the URL.

def handle_params(_params, _url, socket) do
    dbg()
    {:noreply, assign(socket, :open_modal, true) |> dbg()}
  end

The incoming socket already has the changes from the call to mount/3. We are reducing over the socket, and each function receives the results of the previous callback. We also get the URL that was requested by the end user. This is the only place where we receive the URL, and it can be a good place to add on functionality. Instead of a modal parameter, we might have the user directed to /users/new and decide to open the modal based on the URL.

[lib/live_view_lifecycle_web/live/user_live/index.ex:69: LiveViewLifecycleWeb.UserLive.Index.handle_params/3]
binding() #=> [
  _url: "http://localhost:4000/users?binary=noggin",
  _params: %{"binary" => "noggin"},
  socket: #Phoenix.LiveView.Socket<
    ...
    assigns: %{
      __changed__: %{users: true},
      users: []
    },
    ...
  >
]

Since we have updated the open_modal value, we once again add an entry to __changed__. The open_modal value of true signals the front end to display the open modal when rendering

[lib/live_view_lifecycle_web/live/user_live/index.ex:70: LiveViewLifecycleWeb.UserLive.Index.handle_params/3]
assign(socket, :open_modal, true) #=> #Phoenix.LiveView.Socket<
    ...
    assigns: %{
      __changed__: %{users: true, open_modal: true},
      users: [],
      open_modal: true
    },
    transport_pid: nil,
    ...
  >

On our second time through, during the connected state, things look exactly as before except we have the new updated socket from the connected mount call.

[lib/live_view_lifecycle_web/live/user_live/index.ex:70: LiveViewLifecycleWeb.UserLive.Index.handle_params/3]
assign(socket, :open_modal, true) #=> #Phoenix.LiveView.Socket<
    ...
    assigns: %{
      __changed__: %{users: true, open_modal: true},
      users: [%User{name: “Johnny Otsuka”, age: 39}],
      open_modal: true
    },
    transport_pid: #PID<0.111.0>,
    ...
  >

The last callbacks in our loop is render/1. We implement a LiveView template using render/1 but you can also create a LiveView template via a file named index.html.heex with the same markup, and it'll behave in the same way.

def render(assigns) do
    dbg()

    ~H"""
    <h1>Listing Users</h1>
    ...
    <table>
      <thead></thead>
        <tr>
          <th>Name</th>
          <th>Age</th>
          ...
        </tr>
      </thead>
      <tbody id="users">
        <%= for user <- @users do %>
          <tr id={"user-#{user.id}"}>
            <td><%= user.name %></td>
            <td><%= user.age %></td>
            ...
          </tr>
        <% end %>
      </tbody>
    </table>
    """
    |> dbg()

The last stop before the data is shipped to the browser is render/1. The assigns are taken out of the socket and passed into this function, and the values are made available to the template. We use @ inside the template as a shortcut to access the values inside the assigns map.

[lib/live_view_lifecycle_web/live/user_live/index.ex:23: LiveViewLifecycleWeb.UserLive.Index.render/1]
binding() #=> [
  assigns: %{
	__changed__: %{page_title: true, user: true, users: true},
	flash: %{},
	live_action: :index,
	page_title: "Listing Users",
	socket: #Phoenix.LiveView.Socket<
  	id: "phx-Fx0gkN3mtBW1lABk",
  	endpoint: LiveViewLifecycleWeb.Endpoint,
  	view: LiveViewLifecycleWeb.UserLive.Index,
  	parent_pid: nil,
  	root_pid: #PID<0.111.0>,
  	router: LiveViewLifecycleWeb.Router,
  	assigns: #Phoenix.LiveView.Socket.AssignsNotInSocket<>,
  	transport_pid: #PID<0.111.0>,
  	...
	>,
	user: nil,
	users: [
  	  %User{
    	       updated_at: ~N[2022-10-11 20:09:00],
    	       inserted_at: ~N[2022-10-11 20:09:00],
    	       name: "Johnny Otsuka",
    	       age: 39,
    	       id: 1,
    	       __meta__: #Ecto.Schema.Metadata<:loaded, "users">
  	  }
	]
  }
]

The output of the render function is a Phoenix.LiveView.Rendered struct. The fingerprint is the most interesting part. The rendering engine uses the fingerprint to identify the rendered html and decide if it has already been rendered and if it is possible to skip the transmission of the rendered data. Since these can also be nested, LiveView doesn’t have to go to the bottom of the tree if it hits a known fingerprint.

[(live_view_lifecycle 0.1.0) lib/live_view_lifecycle_web/live/user_live/index.ex:63: LiveViewLifecycleWeb.UserLive.Index.render/1]
~H"""
<h1>Listing Users</h1>

...
""" #=> %Phoenix.LiveView.Rendered{
  static: ["<h1>Listing Users</h1>\n\n",
   "\n\n<table>\n  <thead>\n	<tr>\n  	<th>Name</th>\n  	<th>Age</th>\n\n  	<th></th>\n	</tr>\n  </thead>\n  <tbody id=\"users\">\n	",
   "\n  </tbody>\n</table>\n\n<span>", "</span>"],
  dynamic: #Function<0.132121069/1 in LiveViewLifecycleWeb.UserLive.Index.render/1>,
  fingerprint: 299956156419391184196309507721197495057,
  root: false
}

The lifecycle of a Phoenix LiveView starts as a static HTML request. When the request reaches our server, mount/3 to handle_params/2 get their chances to update the socket, and often the assigns. Next, the websocket is connected between the browser and a LiveView process. Once that connection is made, the LiveView process executes the same functions used to generate the static HTML response in order to generate the websocket connection. From this point on, the LiveView process maintains the state between the client and server. Updates on the back end render on the front end in near real time. Take the time to explore further and add some functionality to your application. Keeping the dbg calls while you play can help solidify your understanding of the LiveView process.

References

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#module-life-cycle https://pragmaticstudio.com/tutorials/the-life-cycle-of-a-phoenix-liveview

Ingenious solutions in your inbox

Ingenious solutions in your inbox

Join our monthly newsletter list and be the first to learn about the latest blogs, events, and more!

You have Successfully Subscribed!