Looking for input about how to better approch JS development in Phoenix (not just liveview)

First Hello.

Secondly:
I want declare my intent before I say anything else so that I’m not misunderstood.
I want to have a conversation that can typically become very contentious.
I don’t wish to be contentious, disrespectful or contrary for the sake of it.
That is not my intent.
I love the free work that other people have created for me that I have not ever had to pay for.
I’m not saying any of the following to be disrespectful of their hard work. So with that thanks for everything.

What is my intent?

My intent is to address my core concerns and to get input about how I can already use existing solutions or possibly develop a new solution to address these core concerns.

I think by addressing these concerns we can make phoenix better for everyone and possibly attract other typically front end heavy developers to adopt phoenix.

What I wish I had in Phoenix.

Following after this are my core concerns and wants as I think they best describe the flow.
In general I want to use phoenix as my primary framework and find a better way to make more expressive js applications in it. I love liveview and fully understand it, there are just these tradeoffs I keep finding myself wishing I didn’t have to make and I wonder if there is a better middle ground where we don’t have to make these compromises. I realize that some of my asks may never happen, I still think they are worth sharing so that 1: You considered the idea, 2: we have meaningful conversation that maybe brings to life new ideas.

So with that lets begin.

The HTTP REQUEST:

1A:
I want to use Phoenix as a primary router for my initial request and provide SSR for SEO.
I think the phoenix router / plug already does a good enough job of this and see this as the right way to address that concern.
Also I think Phoenix controllers do good enough for this for the great majority of projects.

1B:
I want all sequential requests to not require a full page load. (IE hydrate then hand off)
I am aware that live sessions and patch requests via liveview can address this.
I am aware that liveview does a dom diff via the socket so no need to call that out.
I do want to come back to this later as I feel liveview is coupled to the socket which I kind of see as the primary barrier to working with other frameworks like svelte or astro.

I feel the compromise of prefetching like other frameworks do would not compromise on performance significantly and possibly would make it much more easier to integrate these other framework with phoenix for SSR. I would love to see something like sveltekit:prefetch or @astrojs/prefetch for all sequential requests personally

1C:
I am aware that liveview mount callback can also addresses the initial request for SSR/SEO 1A

The Template And Code Organization

2A:
I want to use HEEX templates for general HTML and componentizing blocks of markup.
I know functional components already do a good enough job for this.

2B:
I would like to have scoped JS like that of other front end frameworks built in to the heex template
Likewise I would like to have scoped CSS like that of many of the frontend frameworks built in to the heex template.

2C:
I know JS hooks solves alot of these issues, but they really don’t feel well organized in my projects and leave me feeling like they are a compromise and not a solution. I tend to wish they had better proximity to the code elements they relate to rather than just living in my assets/js folder.
Also having to use hooks as the primary way of code splitting for your JS is not great, though it does work.
I believe I seen something possibly in the works for better organizing hooks so I guess there is some solutions for that. Though at the end of the day they are still just hooks. It would be nice to have a way outside of live view specifically and something more integrated as a part of heex in general when dealing with JS. Given heex is about embedding elixir in html and the fact html is so closely tied into CSS and JS it seems integrating support specifically in heex more sense IMO.

2D:
Same goes for Phoenix.LiveView.JS.
Its just not as pleasant of an experience developing UI interactions with LV.JS as it is working in say Svelte, Vue, ect components. I tend to feel less confident about working in this area of my codebase when working with LV. This is one of the major reasons I wish for scoped js in the heex templates as to keep the related code closer to the areas they relate to. I don’t know how I feel about just throwing in a script tag into these templates either.

The Event Lifecycle and the Socket.

3A:
It would look as if the great majority of compromises made are the result of the web socket.
I would ask:

  • Is the performance gained really worth it?
  • Can we have relative performance but open up more possibilities by doing it differently?

3B:
We still have Channels for when we really need that pubsub via sockets.

3C:
Server Sent Events are also a possibility.

3D:
https://hologram.page/ I think shows some of these trade offs in terms of the websocket and requiring network for things that maybe should stay client side till they need pushed to the server. Consider Offline mode and how the socket has been a barrier for that.

3E:
Page transitions and other more rich interactivity via js feels impossible with liveview. Again I feel this is the tradeoff as a result of the socket.

