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. Turbo:


    Give Your Traditional App
    the "single-page-app" Feel
    by your friend Ryan Weaver
    @weaverryan

    View Slide

  2. > 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

    View Slide

  3. Follow along with the code:
    @weaverryan
    https://github.com/weaverryan/symfony-world-2021-turbo

    View Slide

  4. A Symfony UX Update


    Part 1
    @weaverryan

    View Slide

  5. Symfony UX?
    A Movement Towards Stimulus*


    *a JavaScript library
    @weaverryan

    View Slide

  6. 🐑


    Grow my flock!
    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;


    });

    View Slide

  7. Full Stimulus Tutorial:
    … in 5 (3?) minutes!
    @weaverryan

    View Slide

  8. On your marks…
    @weaverryan

    View Slide

  9. Get set…
    @weaverryan

    View Slide

  10. Go!
    @weaverryan

    View Slide

  11. 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!


    }


    }


    View Slide

  12. Attach to an Element



    🐑





    Grow my flock!








    View Slide

  13. Add Actions:



    🐑





    Grow my flock!








    View Slide

  14. 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!');


    }


    }


    View Slide

  15. 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);


    }


    }

    View Slide

  16. Pass in the values



    🐑





    Grow my flock!








    View Slide

  17. Add a Target to Find Elements



    🐑





    Grow my flock!








    View Slide

  18. 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;


    }


    }

    View Slide

  19. Congrats on Learning Stimulus!
    🎉
    @weaverryan

    View Slide




  20. 🐑





    Grow my flock!






    Twig Helper Methods



    🐑





    Grow my flock!








    View Slide

  21. The Huge Bene
    fi
    t?
    Your JavaScript "just works"


    … always


    … even if HTML is loaded later
    @weaverryan

    View Slide

  22. So how do I get this JavaScript Library?
    composer require encore
    @weaverryan

    View Slide

  23. package.json
    {


    "devDependencies": {


    "@symfony/stimulus-bridge": "^2.0.0",


    "@symfony/webpack-encore": "^1.0.0",


    "stimulus": "^2.0.0",


    // ...


    }


    }


    @weaverryan

    View Slide

  24. 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);

    View Slide

  25. 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);

    View Slide

  26. symfony/ux Packages
    Nice (optional)


    Stimulus Controllers + Bundles
    @weaverryan

    View Slide

  27. composer require symfony/ux-chartjs
    {


    "devDependencies": {


    "@symfony/stimulus-bridge": "^2.0.0",


    "@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/assets",


    "@symfony/webpack-encore": "^1.0.0",


    "chart.js": "^2.9.4"


    }


    }
    yarn install --force

    View Slide

  28. assets/controllers.json
    {


    "controllers": {


    "@symfony/ux-chartjs": {


    "chart": {


    "enabled": true,


    "fetch": "eager"


    }


    }


    }


    }


    {{ stimulus_controller(‘symfony/ux-chartjs/chart’, data) }}

    View Slide

  29. View Slide

  30. Stimulus 3? No biggie
    - Package renamed: stimulus -> @hotwired/stimulus


    - "action parameters"


    - Value defaults


    - Debug mode
    @weaverryan

    View Slide

  31. The Lifecycle of an SPA
    Part 2
    @weaverryan

    View Slide

  32. 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

    View Slide

  33. 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

    View Slide

  34. SPA
    JavaScript boots once: is a
    long-running process
    Traditional
    JavaScript boots again… and again…
    and again… on every navigation

    View Slide

  35. The Enemy: Full Page Reloads
    Full Page Reloads "feel" slow
    Full Page Reloads cause your application** to reboot
    ** application == JavaScript parsing & initialization
    @weaverryan

    View Slide

  36. Hello Turbo
    Part 3
    @weaverryan

    View Slide

  37. 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

    View Slide

  38. {


    "controllers": {


    "@symfony/ux-turbo": {


    "turbo-core": {


    "enabled": true,


    "fetch": "eager"


    }


    }


    }


    }
    composer require symfony/ux-turbo
    (It's a fake controller!)

    View Slide

  39. End Result:


    The Turbo JavaScript has been initialized!
    @weaverryan

    View Slide

  40. End Result:


    Full Page Refreshes are GONE
    @weaverryan

    View Slide

  41. The Devil is in the
    Details
    Part 4
    @weaverryan

    View Slide

  42. No server changes you said?
    @weaverryan

    View Slide

  43. #[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

    View Slide

  44. The Elephant in the room:
    Your JavaScript needs to "support" only being
    initialized once…
    … and keep working while HTML changes.
    @weaverryan

    View Slide

  45. // 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

    View Slide

  46. The solution is simple: Stimulus
    @weaverryan

    View Slide

  47. Wait, so Turbo forces me to write my


    JavaScript a speci
    fi
    c way? Boo that!
    @weaverryan

    View Slide

  48. You write your JavaScript so that it can be
    executed once and work forever.


    Then you get Turbo for free
    The Truth:
    @weaverryan

    View Slide

  49. 3rd Party JavaScript (e.g. analytics)


    sometimes expects full page reloads.
    This is solvable… and you share


    the problem with SPAs
    @weaverryan

    View Slide

  50. Extras!
    Part 5
    @weaverryan
    🥑🥑🥑

    View Slide

  51. Should I ever use React/Vue/etc?
    Sure!
    @weaverryan

    View Slide

  52. import React from 'react';


    export function PrintMessage(props) {


    return (





    {props.message} rendered at


    {(new Date()).toTimeString()}





    )


    }
    Component
    @weaverryan

    View Slide

  53. // 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(


    ,


    this.element


    );


    }


    }
    Stimulus Controller

    View Slide



  54. message: 'Symfony World 2021!'


    }) }}>
    Render wherever you want
    @weaverryan

    View Slide

  55. Tell your Controllers to be Lazy!
    @weaverryan

    View Slide

  56. // 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

    View Slide

  57. Turbo Frames
    … but without the "old" and "weird"
    @weaverryan

    View Slide




  58. Links targeting the entire page





    My message title


    My message content


    Edit this message








    One comment


    Two comments


    ...







    View Slide

  59. Turbo Streams
    Update HTML elements directly from the server


    … for any user
    @weaverryan

    View Slide

  60. #[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

    View Slide










  61. This div will be appended to the element with the DOM ID "messages".




















    This div will be prepended to the element with the DOM ID "messages".









    Turbo Streams

    View Slide

  62. What this all means for us:
    @weaverryan

    View Slide

  63. 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

    View Slide

  64. THANK YOU!
    Symfony UX Info:


    github.com/symfony/ux
    Turbo Tutorial


    symfonycasts.com/screencast/turbo
    Stimulus Tutorial


    symfonycasts.com/screencast/stimulus
    @weaverryan

    View Slide