Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Forever Scroll with Turbo Frames

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

You've made it to the final chapter of the Doctrine tutorial! This chapter is... a total bonus. Instead of talking about Doctrine, we're going to leverage some JavaScript to turn this page into a "forever scroll". But don't worry! We'll talk more about Doctrine in the next tutorial when we cover Doctrine Relations.

Here's the goal: instead of pagination links, I want this page to load nine results like we see on Page 1. Then, when we scroll to the bottom, I want to make an AJAX request to show the next nine results, and so on. The result is a "forever scroll".

In the first tutorial in this series, we installed a library called Symfony UX Turbo, which enabled a JavaScript package called Turbo. Turbo turns all of our link clicks and form submits into AJAX calls, giving us a really nice single page app-like experience without doing anything special.

Whelp, as cool as that is, Turbo has two other, optional superpowers: Turbo Frames and Turbo Streams. You can learn all about these in our Turbo tutorial. But let's get a quick sample of how we could leverage Turbo Frames to add forever scroll without writing a single line of JavaScript.

turbo-frame Basics!

Frames work by dividing parts of your page into separate turbo-frame elements, which acts a lot like an iframe... if you're old enough to remember those. When you surround something in a <turbo-frame>, any clicks inside of that frame will only navigate that one frame.

For example, open the template for this page - templates/vinyl/browse.html.twig - and scroll up to where we have our for loop. Add a new turbo-frame element right here. The only rule of a Turbo Frame is that it needs to have a unique ID. So say id="mix-browse-list", and then go all the way to the end of that row and paste the closing tag. And, just for my own sanity, I'm going to indent that row.

... lines 1 - 2
{% block body %}
... lines 4 - 27
<turbo-frame id="mix-browse-list">
<div class="row">
{% for mix in pager %}
... lines 31 - 45
{% endfor %}
... lines 47 - 48
</div>
</turbo-frame>
... lines 51 - 52
{% endblock %}

Okay, so... what does that do? If you refresh the page now, any navigation inside of this frame stays inside the frame. Watch! If I click "2"... that worked. It made an AJAX request for Page 2, our app returned that full HTML page - including the header, footer and all - but then Turbo Frame found the matching mix-browse-list <turbo-frame> inside of that, grabbed its contents, and put it here.

And though it's not easy to see in this example, the only part of the page that's changing is this <turbo-frame> element. If I... say... messed with the title up here on my page, and then click down here and back to Page 2... that did not update that part of the page. Again, it works a lot like iframes, but without the weirdness. You could imagine using this, for example, to power an "Edit" button that adds inline editing.

But in our situation, this isn't very useful yet... because it works pretty much the same as before: we click the link, we see new results. The only difference is that clicking inside a <turbo-frame> didn't change the URL. So no matter what page I'm on, if I refresh, I'm transported right back to Page 1. So this was kind of a step backwards!

But stick with me. I have a solution, but it involves a few pieces. To start, I'm going to make the ID unique to the current page. Add a -, and then we can say pager.currentPage.

... lines 1 - 27
<turbo-frame id="mix-browse-list-{{ pager.currentPage }}">
... lines 29 - 49
</turbo-frame>
... lines 51 - 54

Next, down at the bottom, remove the Pagerfanta links and replace them with another Turbo Frame. Say {% if pager.hasNextPage %}, and inside of it, add a turbo-frame, just like above, with that same id="mix-browse-list-{{ }}". But this time, say pager.nextPage. Let me break this onto multiple lines here... and then we're also going to tell it what src to use for that. Oh, let me fix my typo... and then use another Pagerfanta helper called pagerfanta_page_url and pass that pager and then pager.nextPage. Finally, add loading="lazy".

... lines 1 - 27
<turbo-frame id="mix-browse-list-{{ pager.currentPage }}">
<div class="row">
... lines 30 - 47
{% if pager.hasNextPage %}
<turbo-frame id="mix-browse-list-{{ pager.nextPage }}" src="{{ pagerfanta_page_url(pager, pager.nextPage) }}" loading="lazy"></turbo-frame>
{% endif %}
</div>
</turbo-frame>
... lines 53 - 56

Woh! Lemme explain, because this is kind of wild. First, one of the super-powers of a <turbo-frame> is that you can give it a src attribute and then leave it empty. This tells Turbo:

Hey! I'm going to be lazy and start this element empty... maybe because it's a little heavy to load. But as soon as this element becomes visible to the user, make an Ajax request to this URL to get its contents.

So, this <turbo-frame> will start empty... but as soon as we scroll down to it, Turbo will make an AJAX request for the next page of results.

For example, if this frame is loading for page 2, the Ajax response will contain a <turbo-frame> with id="mix-browse-list-2". The Turbo Frame system will grab that from the Ajax response and put it here at the bottom of our list. And if there's a page 3, that will include yet another Turbo Frame down here that will point to Page 3.

This all might seem a bit crazy, so let's try this out. I'm going to scroll up to the top of the page, refresh and... perfect! Now scroll down here and watch. You should see an AJAX request show up in the web debug toolbar. As we scroll... down here... ah! There's the AJAX request! Scroll down again and... there's a second AJAX request: one for Page 2 and one for Page 3. If we keep scrolling, we run out of results and reach the bottom of the page.

