Skip to content

Conversation

@pinetops
Copy link

@pinetops pinetops commented May 14, 2025

This adds a capability similar to useMemo() in React, that allows for a more declarative way of expressing dependencies between assigns.

This is the first of a number of possible features that put together could potentially improve the DX of liveview by making it possible to write things more declaratively. I've pulled them together in a package here https://hexdocs.pm/livex/readme.html, but it's very much an experiment on whether these are good ideas to add upstream.

And so it's also a bit of a trial balloon to see if there's interest in going in a direction like this. I'm pretty flexible on the details (indeed I already have ideas for how to improve it) and if there are suggestions I'd love to add them to livex to help prototype them. I feel pretty good about the assign_new/pre_render/state approaches - and I think they do advance the DX a fair but, but I'm bit less certain about the events so it's very much a work in progress.

I'm curious if this general direction is interesting - and really, is having a future state where liveview looks a bit more akin to functional React a good idea? And would patches to push the framework in this kind of direction be welcomed?

@pinetops pinetops force-pushed the features/assign_new branch from 2125705 to dd43e3c Compare May 14, 2025 17:06
@SteffenDE
Copy link
Collaborator

Hey @pinetops,

thank you very much for the contribution! We've also seen your thread on Livex on the forum. We're currently at ElixirConf EU so time is a bit limited right now, but we're planning to discuss your proposals and get back to you hopefully by the end of next week!

@pinetops
Copy link
Author

Thanks! Enjoy the conference. Due to a bit of bad planning I actually just flew out of Krakow this evening 🤷

@josevalim
Copy link
Member

Hi @pinetops, glad to see someone experimenting with all of these ideas. Below I add my comments (not representative of the Phoenix team).

URL based assigns

They are great. My suggestion would be to add them as a regular function:

socket |> url_assign(:foo, default_value, &casting_function/1)

Declarative Data Derivation

My biggest concern about this approach is that it depends on the developer maintaining those dependencies, and they will forget, make mistakes, etc, leading to inconsistent UIs.

