Live Components
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeHappy Day 27 of Last Stack! We've accomplished a lot during the first 26 days with just three letters of LAST Stack: Asset Mapper, Stimulus, and Turbo. Today we crack the code on the L of LAST Stack: Live components. Live components let us take a Twig component... then re-render it via Ajax as the user interacts with it.
Our goal is this global search. When I click nothing happens! What I want to do is open a modal with a search box that, as we type, loads a live search.
Opening the Search Modal
Start inside templates/base.html.twig. Search for search! Perfect: this is the fake search input we just saw. Add a <twig:Modal> with :closeButton="true", then a <twig:block> with name="trigger". Put the fake input inside that. To make this open the modal, we need data-action="modal#open":
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true"> | |
| <twig:block name="trigger"> | |
| <div | |
| class="hidden md:flex pr-10 items-center space-x-2 border-2 border-gray-900 rounded-lg p-2 bg-gray-800 text-white cursor-pointer" | |
| data-action="modal#open" | |
| > | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
| <span class="pl-2 pr-10 text-gray-500">Search Cmd+K</span> | |
| </div> | |
| </twig:block> | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 45 - 54 | |
| </div> | |
| // ... lines 56 - 84 | |
| </body> | |
| </html> |
Cool! If we refresh, nothing changes: the only visible part of the modal is the trigger. For the modal content, after the Twig block, I'll paste in a div:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true"> | |
| <twig:block name="trigger"> | |
| <div | |
| class="hidden md:flex pr-10 items-center space-x-2 border-2 border-gray-900 rounded-lg p-2 bg-gray-800 text-white cursor-pointer" | |
| data-action="modal#open" | |
| > | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
| <span class="pl-2 pr-10 text-gray-500">Search Cmd+K</span> | |
| </div> | |
| </twig:block> | |
| <div class="relative"> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
| </div> | |
| <input | |
| type="search" | |
| aria-label="Search site" | |
| placeholder="Search for anything" | |
| class="px-4 py-2 pl-10 rounded bg-gray-800 text-white placeholder-gray-400 w-full outline-none" | |
| /> | |
| </div> | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 57 - 66 | |
| </div> | |
| // ... lines 68 - 96 | |
| </body> | |
| </html> |
Nothing special here: just a real search input.
Back at the browser, when I click... uh oh. Nothing happens! Debugging always starts in the console. No errors, but when I click, watch: there's no log that says that the action is being triggered. We've got something wrong with that and maybe you saw my mistake? We added the data-action to a div. Unlike a button or a form, Stimulus doesn't have a default event for a div. Add click->:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true"> | |
| <twig:block name="trigger"> | |
| <div | |
| // ... line 35 | |
| data-action="click->modal#open" | |
| > | |
| // ... lines 38 - 39 | |
| </div> | |
| </twig:block> | |
| // ... lines 42 - 53 | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 57 - 66 | |
| </div> | |
| // ... lines 68 - 96 | |
| </body> | |
| </html> |
And now... got it!
Oh, and it auto-focused the input! That's.... just a feature of dialogs! They work like a mini page within a page: it autofocuses the first tabbable element... or you can use the normal autofocus attribute for more control. It just works how you want it to.
Modal: Control the Padding
Anyway, I'm picky: this is more padding than I want. But that's ok! We can make our Modal component just a bit more flexible. In components/Modal.html.twig, the extra padding is this p-5. On top, add a third prop: padding='p-5'. Copy that. And down here, render padding:
| {% props allowSmallWidth=false, closeButton=false, padding="p-5" %} | |
| <div | |
| // ... lines 3 - 6 | |
| > | |
| // ... lines 8 - 9 | |
| <dialog | |
| // ... lines 11 - 13 | |
| > | |
| <div class="flex grow {{ padding }}"> | |
| // ... lines 16 - 18 | |
| </div> | |
| // ... lines 20 - 28 | |
| </dialog> | |
| // ... lines 30 - 35 | |
| </div> |
Over in base.html.twig, on the modal, add padding equals empty quotes:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true" padding=""> | |
| // ... lines 33 - 53 | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 57 - 66 | |
| </div> | |
| // ... lines 68 - 96 | |
| </body> | |
| </html> |
Let's check it! And... much neater.
Creating the Twig Component
To bring the results to life, we could repeat the data-tables setup from the homepage. We could add a <turbo-frame> with the results right here and make the input autosubmit into that frame.
Another option is to build this with a live component. But before we talk about that, let's first organize the modal contents into a twig component.
In templates/components/, create a new file called SearchSite.html.twig. I'll add a div with {{ attributes }}. Then go steal the entire body of the modal, and paste it here:
| <div {{ attributes }}> | |
| <div class="relative"> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-500" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M21 21l-6 -6"/></svg> | |
| </div> | |
| <input | |
| type="search" | |
| aria-label="Search site" | |
| placeholder="Search for anything" | |
| class="px-4 py-2 pl-10 rounded bg-gray-800 text-white placeholder-gray-400 w-full outline-none" | |
| /> | |
| </div> | |
| </div> |
Over in base.html.twig, it's easy, right? <twig:SearchSite /> and done:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true" padding=""> | |
| <twig:block name="trigger"> | |
| // ... lines 34 - 40 | |
| </twig:block> | |
| <twig:SearchSite /> | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 47 - 56 | |
| </div> | |
| // ... lines 58 - 86 | |
| </body> | |
| </html> |
At the browser, we get the same result.
Fetching Data with a Twig Component
The site search is really going to be a voyage search. To render the results, we have two options. First, we could... somehow get the voyages that we want to show inside of base.html.twig and pass them into SearchSite as a prop. But... fetching data from our base layout is tricky... we'd probably need a custom Twig function.
The second option is to leverage our Twig component! One of its superpowers is the ability to fetch its own data: to be standalone.
To do that, this Twig component now needs a PHP class. In src/Twig/Components/, create a new PHP class called SearchSite. The only thing that this needs to be recognized as a Twig component is an attribute: #[AsTwigComponent]:
| // ... lines 1 - 2 | |
| namespace App\Twig\Components; | |
| // ... lines 4 - 6 | |
| use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; | |
| class SearchSite | |
| { | |
| // ... lines 12 - 22 | |
| } |
This is exactly what we saw inside the Button class. A few days ago, I quickly mentioned that Twig component classes are services, which means we can autowire other services like VoyageRepository, $voyageRepository:
| // ... lines 1 - 5 | |
| use App\Repository\VoyageRepository; | |
| // ... lines 7 - 8 | |
| class SearchSite | |
| { | |
| public function __construct(private VoyageRepository $voyageRepository) | |
| { | |
| } | |
| // ... lines 15 - 22 | |
| } |
To provide the data to the template, create a new method called voyages()! This will return an array... which will really be an array of Voyage[]. Inside return $this->voyageRepository->findBySearch(). That's the same method we're using on the homepage. Pass null, an empty array, and limit to 10 results:
| // ... lines 1 - 4 | |
| use App\Entity\Voyage; | |
| // ... lines 6 - 8 | |
| class SearchSite | |
| { | |
| // ... lines 12 - 15 | |
| /** | |
| * @return Voyage[] | |
| */ | |
| public function voyages(): array | |
| { | |
| return $this->voyageRepository->findBySearch(null, [], 10); | |
| } | |
| } |
The search query isn't dynamic yet, but we do now have a voyages() method that we can use in the template. I'll start with a bit of styling, then it's normal twig code: {% for voyage in this - that's our component object - .voyages. Add endfor, and in the middle, I'll paste that in:
| <div {{ attributes }}> | |
| // ... lines 2 - 13 | |
| <div class="text-white py-2 rounded-lg"> | |
| {% for voyage in this.voyages %} | |
| <a href="{{ path('app_voyage_show', { id: voyage.id }) }}" class="flex items-center space-x-4 px-4 p-2 hover:bg-gray-700 cursor-pointer"> | |
| <img | |
| class="h-10 w-10 rounded-full" | |
| src="{{ asset('images/'~voyage.planet.imageFilename) }}" | |
| alt="{{ voyage.planet.name }} planet" | |
| > | |
| <div> | |
| <p class="text-sm font-medium text-white">{{ voyage.purpose }}</p> | |
| <p class="text-xs text-gray-400">{{ voyage.leaveAt|ago }}</p> | |
| </div> | |
| </a> | |
| {% endfor %} | |
| </div> | |
| </div> |
Nothing special: an anchor tag, an image tag, and some info.
Let's try it. Open! Sweet! Though, of course, when we type, nothing updates! Lame!
Installing & Upgrading to a LiveComponent
This is where live components comes in handy. So let's get it installed!
composer require "symfony/ux-live-component:^2.0"
To upgrade our Twig component to a Live component, we only need to do two things. First, it's #[AsLiveComponent]. And second, use DefaultActionTrait:
| // ... lines 1 - 6 | |
| use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; | |
| use Symfony\UX\LiveComponent\DefaultActionTrait; | |
| class SearchSite | |
| { | |
| use DefaultActionTrait; | |
| // ... lines 14 - 25 | |
| } |
That's an internal detail... but needed.
So far, nothing will change. It's still a Twig component... and we haven't added any live component superpowers.
Adding a Writable Prop
One of the key concepts with a Live Component is that you can add a property and allow the user to change that property from the frontend. For example, create a public string $query to represent the search string:
| // ... lines 1 - 10 | |
| class SearchSite | |
| { | |
| // ... lines 14 - 16 | |
| public string $query = ''; | |
| // ... lines 18 - 29 | |
| } |
Below, use that when we call the repository:
| // ... lines 1 - 10 | |
| class SearchSite | |
| { | |
| // ... lines 14 - 16 | |
| public string $query = ''; | |
| // ... lines 18 - 25 | |
| public function voyages(): array | |
| { | |
| return $this->voyageRepository->findBySearch($this->query, [], 10); | |
| } | |
| } |
To allow the user to modify this property, we need to give it an attribute: #[LiveProp] with writeable: true:
| // ... lines 1 - 7 | |
| use Symfony\UX\LiveComponent\Attribute\LiveProp; | |
| // ... lines 9 - 10 | |
| class SearchSite | |
| { | |
| // ... lines 14 - 15 | |
| (writable: true) | |
| public string $query = ''; | |
| // ... lines 18 - 29 | |
| } |
Finally, to bind this property to the input - so that the query property changes as the user types - add data-model="query":
| <div {{ attributes }}> | |
| <div class="relative"> | |
| // ... lines 3 - 5 | |
| <input | |
| type="search" | |
| data-model="query" | |
| // ... lines 9 - 11 | |
| /> | |
| </div> | |
| // ... lines 14 - 30 | |
| </div> |
That's it! Check out the result. We start with everything, but when we type... it filters! It even has built-in debouncing.
Backstage, it makes an AJAX request, populates the query property with this string, re-renders the Twig template and pops it right here.
Now that this is working, I don't think we need to load all the results at first. And, look, it's just PHP, so this is easy. If not $this->query, then return an empty array:
| // ... lines 1 - 10 | |
| class SearchSite | |
| { | |
| // ... lines 14 - 25 | |
| public function voyages(): array | |
| { | |
| if (!$this->query) { | |
| return []; | |
| } | |
| // ... lines 31 - 32 | |
| } | |
| } |
And in SearchSite.html.twig, add an if statement around this: if this.voyages is not empty, render that... with the endif at the bottom:
| <div {{ attributes }}> | |
| // ... lines 2 - 14 | |
| {% if this.voyages is not empty %} | |
| <div class="text-white py-2 rounded-lg"> | |
| {% for voyage in this.voyages %} | |
| // ... lines 18 - 29 | |
| {% endfor %} | |
| </div> | |
| {% endif %} | |
| </div> |
For those of you that are sticklers for details, yes, with this.voyages, we're calling the method twice. But there are ways around this - and my favorite is called #[ExposeInTemplate]. I won't show it, but it's a quick change.
Fixing the Modal to the Top
So, I'm happy! But, this isn't perfect... and I want that. One thing that bothers me is the position: it looks low when it's empty. And as we type, it jumps around. That's the native <dialog> positioning, which is normally great, but not when our content is changing. So in this one case, let's fix the position near the top.
In Modal.html.twig, add one last piece of flexibility to our component: a prop called fixedTop = false:
| {% props | |
| // ... lines 2 - 3 | |
| padding="p-5", | |
| fixedTop=false | |
| %} | |
| // ... lines 7 - 42 |
Then, at the end of the dialog classes, if fixedTop, render mt-14 to set the top margin. Else do nothing:
| // ... lines 1 - 6 | |
| <div | |
| // ... lines 8 - 11 | |
| > | |
| // ... lines 13 - 14 | |
| <dialog | |
| class="open:flex bg-gray-800 rounded-lg shadow-xl inset-0 w-full md:w-fit md:max-w-[50%] {{ allowSmallWidth ? '' : 'md:min-w-[50%] ' }}animate-fade-in backdrop:bg-slate-600 backdrop:opacity-80{{ fixedTop ? ' mt-14' : '' }}" | |
| // ... lines 17 - 18 | |
| > | |
| // ... lines 20 - 33 | |
| </dialog> | |
| // ... lines 35 - 40 | |
| </div> |
Over in base.html.twig, on the modal... it's time to break this onto multiple lines. Then pass :fixedTop="true":
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true" padding="" :fixedTop="true"> | |
| // ... lines 33 - 43 | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 47 - 56 | |
| </div> | |
| // ... lines 58 - 86 | |
| </body> | |
| </html> |
And now, ah. Much nicer and no more jumping around.
Setting the Search as Turbo Permanent
What else? Pressing up and down on my keyboard to go through the results is needed, though I'll save that for another time. But watch this. If I search, then click out and navigate to another page, not surprisingly, when we open the search modal, it's empty. It would be really cool if it remembered the search.
And we can do that with a trick from Turbo. In base.html.twig, on the modal, add data-turbo-permanent:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal :closeButton="true" padding="" :fixedTop="true" data-turbo-permanent id="global-search-modal"> | |
| // ... lines 33 - 43 | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 47 - 56 | |
| </div> | |
| // ... lines 58 - 86 | |
| </body> | |
| </html> |
That tells Turbo to keep this on the page when it navigates. When you use this, it needs an id.
Let's see how this feels. Open the search, type something, click off, go to the homepage and open it again. So darn cool!
Opening Search on Ctrl+K
Ok, final thing! Up here, I'm advertising that you open the search with a keyboard shortcut. That's a lie! But we can add this... and, again, it's easy.
On the modal, add a data-action. Stimulus has built-in support for doing things on keydown. So we can say keydown., then whatever key we want, like K. Or in this case, Ctrl+K.
If we stopped now, this would only trigger if the modal were focused and then someone pressed Ctrl+K. That's... not going to happen. Instead, we want this to open no matter what is focused. We want a global listener. Do that by adding @window.
Copy that, add a space, paste and also trigger on meta+k. Meta is the command key on a Mac:
| <html> | |
| // ... lines 3 - 15 | |
| <body class="bg-black text-white font-mono"> | |
| <div class="container mx-auto min-h-screen flex flex-col"> | |
| <header class="my-8 px-4"> | |
| <nav class="flex items-center justify-between mb-4"> | |
| // ... lines 20 - 31 | |
| <twig:Modal | |
| // ... lines 33 - 37 | |
| data-action="keydown.meta+k@window->modal#open keydown.ctrl+k@window->modal#open" | |
| > | |
| // ... lines 40 - 50 | |
| </twig:Modal> | |
| </nav> | |
| </header> | |
| // ... lines 54 - 63 | |
| </div> | |
| // ... lines 65 - 93 | |
| </body> | |
| </html> |
Testing time! I'll move over and... keyboard! I love it! Done!
Lazy-Loading Live Component
Oh, and Live Components can also be loaded lazily via AJAX! Watch: add a defer attribute. When we refresh, we won't see any difference... because that component is hidden on page load anyway. But in reality, it just loaded empty then immediately made an Ajax call to load for real. We can see that down here in the web debug toolbar! This is a great way to defer loading something heavy, so it doesn't slow down your page.
It's not particularly useful in our case because the SearchSite component is so lightweight, so I'll remove it.
Tomorrow, we'll spend one more day with Live Components - this time to give a form real-time-validation superpowers and solve the age-old pesky problem of dynamic or dependent form fields.
40 Comments
I got some weird behaviours when using
keydown.ctrl+k@window->modal#open keydown.meta+k@window->modal#openin Ubuntu+ChromeWhen I press CTRL+k, it opens the search modal and Chrome search bar.
When I press META+k (I suppose it also works with the Windows key), it opens the search modal with a letter k already filled in. Changing to other letters happens the same and triggers the Ubuntu key binding. For instance, META+l would lock the session
I added
:preventto both of them to callpreventDefault()to work correctlykeydown.ctrl+k@window->modal#open:prevent keydown.meta+k@window->modal#open:preventhttps://stimulus.hotwired.dev/reference/actions#options
Hey Rcapile,
So did that
:preventhelp you? It seems like you have some global shortcuts for that combination. The other solution would be to change to a key that isn't reserved in your system, but if with:preventit works - even better :) Thanks for sharing this with others!Cheers!
Hello. Long time without an answer for this topic, so a quick one to clarify it :)
I made test with :prevent, and it works well.
Hey Shaan,
Awesome, thanks for confirming it works for you :)
Cheers!
Hi, is there a way to use PagerFanta in live components?
I made a live component witch show a list of items with getItems method.
I have a liveProp $query to filter the list.
But if there are too many items the list will be too long. So i would like to use PagerFanta, but i can't figure out how to use it in a livecomponent.
Hey Gbd,
It should be possible, why not? :) We were talking about Pagerfanta in this tutorial, in this specific chapter: https://symfonycasts.com/screencast/last-stack/pager-sorting - that's how to use Pagerfanta, or you can search more related videos about it on SymfonyCasts: https://symfonycasts.com/search?q=pagerfanta&sort=most relevant&page=1&types=video . Or just read their official docs.
So, in theory, you just need to inject Pagerfanta and Repository in your Live Component. Instead of getting an error of entities you need to return a query builder object from the repository, and you will need to pass that query builder to the Pagerfanta with some other details like page, limit, etc. And Pagerfanta will do the rest for you getting you th limited result.
I hope that helps!
Cheers!
Ok, Pagerfanta will prepare a list of links (like <a href=...&page=2... >) but can normal links be used in Live Component?
I tried and it throws me a MethodNotAllowedHttpException. Do I need to define something in Live Component, or convert links from Pagefranta to some butons/actions?
Hey Nataniel,
There's some misunderstanding I suppose. The MethodNotAllowedHttpException says that the controller that is trying to handle your request does not support the request method, and the solution may depend on the details. If the problem with your custom route - you either should match the method of the request your sending, see https://symfony.com/doc/current/routing.html#matching-http-methods - or send the request with the method that's required by that route.
I hope that helps
Cheers!
So I added a pagefrante to the example with LiveComponent as below:
after these changes, clicking on the page number triggers a download (?😯?) instead of switching to the next page.
Thanks Victor!
Yes, in PageFranata the links are the same as the one which goes from/to the LiveComponent Ajax, but (obviously) GET not POST.
So I now have the idea of replacing the hrefs with actions or somehow replacing all the default PageFranta elements with buttons.
I'm not sure I know how to do this yet though. But I'll try.
I have something like this, but is it the best idea?
And, um, how do I apply this to a full widget?
Hey @Nataniel-Z ,
So you create your own Next/Previous buttons and tied that to the live component - it should work great I think, give it a try. Thankfully, Pagerfanta has some useful methods that you can leverage to see if you need to show Next/Previous.
Are you talking about Symfony Form widget? Yeah, that might be trickier, probably better to keep the current implementation in the template for simplification, but if you want to do that for a form widget - you would need to override the button widget ofthe default form theme (or form theme you want to base on), and use that new custom form theme with overridden widget. And since it's not that easy to pass the data into form widget - you would probably need to code a workaround on the things you have, for example base on some special attributes you're passing in the widget.
I hope that helps!
Cheers!
Great, thank you very much for the confirmation - that's what I thought.
Symfony simplifies so many things that it's sometimes easy to forget that you have to add more yourself :)
Haha, yeah :D
I'm happy to see you nailed that case and it works for you now :)
Cheers!
Hey @Victor,
I hope you can help me:
Live Components: Normal (non-LiveProp) props lose values after re-render
Everything works on the first render, but as soon as I trigger a live action (like handleZoom or handleMaximization), the component re-renders — and all normal props ($url, $title, $alt) lose their values (they become null).
This makes it difficult to mix normal props (for static context data) with live behavior (like toggling booleans) — since the static data disappears after any LiveAction.
Is this the expected behavior, or am I missing a recommended pattern for handling static props that shouldn’t change but need to persist after live re-renders?
Thanks a lot — this feature is super powerful, and I just want to make sure I’m following best practices!
Best wishes,
Zerewan
Hey Zerewan,
Yes, that’s expected behavior - you’ve run into one of the key gotchas of Symfony UX Live Components :)
Non-LiveProps are not preserved between re-renders, they are only passed on the initial render from Twig. After a LiveAction or re-render, LiveComponent restores only
#[LiveProp]properties from the frontend payload. So yes, you need to mark those fields as#[LiveProp]too to persist values between requests.Cheers!
Hi i'm a bit confused now whats the diffrence between LiveComponents, TwigComponents, Turbo Frames/ Stream and Stimulus Controller or where are the benefits over one or the other??
For me they have too much similarities and i have a hard time deciding what to take (e.g. for a modal, for a table to reload after an action etc).
Hey @michitheonlyone ,
Yeah, it's easy to get lost in the beginning with that. :) I think watching this course from beginning to end should explain the difference well, but in case you missed it, let's recap one more time.
LiveComponents is mostly the same as TwigComponents but with dynamic JS actions because they work with some JS code that makes it possible to update content dynamically without a full page refresh. If we only talk about TwigComponents - they are not dynamic, i.e. no helpful JS code, so with them, we only talking about full page refresh.
So TwigComponents' purpose is a simple way to encapsulate reusable UI logic with Twig templates. While LiveComponents' (Symfony UX) purpose is to combine server-side rendering with dynamic client-side updates that help to avoid full page refresh out of the box. I.e. LiveComponents already have some built-in JS code that helps to achieve it. The only different between them in PHP code is that you add
#[AsLiveComponent]to the TwigComponent to make it LiveComponent - so easy! That's why most of the time people use LiveComponents instead of TwigComponents because they give you more advantages with JS behaviour. That's why, unless you don't need dynamic JS / UX improvements, just always use LiveComponents instead of TwigCompnents. And the benefit for LiveComponents is that all the JS is already written for you by Symfony, you just leverage it by using special syntax in the template. And yes, LiveComponents uses Stimulus controllers behind the scene, but that's already a thord-party written code, you just use it with 0 need to write your own JS code.But if you need more custom control over JS code, i.e. when you want (or need) to write a custom JS code - that's where Stimulus controllers come into action. And we have several courses about Stimulus to see how to write them and where those are useful, so I would recommend you to check them out: https://symfonycasts.com/screencast/stimulus
Basically, with Stimulus you can write your own JS code but in Stimulus way, and because you're completely responsible for writing the JS code - there's basically no limitation, you can do whatever you want with it. And finally Turbo Frames / Streams is another tool that allows you to move your application into the next level: give it a single page application feel, i.e. when you click links load the new pages in the background using AJAX requests and dynamically replace the content. Once again, to know what you can do with it - I would recommend you to watch a separate course where it all is explained well: https://symfonycasts.com/screencast/turbo
I hope this clarifies things for you well now. But mostly, watching more related tutorials should explain it even better where and how you can leverage those tools.
Cheers!
Hi Victor
Thank you very much! You cleared out the picture for me. As always in programming: - There isn't just one way of doing it ;-)
Hey @michitheonlyone ,
Indeed, there are a lot of ways of doing things in programming... but Michi is the only one :p
I'm glad to hear it helped you ;)
Cheers!
Hi Victor it's me again ;-) i'm still a little bit confused as i'm playing around LiveComponents and Turbo Streams i sense some similarities there can you explain what is the benefit of using one over the other or when to use what? My scenario: i have 2 entity tables side by side (or in 2 tabs) with a modal form for add and edit as well as independent sorting and pagination (pagerfanta) is it better to use a LiveComponent (for each entity list) structure or rather turbo streams/ frames for each?
btw the sorting and pagination state should be stored in the url and i'm still not sure how to do this
Hey @michitheonlyone ,
If you can do something with LiveComponents - you should go with them :) The feature of LiveComponents is that they have already written JS for you, so you don't need to write any JS code with them, you just leverage the JS code written by the contributors. From the PHP dev point of view, it should sounds more familiar and easier. You just write PHP code and LiveComponents core do the JS work for you.
With the Turbo Streams - that's something that allow you to dynamically update the page with some response.
In short, there might be some overlaps, but they have slightly different approaches. IMO in your case better to use LiveComponents as you probably want to do apply some changes to the table data on a specific event, e.g. sorting, paginating, etc. But for simple operations like adding a new row you just added to the table - it might be also possible to use Turbo Streams. But to be consistent and simplify the code, I think you can just re-render the same template with LiveTemplates when you added a new entry, and the table will reflect the change.
Yeah, for pagination and sorting it's recommended to us query parameters, but I'm not sure you can do it easily with LiveComponents. Unfortunately, I haven't done it myself yet, but you can find some docs about it here: https://symfony.com/bundles/ux-live-component/current/index.html#changing-the-url-when-a-liveprop-changes
I hope this helps!
Cheers!
Sorry if this question is not related to the current lesson but isn't strange that
id="planet-card-{{ voyage-planet-id }}" is present in two files,homepage.html.twigand_card.html.twig?I've just checked in "finish" directory.
The problem is that in homepage there are rfew epeated ID's and this is not good plus I get the error :"Uncaught (in promise) Error: The response (200) did not contain the expected <turbo-frame id="planet-card-1"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload."
Maybe it is my mistake but the problem disappear if I change ID name in
home.page.html.twigHey @Fedale
Do you have repeated IDs in your database? Or what do you mean?
When you want to refresh the content of a
turbo-frameyou need to return anotherturbo-frameelement in your HTML response with the same ID attribute as the one you want to refresh/replace. So, having twoturbo-frameswith the same ID in different files it's okCheers!
Hey Ryan,
I have a Live Component that includes a form within a modal. This form submits to a save method within the Live Component. On my page, I have two columns:
The first column contains a Live Component used for searching through different Categories, Subcategories, and Groups to find an Item. When an Item is selected, it opens a modal, which is another Live Component.
The second column displays everything that has been added to the database from the modal. This second column is wrapped in a Turbo Frame.
The challenge I'm facing is that the original Live Component in column one is loaded in a Turbo Frame. When there is a form success from a modal, it doesn't remember the contents of that original Turbo Frame and empties it. If I remove the Live Component in column one from the Turbo Frame, it retains its state and remembers the previously selected Category, Subcategory, and Group.
I am trying to find a way to return a specific Turbo Frame update in my LiveComponent save method to only refresh the relevant part of the page, thus keeping the Turbo Frame in column one in its original state. I initially thought I could achieve this with Turbo Streams, but it seems that the approach you taught for a normal Symfony controller doesn't work directly within a LiveComponent. How can I address this issue and selectively update Turbo Frames within a Live Component?
Hey @Brandon!
Wow, this is super interesting! Though, tbh, despite your detailed description, it's complex enough that I don't have my mind wrapped around everything.
However, might this be what you're looking for? https://symfonycasts.com/blog/redirect-turbo-frame
Basically:
In
save()of yourLiveAction, instead of redirecting (which you are maybe or maybe not doing), allow the template to re-render, but with a<turbo-stream>inside that targets the frame you want and replaces it with a new version of itself with the correct URL.Let me know if that helps. Or if not, you can give even more info and I'll do my best :).
Cheers!
Ryan,
I read through the blog post and it seems close but I want to explain my situation a little more. LiveComponent #1 located in a Turbo Frame in column 1, lets you select a Category, Subcategory, and Group to find a specific item. Next to the item is a button that when clicked opens a modal which is LiveComponent #2. A lot happens with calculations in this LiveComponent, so I decided to make a 'save' method in the LiveComponent rather than doing it in a controller. So after the save method, I currently am redirecting, back to the 'index' page that houses LiveComponent #1 and a second Turbo Frame that displays every item that has been added. After success on the save method, I only really want to have the second Turbo Frame update, or refresh or replace, but yes, this is what the end of my save method looks like currently:
I have been kind of struggling with what to return, and it seems like I've tried everything. So with what the blog post says, I would add
To what page, #1 LiveComponent doesn't seem right as it is only a dynamic form for filtering to a certain item, #2 LiveComponent that has the modal and where the save method is called seems right, but it doesn't really have direct access to the page that has the Turbo Frame in column 2 that I want updated though, or would it go on the index page that contains the two Turbo Frames, and where LiveComponent #1 is called?
Hey @Brandon!
I think I understand! So let's make a few assumptions:
A) Let's assume that
takeoff_circuitis the route for the main page you're loading. Like, if the user typed the URL to this route in their browser, it would load the whole page - the page with the multiple live components and turbo frames.B) Let's also assume that the 2nd turbo frame - the one that shows the results - looks like this:
So, when #2 LiveComponent saves (the modal), yes, I'd include something like this in the template of your live component:
Notice the
srcistakeoff_circuit. The flow will be:A) The existing
turbo-frameis removed from the page, and this new one is addedB) Because it has a
src, it will make an AJAX request totakeoff_circuit. That will return the entire page, but Turbo will only grab the content from theitem-listframe on that page, then put it onto the new page.The only weird thing will be that the
item-listframe will be emptied for just a moment. I can think of 2 ideas to deal with this:1) Perhaps you don't need a
turbo-framefor theitem-listat all. Perhaps you can just return a stream from your #2 live component that updates this element with the new content - something like:Where this template is a partial that renders the
<div id="item-list">and has theforinside to actually render the content.2) If you do need a frame, you could also create a custom stream action called, for example,
refresh-frame. Then, with the JS you use for that custom stream action, you find the target<turbo-frame>element and set itssrcattribute to thetakeoff_circuitURL and also call.refresh()on it if it's already set - references https://turbo.hotwired.dev/reference/frames#functions and https://marcoroth.dev/posts/guide-to-custom-turbo-stream-actionsCheers!
Ryan,
This is awesome thank you for the reply, I appreciate all the hard work. The last few days I decided to see if I could make the entire page into one big LiveComponent, and I was successful, so now I'm not using Frames or Streams for this. I do have some repeated code which I don't like, but I love LiveComponents. I'll give your suggestion a try and see if I can eliminate my repeated code. I know you touched on LiveComponents with 30 Days of LAST stack, but I'd love to see an entire tutorial on them showing all of the different features.
Hey @Brandon!
Cool!
Live Components had its first stable release (checks watch) yesterday :). That's what I was waiting for before considering a tutorial. It's in the works!
Cheers!
Hello, following the course, from what you see, voyages is placed inside the anonymous component twig, maybe i miss something but i dont know
Neither the property "voyages" nor one of the methods "voyages()", "getvoyages()"/"isvoyages()"/"hasvoyages()" or "__call()" exist and have public access in class "Symfony\UX\TwigComponent\AnonymousComponent".<br />fixed, finaly i create my twigcomponent with the comand make:twig-component with select AsLiveComponent, also i run debug:twig-component to see how to look the component
Hey @sansxd!
Glad you figured it out - and sorry for the slow reply! In this tutorial, I wasn't using an anonymous component, but rather one backed with a class - https://symfonycasts.com/screencast/last-stack/live-components#codeblock-f055aa7e33 - and that is what you probably got when you used
make:twig-component:).Cheers!
Hi, I'm using a Live Component with a Live Action that ends with
return $this->redirectToRoute('my-route');Is there a way to load this new page (only the changing part) in a turbo-frame ?
Thanks for your answer
Cyril
Hey @Cyril
yes, the response just needs to return HTML wrapped in a turbo-frame element with the same ID as the one you want to replace
Cheers!
I tried but it doesn’t work. The live component is in the turbo-frame, the live action is redirecting to the same route (reloading the same page via the controller). So the turbo-frame element is in the returned HTML. But the entire page is reloaded from scratch, not only the changes in the turbo-frame. I tried to had a specific header Turbo-Frame in the redirectToRoute but it doen’t work…
Hey @Cyril!
That's an interesting situation! Internally, when you redirect from
LiveAction, LiveComponents sees that and checks to see ifTurbois installed. If it is, it does aTurbo.visit()instead of a real, full site redirect.So that works great. But you want something different: not a page navigation, but a frame navigation. I'm not sure that it makes sense to add this feature specifically to LiveComponents, but there is a solution:
A) In your
LiveAction, instead of redirecting, set some flag (i.e. property on your class) that indicates that you're in this "success" situation.B) In your template, anywhere inside your root component element, add something like this:
That should do it! When this
turbo-streampops onto the page, it'll replace your existingturbo-framewith this new one. That new element will instantly activate, see itssrcattribute, and make an Ajax request to fetch that page. It'll then use its normal behavior of only loading the matching frame from that page into the frame.Let me know if this works! I love combining these tools :)
Cheers!
Thanks Ryan! Your solution is working well but, you're right :
My mistake! I was wrong in thinking that normal redirection didn't work... Looking at the Profiler, I can see that only Ajax requests are made.
My problem seems to be somewhere else: after the LiveAction, the page scrolls up at the top. That's why I thought it was fully recharging!
Usually, Turbo reloads the page without any move, which gives a nice impression of fluidity. Here, it doesn't have this behavior.
Your solution works the same way: the page scrolls at the top :-(
Any idea?
Hey @Cyril!
Ah! So I can explain this:
With a normal Turbo navigation (i.e. NOT inside a
<turbo-frame>) Turbo actually DOES always scroll to the top. To prevent that, you need to be navigating in a frame. In Turbo 8 (released today, so I haven't played with it yet), you may be able to preserve the scroll position by adding:to your page. Note: this will only work for what Turbo 8 calls a "page refresh": when the navigation you're going to is exactly the same as the current URL.
Cheers!
Ok, I'll try with turbo 8. Thank you very much for taking the time to reply to me after your live stream ;-)
Cyril
"Houston: no signs of life"
Start the conversation!