Phoenix Liveview - Access the state from within Javascript

Hi there,

I currently have a Phoenix Liveview application set up and running. I know that in the html.heex files you can use syntax to access the current state. For example:

<%= for msg <- @messages do %>
    <h3> <%= message.id %> </h3>
<% end %>

Is something similar also possible for Javascript? From within Javascript I would like to be able to access and browse through my state. I know I can use push_event and hooks to trigger stuff in Javascript and then I can provide the required data as a parameter, but in this case there is no server-side trigger.

Thanks for your help in advance!

The state is never provided to the client. Your only options are events sending data back and forth.

5 Likes

Oh alright, how would I best approach this? More concretely, how do I request data from within Javascript without any user interaction triggering it.

I’m not sure if this is the best solution but could you make a js object in your heex file?

<script>
var myState = { messages: <%= @messages %> }
<script>

You could even build the variable as you loop through your messages.

You can, for example, request data periodically using setInterval() from a JS hook like this:

Hooks.PeriodicRequest = {
  mounted() {
    this.interval = setInterval(() => {
      this.pushEvent("request_data", { foo: "bar" }, (serverReply) => {
        console.log(serverReply);
      });
    }, 1_000);
  }
  destroyed() {
    clearInterval(this.interval);
  }
}

But there must be a more efficient and idiomatic way to solve your problem Could you please describe your use case?

Well, I don’t recommend this, but if you reeeeaaaally want to you can stick a push_event into handle_params. Something like this:

  @impl true
  def handle_params(params, session, socket) do
    {:noreply,
     socket
     |> push_event("state", socket.assigns)}
  end

handle_params is called whenever there’s a live patch event and also after mount. So whenever that LiveView is mounted and whenever there’s a live patch event, your state ends up being pushed as part of the payload for push_event. This means your state is pretty much always available to the JS without the user doing anything besides navigating to the page. You’d also need an event listener in your app.js to make sure that this event is always caught.

I caution that this is a hack, though. If you’re doing this much with JS then you’re probably outside of idiomatic Liveview development already, so it may help to rethink your approach. Ideally, JS interop should be a last resort for when the Liveview abstractions don’t offer what you need.

1 Like

In my experience, JS interop is standard practice. I don’t feel that we should try to avoid it at all costs. Rather, one should use the appropriate abstraction for the job at hand.

This is a bit of a red flag though. Why do you need the entire state available on JS? What are you doing?

2 Likes

(shrug) fair enough. I may have worded that too strongly. I do agree that it’s a red flag to need the whole state available in JS.

For me, the appeal of Liveview is avoiding JS. Even the latest Liveview book is subtitled “Interactive Elixir Web Programming Without Writing Any JavaScript”.

Right. Isn’t one of the main points of LV is to not manage state in two separate places?

2 Likes

Things get proper messy when we try to!

It’s a trick though! If your app is on the local intranet then you can afford it but over the www not using JS is going to make some things uneccessarily slow. Depends what we’re building :blush:

I am building an interactive program visualizer. Server-side I keep the current state of my program, client side I show a visualization to the user. Because the user can interact with the visualization, I often want to show/hide different information or parts of my visualization. For example, when a user clicks on an actor in my program, some information about that actor should appear. Thus I need to retrieve this information from my state somehow.

This is an interesting solution. Probably this is not the most efficient one which might cause problems at a later stage (there will be a lot of updates), but if there is no other way this will do for now.

I’ll experiment with it tomorrow and see if there are any other suggestions in the meantime.

Where are you keeping the current state? In a genserver somewhere? In a JSON object? You say “server-side” but where, specifically?

From what you’ve described, this sounds quite easy to do with Liveview. There could be scaling issues if many thousands of people need to see the same thing, but it doesn’t sound like you have a huge userbase…

I’m currently keeping the state in the LiveView socket as well as in a GenServer. This is still very subject to change though, as development is in a very early stage.

It’s good to hear that this should be quite easy. Do you refer to your earlier solution with this or what would your suggestion be?

The scaling issue would not come from the users, but from the program I am trying to visualize. I am not trying to visualize structure, but behavior. More specifically, behavior of a distributed system with actors (like Elixir processes). As such, large complex programs might result in a huge amount of updates to the state.

I don’t think JS interop is a last resort. For certain use-cases it is the way to go and encouraged.

I have a scenario, where I have a rich text editor which fetches data from live view, saves data on state and even utilizes live uploads fully from JS land.

As the data can be quite large and changes frequently I don’t want to put it into data attributes. To get the data, I call pushEvent from JS (when the hook initializes), handle that event in the live view and reply with the data.

  def handle_event("Editor:init", _params, socket) do
    {:reply, %{data: socket.assigns.issue.body}, socket}
  end

and on JS side

   this.pushEvent('Editor:init', {}, (reply: { data: Record<string, unknown> }) => {
      if (reply.data) {
         // do whatever you want with the data
      }
    });

To listen for changes pushed from the server I use handleEvent

this.handleEvent(
  `Editor:upload_progress:${nodeKey}`,
  (payload: { key: string; progress: number }) => {
    // do whatever you need to do.
  },
);

For your usecase you should be able to do pushEvent with replies just fine. When a user clicks a button in your JS part, just call pushEvent (which is available in the hook) and reply with the latest state from the server. The way how you store and handle state in your JS part depends on you.

2 Likes

I’ve reading up on the subject and from my understanding I will need to make use of Hooks in order to be able to call this.pushEvent. However, I am slightly clueless on how to approach this. I have found some examples on how to work with Hooks when, for example, a button is clicked using the phx-hook attribute. However, in my case the event should simply be pushed from within an arbitrary JavaScript function. A function that is not necessarily linked to a user interaction/HTML element.

Is this possible? If so, could you share an example?

You would need to have at leat one hook set up to be able to call pushEvent from an arbitrary function.

You could create a live_component and mount it somewhere at the top of your app tree.

defmodule MyAppWeb.MyComponent do
  use Phoenix.LiveComponent

  @impl true
  def render(assigns) do
    ~H"""
    <div id={@id} phx-hook="MyHook" class="hidden">
    </div>
    """
  end

  @impl true
  def handle_event("ping", params, socket) do
    {:noreply, push_event(socket, "pong")}
  end
end

In your app.html.heex

<div id="main">
  <.live_component module={MyAppWeb.MyHook} id="my-hook" />
  <!-- your content -->
</div>

and then register a hook in your app.js

const MyHook = {
  mounted() {
    // this.pushEvent is in scope here
    // depending on if you use a lib like react you can pass the pushEvent via props.
    // for no library, you could also utilise a global variable, but I don't really know a use case for it
    
    window.__MyHookThings__ = {
      pushEvent: this.pushEvent.bind(this),
      handleEvent: this.handleEvent.bind(this),
    };
  }

  destroyed() {
    delete window.__MyHookThings__;
  }
}

Now you can call pushEvent from anywhere in your JS.

if (window.__MyHookThings__) {
    window.__MyHookThings__.handleEvent('pong', () => {
      console.log('pong received');
    })

    window.__MyHookThings__.pushEvent('ping');
}