3F:
Change tracking in LV and Client side only state is a real battle. It tends to make me think maybe this does not need to be statefull on server after all. But then I realize the pain of duplicating models on the client to replicate that of the models on the server. There has to be a better way even if the sever is the source of truth. Let alone the compromise of offline mode or service workers, push events ect.

My conclusions so far:

  • I love phoenix, and want to keep using it as my main application.
  • I like the general idea of liveview but feel at odds with it when I compared to other js solutions.
  • I wish I had a more unified development experience (mono repo) when working with other JS frameworks inside of Phoenix. I realize this was how we got to liveview.
  • The socket requires a lot of tradeoffs even when using channels.
  • I’m not sure the trade offs are worth it for the socket given there are other like solutions.

How did I get to this place?

I’ve spent some time trying to figure out how to get the best of both worlds.

  • Phoenix + Vue,
  • Phoenix + Svelte (including live svelte)
  • Phoenix + liveview + other js frameworks

I recently deiced to give mix phx.new --no-assets --no-live a try and see what its like to just divorce from liveview as much as I could and write vanilla ES modules + channels and once trying server sent events.

it was not a horrible experience but it did highlight what I got out of the box for free.
In someways it inspired me to want more freedoms at the cost of out of the box features.
For example I’m not sure I will be going back to daisy UI or even tailwind moving forward.

Any ways I don’t mean this to be a rant about liveview,
I find myself constantly thinking about these problems and find myself even telling myself that I should just develop a whole new solution.

I would love to see what other people say about what I have said here.

Thanks for reading this.

7 Likes

It sounds like you might like GitHub - inertiajs/inertia-phoenix: The Phoenix adapter for Inertia.js. ?

3 Likes

Hey @Fullstacking, welcome to the forum! :slight_smile:

I think you have two solid options based on your requirements:

  1. Inertia.js (as @olivermt mentioned) - if you’re cool with JS frameworks
  2. Hologram if you want to stay in Elixir-land - it runs on top of Phoenix and already solves many of the problems you’ve outlined (component organization, rich interactivity, with client-side code intelligently transpiled to JavaScript), and scoped CSS planned.

Have you considered Hologram? Curious about your thoughts on it given your specific needs around UI expressiveness and avoiding those socket tradeoffs.

1 Like

Co-located JS is coming to LiveView in the next release.

Co-located CSS would be nice. I have been using component-scoped CSS in my own LV apps (via a custom library I wrote) and I enjoy the developer experience quite a bit more than Tailwind. I hope something similar makes it into LV some day.

2 Likes

I had seen inertia but if I recall it didn’t address ssr.
I have not really given hologram an honest 15 minutes of my time and I should so I cant say much about it other than it looks like its still the some ideology as live view in that it wants to abstract away the js.

I did just come across lit.js and I have to say this feels closer to what I’m looking for in terms of homogenized templates

For example.

home.html.heex

<simple-greeting :for={name <- ["foo", "bar", "baz"]} name={"#{name}"} />

app.js

import { LitElement, css, html } from "lit";

export class SimpleGreeting extends LitElement {
  static properties = {
    name: {},
  };
  // Define scoped styles right with your component, in plain CSS
  static styles = css`
    :host {
      color: blue;
    }
  `;

  constructor() {
    super();
    // Declare reactive properties
    this.name = "World";
  }

  // Render the UI as a function of component state
  render() {
    return html`<p>Hello, ${this.name}!</p>`;
  }
}
customElements.define("simple-greeting", SimpleGreeting);

Like this feels one step closer to what I’m looking for.

I think this would be amazing if I could consolidate the html, css to a single place but also get this to play nice with liveview so that they are not fighting over the local transient state.

Bonus points would be to have some kind of async partial loads without requiring web sockets so that I could regain use of service workers proxies for things like off line mode ect.

I’ve long expected that Phoenix could offer an inertia-like experience out of the box. LiveView already handles dom updates on socket state updates. Theoretically the dom updater doesn’t know that the updates are only coming from the server, it just knows if the state changed, and if so, update the ui. So really all Phoenix needs to provide is a way to update the socket state from the client. Currently we do that by sending a message to the server and the server responds with an update to a set of previously defined assigns. I wonder if it’s possible to introduce a new type of assign (like how stream assigns were later introduced) that takes a default value, populated on initial page load, but then is opaque to the server from there. Then perhaps some new JS hooks can be introduced to CRUD over those opaque objects. To the dom updater it’s just another object on the state object so the dom updates like normal

Hologram is fundamentally different from LiveView. While LiveView sends diffs over the wire, Hologram transpiles your Elixir code to JavaScript for client-side state and instant DOM updates.

You’re technically right that JS is abstracted away, but the development experience differs - there’s no abstraction leakage to deal with due to transpilation.

Worth giving it those 15 minutes - there’s a big update coming within the next 2 weeks with several roadmap items completed! :grinning_face_with_smiling_eyes:

8 Likes

Hello :waving_hand: I wanted to add my 2 cents here, as I had similar “problems” with LiveView and currently I’m quite satisfied with my workflow. For context, I’m the creator of LiveVue library, and I have quite a deep familiarity and preference for Vue frontend framework. I gave a talk “Why mixing LiveView and a frontend framework is a great idea” during ElixirConfEU 2025, recording is not yet out but you can check slides here (desktop-optimized)

So, in short:

  • I love declarative approach of LiveView, and having a stateful connection to the server which allows to skip writing API endpoints
  • I don’t like JS interop - JS module is limiting and imperative, hooks are detached and imperative as well.
  • LiveComponents are nice but have awkward API for updates, and can’t handle handle_info etc. Also doesn’t solve client side state.

What I want:

  • Be able to decide where to keep state - on server, or on client?
  • Have declarative rendering on both sides
  • Use amazing features of LiveView without problems

My current approach:

  • Use LiveVue in the same way as Interia.js does - one top-level Vue component per LiveView
  • Ditch HEEX, render whole HTML in Vue.
  • LiveView is responsible for updating props which are transparently propagated to the frontend.
  • Vue can decide if given user action should be handled locally or propagated to the server
  • I’m colocating Elixir files and Vue files.
  • live-navigate is fast, since it only updates props of a Vue component. Update is synchronous, no flash of unstyled content.
  • SSR is available and quite fast.
  • Using Vite instead of ESbuild (amazing DX)

Using that approach is not yet as straightforward as I’d like, I’m improving library to make it so (eg colocating requires moving node_modules to root etc). Igniter installer is also coming. Not sure if it checks all your boxes, but wanted to highlight there’s a possibility to do something like this without using Inertia :wink:

Some screenshots from my current project.



10 Likes

When Jose wrote about Remix I found his point in the opening paragraph about integrating client and server to be quite interesting. He said that he thought Remix was an attempt to do this with “different tradeoffs” than LiveView.

Now as far as I am aware Remix is already dead and replaced with React Router 7 (honestly the JS ecosystem is beyond parody), but I think the recent work on React Server Components offers another interesting case study in this area. Dan Abramov has written a number of great articles on the topic recently, particularly this one. I don’t understand RSC particularly well yet, but it’s probably a good place to mine for ideas as it seems to be another attempt at this problem with “different tradeoffs”.

I’m glad there is someone else on here bothered by the imperative APIs :slight_smile:

I will say, though, that I don’t think JS hooks are a problem. They exist solely as an escape hatch, and so their API isn’t really declarative or imperative in the context of rendering because they don’t do any rendering. They do have an update cycle, but it would be trivial to convert from mounted/updated events to a declarative style. For example, you could simply render a React component into the tree after mounted and then pass props to it via updated. (Obviously you are well aware of this given it’s quite similar to what you’re doing with Vue!)

Streams on the other hand are an imperative UI for rendering, and they worry me a lot.

Anyway I am essentially rambling but I think I’d like to take a deep dive into LiveVue to see what you’ve been up to as I think I’m also going to take a stab at some of these problems soon. My database project has nearly made it to the “make it work” stage and I’ll need a new distraction once the hard problems are all gone :slight_smile:

Is this true!? Oh boy, haha. I wonder if Shopify is going to push their Hydrogen clients on to React Router 7 now.


My biggest problem with the JS modules is that I feel myself wanting more of it which is of course exactly what they are trying to avoid. What are some problems around it being imperative? Is it just having two programming models or does it propose larger issues? I only use js commands for UI enhancements so I find it fits my mental model well, but I’m often missing something in these convos :upside_down_face:

LOL it’s always interesting when you do :sweat_smile:

2 Likes

I think to be fair it was dead and then Shopify “acquired” it (whatever that means) and so now it’s not dead, or something, but they also merged it into React Router 7 somehow. Which, I mean, really just drives the point home further. Keeping up with JS churn is a full time job and I absolutely cannot be bothered.