If you're new to Turbo Frames, that concept may have been a little confusing, but you can learn more on our Turbo tutorial. And a shout-out to an AppSignal blog post that introduced this cool idea.

All right, team! Congrats on finishing the Doctrine course! I hope you're feeling powerful. You should be! The only major missing part of Doctrine now is Doctrine Relations: being able to associate one entity to another through relationships, like many-to-one and many-to-many. We'll cover all of that in the next tutorial. Until then, if you have any questions or have a great riddle you want to ask us, we're here for you in the comments section. Thanks a lot, friends! And see you next time!

Leave a comment!

10
Login or Register to join the conversation

Woah that trick is EPIC! Love it!

Great course once again!

Good Job @symfonycasts and @ryanweaver

1 Reply
excentrist Avatar
excentrist Avatar excentrist | posted 1 month ago

Hey, when will the next Doctrine tutorial be available?
Thanks a lot for your work!

Reply

Hey excentrist!

I'm shooting for January - I definitely want to get the Doctrine relations tutorial out!

Cheers!

2 Reply

Hey guys,

I was wondering, if using turbo frames in production, would that negatively impact a page's SEO score, considering the fact that the potential relevant search results would be initially hidden from the search engines?

What's your thought on this?

Thanks a lot.

Reply

Hi roenfeldt!

Excellent question! SEO is complex, but I can fairly confidently say no: turbo frames will NOT negatively impact SEO. In fact, I believe they're designed with this in mind.

Why? Let's look at an example:

<turbo-frame id="featured-product">
    ...
  
    <a href="/products/blow-up-sofa-couch">See More</a>
</turbo-frame>

You and I understand that, thanks to Turbo Frames, when we click the See More link, it will only load inside of THIS turbo-frame. So, at first, it might seem like you have some potential content that a search engine crawler will never see!

But the beauty of Turbo-Frames is that the link URL - /products/blow-up-sofa-couch - is a REAL URL to a REAL, full page. So if a crawler crawls your page and see this <a> tag, it will look like a perfectly normal <a> tag. And when they follow the link, they will load (and index) the full page that renders when you go to /products/blow-up-sofa/couch. Turbo Frames are a nice bit of magic, but they fall back to real, functional, boring pages.

The only potential spot where SEO could be affected is if you use a "lazy" frame (where it starts empty and then loads after page load or once the item becomes visible). But, most crawlers are good now at letting some JavaScript load, and any application that uses JavaScript to load content a few ms later would share this problem.

Let me know if this helps!

Cheers!

2 Reply

Hey Ryan,

That makes a lot of sense, thank you for clarifying that out for me.

Reply
seb-jean Avatar
seb-jean Avatar seb-jean | posted 3 months ago

Hello,
Are your turbo-frames nested?

<turbo-frame id="mix-browse-list-1">
	<turbo-frame id="mix-browse-list-2">
		<turbo-frame id="mix-browse-list-3">
		 ...
		</turbo-frame>
	</turbo-frame>
</turbo-frame>
Reply

Hey Seb,

Yes, in this case, we embedded a turbo-frame containing the HTML of the subsequent pages into the main turbo-frame, which contains the first page results

Cheers!

Reply
seb-jean Avatar

Hey Diego,

I think it's a pity.
A better solution might be this: https://github.com/thoughtbot/hotwire-example-template/tree/hotwire-example-pagination.

What do you think ?

Reply

Why would it be better? I'm asking because I don't see any downsides in Ryan's approach

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.7", // v3.7.0
        "doctrine/doctrine-bundle": "^2.7", // 2.7.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.12", // 2.12.3
        "knplabs/knp-time-bundle": "^1.18", // v1.19.0
        "pagerfanta/doctrine-orm-adapter": "^3.6", // v3.6.1
        "pagerfanta/twig": "^3.6", // v3.6.1
        "sensio/framework-extra-bundle": "^6.2", // v6.2.6
        "stof/doctrine-extensions-bundle": "^1.7", // v1.7.0
        "symfony/asset": "6.1.*", // v6.1.0
        "symfony/console": "6.1.*", // v6.1.2
        "symfony/dotenv": "6.1.*", // v6.1.0
        "symfony/flex": "^2", // v2.2.2
        "symfony/framework-bundle": "6.1.*", // v6.1.2
        "symfony/http-client": "6.1.*", // v6.1.2
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/proxy-manager-bridge": "6.1.*", // v6.1.0
        "symfony/runtime": "6.1.*", // v6.1.1
        "symfony/twig-bundle": "6.1.*", // v6.1.1
        "symfony/ux-turbo": "^2.0", // v2.3.0
        "symfony/webpack-encore-bundle": "^1.13", // v1.15.1
        "symfony/yaml": "6.1.*", // v6.1.2
        "twig/extra-bundle": "^2.12|^3.0", // v3.4.0
        "twig/twig": "^2.12|^3.0" // v3.4.1
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "6.1.*", // v6.1.0
        "symfony/maker-bundle": "^1.41", // v1.44.0
        "symfony/stopwatch": "6.1.*", // v6.1.0
        "symfony/web-profiler-bundle": "6.1.*", // v6.1.2
        "zenstruck/foundry": "^1.21" // v1.21.0
    }
}