-
Notifications
You must be signed in to change notification settings - Fork 1k
Add assign_new/4 that supports conditional assign based on deps #3799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
2125705 to
dd43e3c
Compare
|
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! |
|
Thanks! Enjoy the conference. Due to a bit of bad planning I actually just flew out of Krakow this evening 🤷 |
|
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 assignsThey are great. My suggestion would be to add them as a regular function: Declarative Data DerivationMy 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: You would say that: 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 UpdatesI 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_messageWhat if we allow Enable PubSub for Components (assign_topic)I believe we discussed adding support for components to |
|
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): Any thoughts on that analysis? |
|
Once we introduce My issue with I am not convinced that PS: Theoretically speaking, I think we don't even need |
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.
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.
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:
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.
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? |
|
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:
So, my conclusions:
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. |
Oh yes, you are right, I forgot about the memoization, which means doing it on
I would say
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 |
|
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:
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! |
|
On the main topic:
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?
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:
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.
So putting this in this reframing:
So, it's a bit different than splitting state by what it's driven by, it's dividing the world into:
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. |
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. |
|
My current thinking about reusable components.
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 |
|
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 :-) |
I’d love to see a way to set params from click and have that deal with the events |
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?