Aha, well if you read my post carefully you will notice I tactfully dodged mentioning JS commands :slight_smile:

It’s true that they are imperative and that’s bad, but they’re so simple that they usually don’t cause very much trouble. There is no temptation to use them for anything complex because it wouldn’t work anyway. However, I have seen some argue for more complex JS commands on here before and I vehemently disagree with that because then it would become a problem.

Streams on the other hand (here we go again) have been a huge problem for me. For example, I wasn’t even able to use them for what was essentially a canonical use case (an infinite scroll feed of news items) because they cannot be updated declaratively when state is changed.

In detail: if a user interacts with an item - say, bookmarks it, I show a little icon on the item. Trivial detail. Remember I am all in on “real-time” with LiveView, so this is done by round-tripping the update through Postgres and back out of PubSub. When the event is dispatched back to the component it gets updated in the page. This works pretty well (Postgres issues aside, no digressions today!).

So if you’re using Streams you would think you can get away with this because it is still declarative if the update is scoped to a single component. Only one feed item needs a bookmark added, so we can grab the new %Item{} from the DB and stream_insert and we’re good to go. Yes, you need proper declarative rendering for complicated stuff, but this is such a simple use case. Streams should work, right?

Hah, no. Question: how do you know if the Item is on the page right now? Maybe it got kicked off the page (infinite scroll, remember?). Maybe it’s in a different feed! Maybe it was only added to the feed after the user loaded the page! Maybe the feed was loaded with some predicate (“unread items”) which is no longer true due to the update itself!

The stream_insert API will update an existing item, yes, but if the item isn’t there it gets added. How are we supposed to know if the item is already there?! We can’t check the DOM, because it’s on the client!

So there is a valuable lesson here: imperative APIs are okay as an escape hatch, but Streams are also a terrible escape hatch because they have no DOM access. If you need an escape hatch you should be writing JS on the client.

3 Likes

I’m not really sure how you intend to solve the problem of “does this random pubsub message belong to the current set” declaratively. The only way I see this being solved – besides manual tracking – is something live updating queries, which you can get with electric sql (/phoenix_sync afaik).

1 Like

Best of both worlds don’t come easy, at least not in a non-opinionated way. In the end, it all boils down the following questions:

  • What is the nature of the application that you want to develop?
  • What do you know already and feel comfortable to work with?
  • The amount of time, the size of the team you have
1 Like

The list of items is stored in memory. I can simply check if it’s there, or not. No problem at all.

Unless I use a Stream, in which case the list of items is stored in memory in the client’s DOM, which I cannot read from the server.

The PubSub messages themselves are, you could argue, imperative. They are converted into a declarative state for rendering like so (pseudoish-code):

def handle_item_updated(item_id, socket) do
  item = Repo.get!(Item, item_id)
  items = List.keyreplace(socket.assigns.items, item.id, 0, {item.id, item})
  assign(socket, :items, items)
end

If you use LiveView without streams, this works perfectly. If you use streams, the problem I mentioned above prevents you from properly updating the DOM because you can’t know if the item is in the list (or anything else about it).

The root of this problem (and so many others) is that the API is imperative.

If you want to use an imperative API as a performance escape hatch there are situations where that is reasonable, but when writing imperative code it is important (one might say it is imperative…) that you have access to the current state, which you don’t with Streams because the state is on the client.

2 Likes

The whole reason for streams is the optimization of not storing state server side though. You can opt to not use streams, you can decide to retain just a set of ids next to a stream and leave the other data to the stream. LiveView 1.1 will come with keyed for. There’s a grayscale of things you can use depending on the tradeoffs. If you need more state server side than streams give you that’s a tradeoffs to keep in mind when chosing the solution.

3 Likes

Yeah, and I get that. The problem is that there seem to be very few use-cases for which this tradeoff is actually worth it. Imperative APIs like this are essentially bug printers already, and then adding in the fact that you can’t even access the current state limits usefulness further.

TBH I think the hard truth is: if you can’t store state on the server, why are you using LiveView? You should be client-rendering those parts of your app. Streams feel to me like a worst-of-both-worlds solution.

I could have, yes, but then how do I implement infinite scroll? Like this?

def handle_page(next?, socket) do
  page = load_page(next?)
  ids = socket.assigns.ids ++ Enum.map(page, &(&1.id))
  limit = 100
  ids = if next?, do: List.take(ids, -limit), else: List.take(ids, limit)
  socket |> assign(:ids, ids) |> stream(:items, :page, ...)
