Events

Phoenix Liveview supports DOM element bindings for client-server interaction. This allows developers to create server-side components that can react to client-side events triggered by the user.

For a complete guide on Bindings, see the Phoenix's Bindings Guide.

Handling events in LiveView (without Surface)

In Phoenix LiveView, when dispatching events in live components, the default target is the parent live view, not the component itself. If you need to handle events locally, you usually need to:

  • Set the phx-[event] attributes on the elements which events need to be listened to.

  • Set the phx-target attribute on those same elements to indicate that you want to handle them locally.

This can be non-intuitive, especially if you're coming from any existing component-based library like React or Vue.js.

Note: The main reason behind this design choice, as explained by José Valim in this discussion, is that, when using Phoenix templates, it's impossible to know what is the parent and what is the child. There's no way to retrieve that information since templates are not treated as structured code, they are just text.

Here's the Counter example used in the Data section, but this time using Phoenix templates:

0

defmodule Counter do
  use Phoenix.LiveComponent

  def mount(socket) do
    {:ok, assign(socket, count: 0)}
  end

  def render(assigns) do
    ~L"""
    <div>
      <h1 class="title">
        <%= @count %>
      </h1>
      <div>
        <button class="button is-info" phx-click="dec" phx-target="<%= @myself %>">-</button>
        <button class="button is-info" phx-click="inc" phx-target="<%= @myself %>">+</button>
        <button class="button is-danger" phx-click="reset" phx-target="<%= @myself %>">
          Reset
        </button>
      </div>
    </div>
    """
  end

  def handle_event("inc", _, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def handle_event("dec", _, socket) do
    {:noreply, update(socket, :count, &(&1 - 1))}
  end

  def handle_event("reset", _, socket) do
    {:noreply, assign(socket, :count, 0)}

Handling events in Surface

Instead of treating templates as plain text, Surface parses the code identifying its structure (the hierarchy of components) and uses that information to restore the initially desired behaviour of handling events in LiveView. Bear in mind that in order to keep the behaviour consistent and predictable across multiple components, you should:

  • always use the :on-[event] directive.
  • always declare event properties as :event

Note: You can still use Phoenix's built-in phx-[event] directly if you want, however, if you need to pass that event as a property, you should declare that property as :string instead of :event.

The :on-[event] directive

Let's rewrite our example again using Surface's :on-click directive:

0

defmodule Counter do
  use Surface.LiveComponent

  data count, :integer, default: 0

  def render(assigns) do
    ~H"""
    <div>
      <h1 class="title">
        {{ @count }}
      </h1>
      <div>
        <button class="button is-info" :on-click="dec">-</button>
        <button class="button is-info" :on-click="inc">+</button>
        <button class="button is-danger" :on-click="reset">Reset</button>
      </div>
    </div>
    """
  end

  #   # Event handlers
  #   ...

As you can see, we didn't have to define phx-target for any of the buttons. Sweet!

Note: The complete list of available events, as well as other types of bindings, can be found in the Bindings section of the docs for Phoenix LiveView.

Pass event through an event property

Another great thing about Surface's approach is that it makes passing events as properties also more intuitive. Using phoenix templates, unless you always pass both, the event and the target, you cannot be sure where the event will be handled. You need to know upfront if there's a phx-target defined for that DOM element inside that component. Using Surface, the event is always passed along with the related target, assuming, by default, that the target is the caller component/view.

In the above examples the events have been handled by the component itself. Sometimes the parent component needs to handle the event. For that kind of use case, you must declare the event in the child component by using the prop macro defining the type as :event, and pass the value of that prop to the :on-[event] directive.

See the properties Event section for more details about event properties.

Stateless component

The simplest case you need to pass an event is when you create a stateless component that includes an element that defines a server binding (event). Since the component is stateless, it cannot handle the event by itself so it needs to receive the event handler as a property.

For example, imagine a Button stateless component that triggers an event when the user clicks on it. In the following example, we create that stateless component.

defmodule Button do
  use Surface.Component

  prop label, :string
  prop click, :event, required: true
  prop kind, :string, default: "is-info"

  slot default

  def render(assigns) do
    ~H"""
    <button type="button" class="button {{ @kind }}" :on-click={{ @click }}>
      <slot>{{ @label }}</slot>
    </button>
    """
  end
end

We declared a required click event property that we use on the <button> tag with the :on-click directive.

Now let's see how to define and pass events to that stateless component. We will use the Button component twice, each with a different handling function that has been defined in a parent live component.

Clicked 0 time(s)

defmodule Example do
  use Surface.LiveComponent

  data count, :integer, default: 0

  def render(assigns) do
    ~H"""
    <div>
      <p>Clicked <strong>{{ @count}}</strong> time(s)</p>
      <Button label="Click!" click="clicked" />
      <Button label="Reset" kind="is-danger" click="reset" />
    </div>
    """
  end

  def handle_event("clicked", _, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def handle_event("reset", _, socket) do
    {:noreply, assign(socket, :count, 0)}
  end
end

Remember that stateless components cannot handle events and do not have state. Events can only be handled in a LiveView or LiveComponent so we will store the state in that kind of component.

Stateful component

In some cases, you may want to have a default behaviour that is handled by the component itself and let the developer override the default implementation with a custom one. To implement a default behaviour, the component must implement an handle_event/3 function, and so it must to be stateful.

One example is a generic stateful Dialog component with a close button. By default, if the user clicks the close button, that will close the modal. However, if you're using the dialog to show a form that the user must fill in lots of information, and you may want to ask for confirmation that the user really wants to close the dialog. Something like: "Are you sure you want to close this form? All information provided will be lost.".

To impement this feature, you need a default local implementation that closes the dialog but this implementation can be overridden by the parent component by passing a custom implementation that, in our case, asks for confirmation before closing it.

First let's take a look at the <Dialog> component and its events.

defmodule Dialog do
  use Surface.LiveComponent

  alias SurfaceSiteWeb.Events.LiveButton.Button

  prop title, :string, required: true
  prop ok, :event
  prop close, :event, default: "hide"

  data show, :boolean, default: false

  slot default

  def render(assigns) do
    ~H"""
    <div class={{ "modal", "is-active": @show }} :on-window-keydown={{ @close }} phx-key="Escape">
      <div class="modal-background" />
      <div class="modal-card">
        <header class="modal-card-head">
          <p class="modal-card-title">{{ @title }}</p>
        </header>
        <section class="modal-card-body">
          <slot />
        </section>
        <footer class="modal-card-foot" style="justify-content: flex-end">
          <Button click={{ @ok }}>Ok</Button>
          <Button click={{ @close }} kind="is-danger">Close</Button>
        </footer>
      </div>
    </div>
    """
  end

  # Public API
  def show(dialog_id) do
    send_update(__MODULE__, id: dialog_id, show: true)
  end

  def hide(dialog_id) do
    send_update(__MODULE__, id: dialog_id, show: false)
  end

  # Default event handlers
  def handle_event("hide", _, socket) do
    {:noreply, assign(socket, show: false)}
  end
end

The component implements a default handle_event that handles the close event, and two public API functions that can be used by other components to open and close the modal.

Note that the Modal stateful component reuses the Button stateless component defined at the beginning of this section, and the events are passed along to these buttons components.

Now take a look at how we can use the dialog component with his default behaviour.

defmodule ExampleWithDefaultBehaviour do
  use Surface.LiveComponent

  alias SurfaceSiteWeb.Events.LiveButton.Button

  def render(assigns) do
    ~H"""
    <div>
      <Dialog title="Fill the form" id="event_dialog_example_1">
        Now, click on the close button to see close the modal.<br />
        Nothing will happen if you click on the OK button ;)
      </Dialog>

      <Button click="show_dialog">Click to look into action the default event</Button>
    </div>
    """
  end

  def handle_event("show_dialog", _, socket) do
    Dialog.show("event_dialog_example_1")
    {:noreply, socket}
  end
end

Now if you want to change the default behaviour of closing the dialog automatically, all you have to do is pass that custom event using the close prop. Remember, we want to ask for confirmation to close the modal.

defmodule ExampleWithOverwrittenBehaviour do
  use Surface.LiveComponent
  alias SurfaceSiteWeb.Events.LiveButton.Button

  def render(assigns) do
    ~H"""
    <div>
      <Dialog title="Fill the form" close="hide_dialog" id="event_dialog_example_2">
        Now, click on the cancel button to see the overwritted behavior. <br>
        Nothing will happen if you click on the OK button ;)
      </Dialog>

      <Dialog
        title="Alert"
        ok="confirm_close_dialog"
        close="hide_confirmation_dialog"
        id="confirmation_dialog_example"
      >
        Are you sure you want to close this dialog? All information provided will be lost. <br>
        If you click on the "close" button, you will be redirect to the previous dialog. <br>
        If you click on the "ok" button, you will confirm that you would like to close
        the first dialog.
      </Dialog>

      <Button click="show_dialog">Click to look into action the overwritten event</Button>
    </div>
    """
  end

  def handle_event("show_dialog", _, socket) do
    Dialog.show("event_dialog_example_2")
    {:noreply, socket}
  end

  def handle_event("hide_dialog", _, socket) do
    Dialog.show("confirmation_dialog_example")
    {:noreply, socket}
  end

  def handle_event("hide_confirmation_dialog", _, socket) do
    Dialog.hide("confirmation_dialog_example")
    {:noreply, socket}
  end

Handle event somewhere else

Using Surface, the event is always passed along with the related target, assuming, by default, that the target is the caller component/view. This should cover most of the cases you have to face when working with events. In the rare cases when you need to handle the event somewhere else, you can explicitly pass the target, e.g., click={{ "click", target: "#target_id" }}. If you want the target to be the parent LiveView, you can set the target option as :live_view.