Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Turbo: Give you Traditional App the "Single-page-app" Feel

weaverryan
December 10, 2021

Turbo: Give you Traditional App the "Single-page-app" Feel

One of the biggest draws to building a "single page app" (SPA) is that full page refreshes are gone, giving users a quick and responsive experience. And, until recently, it seemed like you needed to choose between a "responsive SPA" *or* a "traditional" app... which are often much simpler to build.

But... that's a lie! Thanks to Turbo, we can now build traditional applications that return HTML *and* have the "no-refresh" SPA experience.

In this talk, we'll look how we can incrementally convert an existing application into a Turbo-powered app with zero full page refreshes. This includes tips for how you write your JavaScript, cleaning up Turbo "previews" and other pitfalls.

We'll also get a primer on Turbo Frames and the *super* cool Turbo Streams, which allow you to (with Mercure) update any part of any user's page in real time!

So lets go build a traditional app *and* give our users the quick experience they deserve!

weaverryan

December 10, 2021
Tweet

More Decks by weaverryan

Other Decks in Programming

Transcript

  1. > Member of the Symfony docs team 
 > Some

    Dude at SymfonyCasts.com > Husband of the talented and beloved @leannapelham symfonycasts.com twitter.com/weaverryan Hallo there!! I’m Ryan! > Father to my much more charming son, Beckett
  2. <div id="flock" data-animal="🐑">🐑</div> <button class="js-grow-flock">Grow my flock!</button> Existing Solution: const

    flockEl = document.getElementById('flock'); const growFlockLink = document.querySelector('.js-grow-flock'); growFlockLink.addEventListener('click', (event) => { event.preventDefault(); flockEl.innerHTML = flockEl.innerHTML + flockEl.dataset.animal; });
  3. Stimulus: create a controller // assets/controllers/grow-flock-controller.js import { Controller }

    from 'stimulus'; export default class extends Controller { connect() { // I will be called each time a matching // element appears on the page, even via Ajax! } }
  4. Actions are Simple Methods // assets/controllers/grow-flock-controller.js import { Controller }

    from 'stimulus'; export default class extends Controller { grow(event) { event.preventDefault(); console.log('I am called on click!'); } }
  5. Allow a Value to be Passed // assets/controllers/grow-flock-controller.js import {

    Controller } from 'stimulus'; export default class extends Controller { static values = { animal: String } grow(event) { event.preventDefault(); console.log('I will grow ' + this.animalValue); } }
  6. Add a Target to Find Elements <div data-controller="grow-flock" data-grow-flock-animal-value="🐑"> <div

    data-grow-flock-target="flock">🐑</div> <button data-action="grow-flock#grow"> Grow my flock! </button> </div>
  7. Access the Target // assets/controllers/grow-flock-controller.js import { Controller } from

    'stimulus'; export default class extends Controller { static values = { animal: String } static targets = ['flock']; grow(event) { event.preventDefault(); this.flockTarget.innerHTML = this.flockTarget.innerHTML + this.animalValue; } }
  8. <div {{ stimulus_controller('grow-flock', { animal: '🐑' }) }}> <div {{

    stimulus_target(‘grow-flock', 'flock') }}>🐑</div> <button {{ stimulus_action('grow-flock', 'grow') }}> Grow my flock! </button> </div> Twig Helper Methods <div data-controller="grow-flock" data-grow-flock-animal-value="🐑"> <div data-grow-flock-target="flock">🐑</div> <button data-action="grow-flock#grow"> Grow my flock! </button> </div>
  9. The Huge Bene fi t? Your JavaScript "just works" …

    always … even if HTML is loaded later @weaverryan
  10. assets/bootstrap.js import { startStimulusApp } from '@symfony/stimulus-bridge'; // Registers Stimulus

    controllers from controllers.json // and the controllers/ directory export const app = startStimulusApp(require.context( '@symfony/stimulus-bridge/lazy-controller-loader!./controllers', true, /\.(j|t)sx?$/ )); // register any custom, 3rd party controllers here // app.register('some_controller_name', SomeImportedController);
  11. assets/bootstrap.js import { startStimulusApp } from '@symfony/stimulus-bridge'; // Registers Stimulus

    controllers from controllers.json // and the controllers/ directory export const app = startStimulusApp(require.context( '@symfony/stimulus-bridge/lazy-controller-loader!./controllers', true, /\.(j|t)sx?$/ )); // register any custom, 3rd party controllers here // app.register('some_controller_name', SomeImportedController);
  12. assets/controllers.json { "controllers": { "@symfony/ux-chartjs": { "chart": { "enabled": true,

    "fetch": "eager" } } } } {{ stimulus_controller(‘symfony/ux-chartjs/chart’, data) }}
  13. Stimulus 3? No biggie - Package renamed: stimulus -> @hotwired/stimulus

    - "action parameters" - Value defaults - Debug mode @weaverryan
  14. JavaScript downloaded (maybe from cache) JavaScript setup (create objects, attach

    listeners) server returns empty HTML JavaScript executes as user interacts User navigates (clicks link, submits form) SPA HTML is built
  15. JavaScript downloaded (maybe from cache) JavaScript setup (create objects, attach

    listeners) server returns full HTML JavaScript executes as user interacts User navigates (clicks link, submits form) Start Over! Traditional App
  16. SPA JavaScript boots once: is a long-running process Traditional JavaScript

    boots again… and again… and again… on every navigation
  17. The Enemy: Full Page Reloads Full Page Reloads "feel" slow

    Full Page Reloads cause your application** to reboot ** application == JavaScript parsing & initialization @weaverryan
  18. Turbo! Zoom! A JavaScript library Automatically turns every click &

    form submit into an AJAX call Requires (nearly) zero changes to your server Eliminates full page reloads! @weaverryan
  19. { "controllers": { "@symfony/ux-turbo": { "turbo-core": { "enabled": true, "fetch":

    "eager" } } } } composer require symfony/ux-turbo (It's a fake controller!)
  20. #[Route('/{animal}', name: 'app_homepage')] public function index(Request $request, string $animal =

    '🐑') { $form = $this->createForm(AnimalForm::class); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // ... } - return $this->render('default/index.html.twig', [ + return $this->renderForm('default/index.html.twig', [ 'animal' => $animal, - 'form' => $form->createView(), + 'form' => $form, ]); } Ok, just one
  21. The Elephant in the room: Your JavaScript needs to "support"

    only being initialized once… … and keep working while HTML changes. @weaverryan
  22. // assets/app.js $(document).ready(() => { console.log('page ready!') $('.some-element').on('click', () =>

    { console.log('clicked!'); }); }) console.log('script is done!'); Turbo Un-friendly JavaScript: Executed once per PAGE LOAD Only works for elements present on initial PAGE LOAD
  23. Wait, so Turbo forces me to write my JavaScript a

    speci fi c way? Boo that! @weaverryan
  24. You write your JavaScript so that it can be executed

    once and work forever. Then you get Turbo for free The Truth: @weaverryan
  25. 3rd Party JavaScript (e.g. analytics) sometimes expects full page reloads.

    This is solvable… and you share the problem with SPAs @weaverryan
  26. import React from 'react'; export function PrintMessage(props) { return (

    <span> {props.message} rendered at {(new Date()).toTimeString()} </span> ) } Component @weaverryan
  27. // assets/controllers/react-footer-controller.js import { Controller } from 'stimulus'; import {

    render } from 'react-dom'; import { PrintMessage } from '../components/PrintMessage'; export default class extends Controller { static values = { message: String } connect() { render( <PrintMessage message={this.messageValue} />, this.element ); } } Stimulus Controller
  28. // assets/controllers/react-footer-controller.js import { Controller } from 'stimulus'; import {

    render } from 'react-dom'; import { PrintMessage } from '../components/PrintMessage'; import React from 'react'; /* stimulusFetch: 'lazy' */ export default class extends Controller { // ... } Stimulus Controller Controller & dependencies are downloaded lazily The moment a matching element appears on the page
  29. <body> <div id="navigation">Links targeting the entire page</div> <turbo-frame id="message_1"> <h1>My

    message title</h1> <p>My message content</p> <a href="/messages/1/edit">Edit this message</a> </turbo-frame> <turbo-frame id="comments"> <div id="comment_1">One comment</div> <div id="comment_2">Two comments</div> <form action="/messages/comments">...</form> </turbo-frame> </body> <turbo-frame>
  30. #[Route('/{animal}', name: 'app_homepage')] public function index(Request $request, HubInterface $hub) {

    // ... $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $hub->publish(new Update( 'chat', $this->renderView('flock/animals.stream.html.twig', [ 'animal' => $animal ]) )); return $this->redirectToRoute('...'); } // ... } Publish an Update to Mercure
  31. <turbo-stream action="append" target="messages"> <template> <div id="message_1"> This div will be

    appended to the element with the DOM ID "messages". </div> </template> </turbo-stream> <turbo-stream action="prepend" target="messages"> <template> <div id="message_1"> This div will be prepended to the element with the DOM ID "messages". </div> </template> </turbo-stream> Turbo Streams
  32. Stimulus Simple, no downside, add it incrementally, write JavaScript that

    you love & that always works. symfony/ux libraries Nice, free extra Stimulus controllers if they solve a problem you have. Turbo Your reward for writing fantastic JavaScript