end

Writing code like that (keeping things in sync that I can’t even see) makes me extremely uncomfortable. And while it might fix this particular issue, it won’t solve the problem in general. What if I need to access item.title to determine whether an item should be updated? Now I have to store those too. And so on.

Like I said, if you’re writing imperative code you really want access to the current state. It doesn’t belong on the server in this case IMO.

Finally: I am willing to accept that while the API is suboptimal there are still some niche cases where it can be used effectively. But there are several places in the docs where Streams are recommended over normal LiveView, e.g. for infinite scrolling. I think that’s a mistake.

P.S. I was very happy to see keyed :for, I think it’s a step in the right direction.

1 Like

Garrison :heart: you share the same feelings towards streams as me. You can check my point in this post. I like to think I had some influence over adding :key into loops :face_with_peeking_eye: All in all, I’ve done some testing and while that’s definitely an improvement, they’re not fully there, yet.

Anyway, I mostly avoiding streams now. IMO state should be either client-side, or server side. If I want ephemeral state on the client, I’m usually using {:reply, value, socket} tuple from handle_event, which then acts as an “API endpoint” of sorts. Having a full-blown client framework on the client side greatly helps with it, so I can decide what to do with that response.

In LiveVue I’m experimenting with using Jsonpatch.diff for each updated prop to minimize payload size at the expense of some processing time. This will remove a “smaller payload” argument from using streams. Optimizing memory footprint sounds important, but personally I’ve never had a serious issue with it.

Some random rambling:

  • Optimizing rendering & payload diff of LiveView is almost exactly the same as optimizing number of HTML updates with virtual DOM in a modern framework. Just, that virtual DOM tree is on the server, and not on the client.
  • Thus, almost all techniques used in eg React or Vue should make sense. :key in loops, change tracking, dynamic vs static content etc.
  • Would be great to have a build-in way of declarative client-side rendering. Alpine JS / Vue Petite are nice, just not really enough. Hologram is interesting, but I’m worried it will try to “reinvent the wheel” instead of making it possible to use existing solutions from the frontend world.

Anyway, that’s that for now :wink:

1 Like

Note that diffing arbitrary trees is an O(n^3) operation, completely nonviable for real-world programs. It is this exact problem which necessitates the key optimizations from React and now LiveView, although since LiveView’s keys are currently global (see my posts on the topic recently) it’s interesting to note that there is not as yet any tree being diffed (only a flat map).

There is a fundamental equivalence between a React-style renderer and a memoized effect system, an argument made at great length in this glorious series of articles. They key insight for React and other such systems (another good example given is MapReduce) is that in order to effectively incrementalize the computation the keys are very very important.

(In React, the keys correspond only to components; Hooks use call order to line up between invocations.)

My point being: if you pass down an arbitrary JSON tree of props and then render them on the client, you are back in O(n^3) territory and in big trouble. JSON keys/values won’t save you, by the way, because they are unordered!

Circling back, I am very curious how React Server Components deal with this problem, as I think it’s a big part of what they’re for. I don’t know the answer yet, though. I think we both have some studying to do :slight_smile:

Just wanted to clarify - it’s definitely not my intent to reinvent the wheel. Modern frontend frameworks (including Vue) have already solved many problems elegantly, and I’m mainly implementing things inspired by those frameworks. My goal is to bring those powerful abstractions and patterns they’ve already perfected to pure Elixir.

The “reinventing” or rather inventing will be more in the phase of local-first features, as this is relatively new terrain for web frameworks.

If you’ve got ideas or features you’d like to see in Hologram - let me know! I’m definitely open for discussion and suggestions :slightly_smiling_face:

4 Likes

I’m aware, O(n^3) obviously is far from ideal - but it’s needed only if you need an optimal diff without keys.

Right now jsonpatch library simply uses index of an item as a key. Sadly, it often results in a suboptimal payload size. That’s why I suggested additional feature which is a deep, key-aware diffing. But for now I didn’t had the time to solve this, I have so many ideas and not enough sleep :joy:

And in case of jsondiff, it’s just a diff of JSON object. Usually simpler than HTML diff because these objects tend to be smaller. RCS are interesting, but TBH I don’t know much about them. Time for some reading :nerd_face:

1 Like