I wonder if a reactive approach, instead of dependencies, would be better. For example, instead of:

 |> stream_new(:products, [:selected_category, :sort_by], fn assigns ->

You would say that:

 |> on_change(:selected_category, &recompute_products/1)

I think that leads to a more forward thinking model, more compatible with LiveView (since today we think that "when an event happens, I do x-y-z"). However, there is still a question about loops (what happens if computing the products also computes the category and so on) and what happens if someone changes the products out of band (which could conflict with the category).

Simplified State Updates

I wonder if those should all be URL updates in practice and therefore we make it easy to update the URL instead, since theoretically you always want to store the state somewhere (either the DB or the URL). Perhaps we should start with that instead?

Internal Messaging: send_message & handle_message

What if we allow send_updateto be sent to a LiveView and it receives def handle_info({:update, ...}, _)instead? Do you think it would help improve things or both having the same callback would be an important requirement?

Enable PubSub for Components (assign_topic)

I believe we discussed adding support for components to attach_hook in the parent LiveView and hijack messages. It is something we would appreciate contributions for!

@pinetops
Copy link
Author

pinetops commented May 19, 2025

Thanks for the feedback!

So, I think the really big change I'm gunning for is actually introducing something like a pre_render() function. That is, a function that takes 'socket' that runs after all handlers and before the render.

This makes more explicit what you're really doing in a liveview which is: render -> event -> reducer -> render -> event -> reducer.

The handle_* functions are the reducers, and the render is, well, the render. And the idea is that derived state can be handled as part of the render (i.e. pre_render is part of the render), rather than as part of the reducer.

When we put things like 'reload the list of entities because the filter changed' in the reducer, we are indeed treating this as a change. This thing happened -> we need to change this other thing.

But a lot of what we do in a view is loading derived state. I.e. the important info is the 'country=us' in the url, and the list of locations filtered by country is derived state.

As far as I can tell most of the major UX frameworks actually have both. e.g. in React, assign_new/4 & stream_new/4 are basically analogous to useMemo(), on_change is closer to useEffect().

This gets a bit fuzzy because the simplest way to add this to liveview is to add a pre_render(socket) -> socket. And as such, both end up putting things in assigns, they're just two different kinds of things, are we memoizing derived state (declaritively defining a relationship) or are we 'doing' something? But given my idea was to put derived state in assigns, both end up being pretty close functionally, so this ends up being a 'what is the better way to think about this?' question more than a 'what does the function do? question.

So tl;dr: I think it would be better to have a clearer distinction between derived state and real state, and a place to declare the relationships (i,.e. pre_render, or with more magic actually in render).

And I think it makes for better code. If we add automatic state recovery/persistence to urls, pre_render and something useMemoish, we get a really clean division between the two, as can be seen here (pre_render is all derived state, handle_* is all real state):
https://github.com/u2i/livex_demo/blob/master/lib/livex_demo_web/live/location_live/index.ex

Any thoughts on that analysis?

@josevalim
Copy link
Member

Once we introduce url_assign or anything similar, then you are 100% right we need somewhere to react to those changes. I would say pre_render or on_change are somewhat equivalent: one handles all at once, the other handles one by one.

My issue with pre_render is that it won't combine with any stateful operation. For example, imagine you have a feed with filters, the feed is implemented with streams. Once you select a filter, you clear the feed, and render it again. We cannot have this on pre-render, as it would be cleared every time, unless you start checking on pre_render which field changed.

I am not convinced that pre_render would replace mount in practice, as checks like connected? are necessary, and it feels weird for pre_render to call assign_new, which basically means it is a no-op every time except the first render. Those things push me more towards an on_change function style than a new module callback.

PS: Theoretically speaking, I think we don't even need pre_render, you could compute those "render-only" assigns inside render itself, before the ~H sigil.

@pinetops
Copy link
Author

pinetops commented May 19, 2025

Once we introduce url_assign or anything similar, then you are 100% right we need somewhere to react to those changes. I would say pre_render or on_change are somewhat equivalent: one handles all at once, the other handles one by one.

Yeh, i agree these are really close functionally. And I think it might depend on whether we're really missing something like pre_render/render. I think what the right concept here is depends on whether we should have memoization in render/pre_render.

My issue with pre_render is that it won't combine with any stateful operation. For example, imagine you have a feed with filters, the feed is implemented with streams. Once you select a filter, you clear the feed, and render it again. We cannot have this on pre-render, as it would be cleared every time, unless you start checking on pre_render which field changed.

Right, that's the point of assign_new/4 and stream_new/4, the idea is that in render we're either doing trivial computations (the way you use assign_new works now in render) or things that are derived but more expensive. pre_render and assign_new go together, a way of only recalculating the memos that need to be updated based on a declarative relationship between the state proper, and the derived state.

I am not convinced that pre_render would replace mount in practice, as checks like connected? are necessary, and it feels weird for pre_render to call assign_new, which basically means it is a no-op every time except the first render. Those things push me more towards an on_change function style than a new module callback.

Yeh, it's not that it gets rid of mount. It's just that once you have it, there's a bunch of stuff that is currently sprinkled around mount, handle_event, handle_info and handle_async (interestingly, I think handle_params is better thought of as state rehydration than a reducer) that in my experiments can be expressed better as relationships between state -> derived state -> deriveder state.

And is it a bad idea that it's a noop most of the time? This is just a question of whether it's good to be able to express these relationships, and I think this comes down to if you prefer to think about the relationship as:

  • if someone triggers the filter change event then recompute the list of locations
  • the list of locations is a function of the filters

I'm basically saying that the second one makes more sense to me, and it seems to work out in pretty well as I've been trying this approach. But whether the two features are good/bad I think is downstream of whether we think one the second one is a better way to think about it - and to encourage liveview developers to think about it. I don't think it would be a good idea to implement these features if the former is the better conceptual approach.

PS: Theoretically speaking, I think we don't even need pre_render, you could compute those "render-only" assigns inside render itself, before the ~H sigil.

The key one in the example is the locations stream_new (yes, ok this liveview has zero examples of the topic of this PR, but stream_new is basically the same idea), which should be recalculated if and only if one of the filters changes. Could we have memoization in render itself? It's how React does it. I didn't go that way because I thought 'pre_render' was a better map of the concept into actual liveview, and doesn't introduce non-functional magic like React does. But it would be pretty neat nontheless...

And actually, I'm curious - when we were discussing my push_js contribution you gave it a thumbs down because the framework should be pushing a more declarative approach. I agree with that (and at best it's a pragmatic workaround to be used when there isn't a good declarative approach) but isn't what I'm proposing an example of a more declarative approach? Is there a reason why you're thinking the imperative approach makes more sense in this case? Or am I thinking about this wrong?

@pinetops
Copy link
Author

pinetops commented May 20, 2025

I thought about this some more and I want to back out of the specifics. Let's just let the fact I've prototyped it stand for evidence that I've been trying to think through the ideas.

Instead, if you wouldn't mind, I'd like to try a set of propositions and see which ones you agree with:

  1. Liveview follows a render -> event -> reducer loop, this makes it similar to React and most of the other front end UX frameworks.
  2. render is render, handle_event/info/async are reducers (with side effects), handle_params is state rehydration
  3. in a optimally written liveview, the state is stored in urls and forms (ok, also cookies and localstorage) everything else is derived state
  4. well written is defined as: is robust against connection failures/backend failures/backend downscaling. in practice, we probably don't make sure everything is completely unaffected by backend issues but it would be rather good if we did
  5. liveviews have 2 kinds of state (state/derived state), livecomponents have 3 (adding parent state, i.e. props in react), functional components have 1 (parent state) - in practice we might fudge this a bit, but it's probably not a good idea
  6. the most natural way to write liveviews is to treat handle_* as reducers on socket/assigns. But it's good engineering to use push_patch to store state, and use assigns to handle derived state

So, my conclusions:

  1. It would be good if we could automatically store state in the url, but use assigns to actually update it (i.e. we do state updates in reducers, rather than by side effect with push_patch). it would also be good if components could do this too.
  2. It would be good to have non-url persistent storage, so we can choose between what survives a hard refresh and what survives a disconnect
  3. it would be good to be able to express derived state declaratively
  4. expressing state declaratively means making sure a set of propositions about the relationship between state and derived state are always true before rendering. So we should have some place to put those propositions. This could be in a lifecycle callback (i.e. insert pre_render between handle_* and render.) It could be by making render capable of storing derived state. It could be writing a dsl to express the state. Or, without modifying liveview, one could just call the function as the last step in each reducer.
  5. in addition to having a place to do the relationships we also need a tool for expressing them. React uses useMemo and a list of dependencies which it compares with equals. Elm automatically figures out the dependencies and automatically invalidates. My proposal here is to use changed, as that fits in with how liveview approaches things.

So, I'd be curious which things you agree with, which you disagree with and which you thing are plausible but you don't know yet.

Btw, I thought that assign_new+deps might have been a natural enough extension of existing functinonality that it would be an easy merge. But other than that these are obviously big changes and that's why I worked on a prototype to try and prove these concepts. I'm happy to get better evidence of whether these approaches are successful and where they hit snags, but it would be helpful to break down which ideas need to be better justified.

@josevalim
Copy link
Member

I think what the right concept here is depends on whether we should have memoization in render/pre_render.

Oh yes, you are right, I forgot about the memoization, which means doing it on render is not the best idea.

render is render, handle_event/info/async are reducers (with side effects), handle_params is state rehydration

I would say handle_params is both state rehydration and a reducer, because it will be invoked as you click through the page. We could instead have something modify the assigns and have it update the URL (it indirectly pushes patch) but the main benefit of using URLs is that you can have regular links that you click (and potentially share with someone). In this sense, it is a specialized handle event. In other words, I would prefer to have proper links than using push_patch.

in a optimally written liveview, the state is stored in urls and forms (ok, also cookies and localstorage) everything else is derived state

Good call out on forms and localstorage. But to me localstorage are different from URLs (and potentially forms too). As per above, URLs (and potentially forms) should mostly be driven by the client, while for localstorage it is fine for us to do the change on the server and then send it to the client (i.e. it is server driven).

I guess, however, one of the big questions for both entries above is how to design this so components (function or live) still only work with assigns, without really caring if the assign is a regular assigns or a url one? 🤔 I also have to say that another benefit of events (vs directly modifying state) is that they decouple the action from what happens. So it is ok-ish for a component to hardcode an event. But have it hardcode a particular state action (i.e. bump this counter) would lead to very poor reuse.


Btw, I would suggest calling your assign_new something other than assign_new. I think assign_new has a very clear purpose and I am not sure blurring the lines are worth it. For example, if I see someone calling assign_new in anywhere but mount or handle_params, I'd probably flag it in a code review. WDYT?

@tcoopman
Copy link
Contributor

I'm mostly following this from a distance because I'm interested in where it leads, but this was something I wanted to react to:

in a optimally written liveview, the state is stored in urls and forms (ok, also cookies and localstorage) everything else is derived state
well written is defined as: is robust against connection failures/backend failures/backend downscaling. in practice, we probably don't make sure everything is completely unaffected by backend issues but it would be rather good if we did

I can definitely see use cases where you would want this, but I would not define this as optimally/well written by default. In a lot of cases I'm perfectly fine with a refresh/failure meaning: fetch everything again. And then the current way of working is a lot less complex than adding in local/client state management by default.

I do agree that having some features to add local state management would be nice!

@pinetops
Copy link
Author

pinetops commented May 20, 2025

On the main topic:

Btw, I would suggest calling your assign_new something other than assign_new. I think assign_new has a very clear purpose and I am not sure blurring the lines are worth it. For example, if I see someone calling assign_new in anywhere but mount or handle_params, I'd probably flag it in a code review. WDYT?

Yeh, this was definitely a bit 'cute'. But the rationale is first that current assign_new is a special case of assign_new/4, i.e. assign_new(socket, :thing, [], ) is identical to assign_new(socket, :thing, ). And second that assign_new is what's currently used for the closest thing we have to memoization: the use of assign_new(assigns..) in render().

And then there's also the fact that assign_new is used for controller->view and view->nested view communication. And that strikes me as a similar: makes the code look nice, but hides some (for the most part, inconsequential) complexity.

But it isn't the best name and I'm totally open to a different one, it's the concept (memoization with deps) that's the important thing for me.

The biggest weakness is it does nothing to educate the developer that this is a different kind of thing. useMemo in React is just different from doing state updates, and you walk away with two different concepts in your head.

So what can I come up with?

  1. assign/4 - just add the third optional argument to assign for 'deps', and assign_new( becomes convenient synonym for assign(,,[],_)
  2. assign_memo/4 - let's go all in on React and make it easy for the LLMs to figure out what we're talking about
  3. dependent_assign/4 - make it clear we're talking about doing conditional assigning
  4. assign_derived/4 - let's make it clear we're talking about derived state, and educate devs that derived state is a thing, also Svelte
  5. assign_computed/4 or compute/4 - let's party like we're vue/ember

So, I think assign_memo/4 is probably the best of the bunch for me. It clearly identifies that we're talking about assigning a different kind of thing, that it relates to the same concept in the most common UX framework, and memoization is the widely used term for caching expensive computations. But open to any of them or something different.

On the conceptual stuff:

I would say handle_params is both state rehydration and a reducer, because it will be invoked as you click through the page. We could instead have something modify the assigns and have it update the URL (it indirectly pushes patch) but the main benefit of using URLs is that you can have regular links that you click (and potentially share with someone). In this sense, it is a specialized handle event. In other words, I would prefer to have proper links than using push_patch.

Yeh, so basically I've reframed what sharing a link is. I'm saying that we should think about it as follows: assigns -> dehydrate state (i.e. non derived assigns) to url with push_patch -> copy paste -> send to someone -> other person drops in browser -> liveview rehydrates state on mount. It's kind of a weird way to look at it, but it actually works, I think.

Good call out on forms and localstorage. But to me localstorage are different from URLs (and potentially forms too). As per above, URLs (and potentially forms) should mostly be driven by the client, while for localstorage it is fine for us to do the change on the server and then send it to the client (i.e. it is server driven).

So putting this in this reframing:

  • urls are liveview/component state in dehydrated form
  • the delta between the form in assigns and form in browser is potential future input to a reducer (i.e. it's just something that happens somewhere else until you change/validate/submit when it's input to the reducer)
  • localstorage is a place we could dehydrate view state to (aside from urls, the other places we could put for reconnect survival it are in the DOM, or in js state) but you can also put things that the back end is unaware of there too

So, it's a bit different than splitting state by what it's driven by, it's dividing the world into:

  • liveview/component state and places we dehydate that state (props/parent state is just state that's owned by a different component/view)
  • derived state
  • everything else that exists independently of views/components. Say if you store an accordian state in localstorage and views/components never find out about it. Or forms that haven't yet been submitted or otherwise been used in a reduction on assigns. Or indeed everything else that happens in the world that views are unaware of. From 'what did I have for lunch today?' to 'who is the president of Poland?' onward.

I guess, however, one of the big questions for both entries above is how to design this so components (function or live) still only work with assigns, without really caring if the assign is a regular assigns or a url one? 🤔 I also have to say that another benefit of events (vs directly modifying state) is that they decouple the action from what happens. So it is ok-ish for a component to hardcode an event. But have it hardcode a particular state action (i.e. bump this counter) would lead to very poor reuse.

So... here's my big question. Are stateful cross project reusable components actually a thing? Most stateful components are just a way of decomposing an app into smaller chunks and making it possible to use, say a particular bit of UX in multiple place on a site. In that case, it's completely fine for the component to decide itself what kind of persistence it wants. I think it's only when you're using a re-usable component that you installed from github that you might want to inject a preference from the containing application.

Also, a lot of the state tends to actually be parent state (i.e. React's props). And in those cases you should use some kind of messaging to the parent. E.g. if you have a modal on a page that can edit a particular location, the id is set by the parent. And if you wanted to add a 'next location' button in the modal, it should message the parent to do it. It doesn't actually have it's own state.

And so, I don't have any examples of a stateful cross project shared component. I tried to contrive one, and it's in my demo app. A country selector that doesn't use form fields. But I think this was basically an error - custom form fields should survive reconnects with form recovery. And I think webcomponents might be the solution here. LLMs make the barrier to entry for doing that rather lower. I built a rather nice searchable/combobox with keyboard navigation yesterday and it integrates perfectly with form recovery.

So, if anyone has any good examples of stateful cross project reusable components that aren't best implemented using form recovery I'd be interested!

But for what it's worth, my take is that web development frameworks in general have largely failed to properly solve the reusable component problem. Mostly the way people seem to do it is either a) pick a component framework you like and live with the style guide, b) pick the style framework you like and if you're lucky there's a component framework for you language but better hope you don't want to get too frisky with the customization or c) someone did a design in figma, and we're just going to have our own set of components for this project. This tension is captured in core_components. Yes we have some reusable components, but really just to get you started.

So, my thought for a platform like phoenix/liveview is to build on what core_components does as a side effect, define a common interface to user interface widgets that could be implemented by different styling approaches - as we've seen in the switch from tailwind -> daisy. It's actually pretty helpful that there's a way to do links, buttons, input fields, forms and the rest in liveview that isn't especially tied to the specific framework. Other UX frameworks in phoenix tend to broadly follow the patterns set out here.

Ideally though, this would include interfaces for the still common, but slightly harder components to implement. More complicated because they go beyond what can be done purely client side - modals, flyovers, data driven combo/auto-complete/search/tagging type widgets.

In the context of liveview Modals/flyovers are mostly a parent state thing (it's the parent that controls open/closed, which entity the modal is an edit for etc), so can either be functional, or only if they have some unrelated internal state, live components. But more complicated form widgets might be best done with webcomponents (or indeed js heavy hooks) so they look like form elements from the liveview perspective, and participate in form recovery - with any other state stored in local storage - rather than needing additional state in assigns.

@pinetops
Copy link
Author

I can definitely see use cases where you would want this, but I would not define this as optimally/well written by default. In a lot of cases I'm perfectly fine with a refresh/failure meaning: fetch everything again. And then the current way of working is a lot less complex than adding in local/client state management by default.

I think this is an understandable pragmatic choice - and is fine for a bunch of contexts - but overall I think it's a poor outcome. It would just be better if the framework made being robust against backend server recycling/reorganizing the default. This is what happens with form recovery, I'd just like to see it for everything else too.

@pinetops
Copy link
Author

pinetops commented May 20, 2025

My current thinking about reusable components.

  • should be functional components
  • should manage their own state with js (i.e. use localStorage)
  • should bundle their own hooks with the upcoming feature
  • components for forms should rely only on form recovery and localstorage to preserve their state (including things like 'is the dropdown open'?)
  • have to be opinionated about styling system. i.e. better to build a 'DaisyUI' component than to try and be truly generic, but standardize on interfaces so someone who builds a Flowbite combobox has the same functional component interface. Could probably share hooks though... now there's an interesting complication!
  • 'open' - in the same sense as core_components. Something doesn't fit with your style? Change the css, or add an option. Need a second variant? Copy and change.
  • functional component + Hooks > function component + webcomponent. Basically, styling is messy with webcomponents. There aren't really good ways to inject styles, so better to use functional components and make the structure open so you just add/remove classes as needed
  • LLMs are very good at writing JS components (Gemini 2.5 specifically), and do pretty well writing hook versions. A good prompt might lower the barrier for creating new variations of components or ones customized to your styling system.
  • we could consider allowing custom phx-* events on functional components. This would make it possible to make more complex widgets, look like real ones to the consumer

tl;dr - we shouldn't try and solve the problem of using live components as reusable components in the global sense. These are for modularity within an application (or maybe across applications within an org).

@josevalim
Copy link
Member

tl;dr - we shouldn't try and solve the problem of using live components as reusable components in the global sense. These are for modularity within an application (or maybe across applications within an org).

Agreed. UI components should be function components which store their state in the client. But we also need to deal with state in the server and we do both within LiveView. So the biggest question to me right now is how to add new abstractions while making sure they play with the abstractions we have today. Although I just realized that, for the URL stuff, we could possibly pass those events as JS.set_params(:counter, @counter + 1) either on phx-click or similar and have the client maybe rewrite that into a href (set_params could even be implemented and useful without any of the other features in this proposal). And it could be a building block for other forms of client state.

@pinetops
Copy link
Author

pinetops commented May 20, 2025

So, the variant I was playing with is that for live components that have internal state, that have a scoped partition of the url ?_dom_id[some_comp_state]=something, then you could basically do updates on the client (it's JSX.assign_state in livex). I think the scoping of url state (or non url state) is important.

And further, I actually have all the 'state' assigns in the component's div tag (I then use js to update the url post patch), so it's actually possible to use css selectors against assigns. And then, if you update those attributes in conjunction with the server state updates you can have updates to assigns make optimistic updates to css. Neat, and it actually kind of works, but not sure it's actually a good idea yet :-)

@andrewtimberlake
Copy link
Contributor

Although I just realized that, for the URL stuff, we could possibly pass those events as JS.set_params(:counter, @counter + 1) either on phx-click or similar and have the client maybe rewrite that into a href (set_params could even be implemented and useful without any of the other features in this proposal). And it could be a building block for other forms of client state.

I’d love to see a way to set params from click and have that deal with the events
I have an implementation I use in my projects described here: https://andrewtimberlake.com/blog/2024/06/how-to-update-url-params-in-phoenix-liveview

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants