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

A Dynamic Frontend with Twig & Zero JavaScript? Say Hello to Twig Components!

A Dynamic Frontend with Twig & Zero JavaScript? Say Hello to Twig Components!

Traditional web apps are back! The Symfony UX initiative - with tools like Stimulus & Turbo - makes it possible to built your HTML using Twig templates but *with* a "single-page experience" and professionally-written JavaScript. Could we go further? Could we organize Twig templates into reusable units? And could we expose those units so we can load them via AJAX calls or HTTP-cache them? And, if we did that... would it be possible to write Twig templates that automatically update on the frontend when the user interacts with them? All with zero JavaScript? Let's go on a journey!

weaverryan

June 18, 2021
Tweet

More Decks by weaverryan

Other Decks in Technology

Transcript

  1. A Dynamic Frontend with
    Twig and Zero JavaScript?


    Introducing…
    by your friend Ryan Weaver
    @weaverryan

    View full-size 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
    Yo! I’m Ryan!
    > Father to my much more


    charming son, Beckett

    View full-size slide

  3. The Great JavaScript Choice:


    Traditional Web App vs SPA
    Part 1
    @weaverryan

    View full-size slide

  4. Which JavaScript Model Should I Follow?
    Traditional Application
    Single-Page Application
    - Return HTML from your app


    - Centered around Twig


    - Sprinkle on JavaScript
    - Return JSON from your app


    - Centered around React/Vue/etc


    - Frontend entirely written in JavaScript
    @weaverryan

    View full-size slide

  5. Misconception
    If I want a *truly* interactive site, I need
    to build it in a frontend-framework.
    @weaverryan

    View full-size slide

  6. Hotwire: HTML over the Wire
    Turbo
    Stimulus
    Auto Ajax for link clicks and form submits
    Professional JavaScript that always works… even if HTML


    Is loaded via Ajax.
    } Translation:


    Your site becomes


    an SPA!
    @weaverryan

    View full-size slide

  7. It all comes back to Twig…

    View full-size slide

  8. Twig is Fantastic
    It's continued to evolve for the past 10 years
    … but can it do more for us?
    @weaverryan

    View full-size slide

  9. Twig Components?
    Part 2
    @weaverryan

    View full-size slide

  10. Let's build a reusable "latest posts" widget


    … and render it on our sidebar
    @weaverryan

    View full-size slide

  11. {# posts/_latestPosts.html.twig #}





    Showing {{ posts|length }} of {{ totalPosts }}


    {% for post in posts %}




    href="{{ path(‘app_post_show’), { id: post.id }) }}"


    >


    {{ post.title }}


    {{ post.createdAt|ago }}





    {% endfor %}



    Create a template partial

    View full-size slide

  12. public function homepage(PostRepository $postRepository)


    {


    return $this->render('main/homepage.html.twig',[


    'posts' => $postRepository->findRecent(),


    'totalPosts' => $postRepository->countPosts(),


    ]);


    }


    Pass variables from the controller

    View full-size slide




  13. {{ include (‘posts/_latestPosts.html.twig’, {


    posts: posts,


    totalPosts: totalPosts


    }) }}



    Render the partial

    View full-size slide

  14. Variables must be passed down from a controller
    Good, but not great
    Unclear contract (what variables does the template need?)
    Testable only with functional tests
    Quick & simple!
    X
    X
    X
    @weaverryan

    View full-size slide

  15. What about render(controller())?
    @weaverryan

    View full-size slide

  16. public function _latestPosts(PostRepository $postRepository)


    {


    return $this->render('presentation/_latestPosts.html.twig',[


    'posts' => $postRepository->findRecent(),


    'totalPosts' => $postRepository->countPosts(),


    ]);


    }
    Create a controller for the partial
    @weaverryan

    View full-size slide




  17. {{ render(controller(


    ‘App\\Controller\\PresentationController::_latestPosts’


    )) }}



    Render the controller

    View full-size slide

  18. Better!
    Testable only with functional tests
    Can be embedded anywhere!
    X
    X
    HTTP Cacheable
    Unclear contract (what variables does the template need?)
    Can be slow if not using HTTP caching
    X
    @weaverryan

    View full-size slide

  19. What about the frontend-framework paradigm?
    Use objects to build HTML elements?
    @weaverryan

    View full-size slide

  20. Twig Components:


    A dead-simple friendship between
    an object & a template
    Part 3
    @weaverryan

    View full-size slide

  21. Install Twig Component
    $ composer require symfony/ux-twig-component
    A shiny new bundle!
    @weaverryan

    View full-size slide

  22. Let's create a simple, reusable


    "alert" component
    @weaverryan

    View full-size slide

  23. Create a component class
    namespace App\Twig\Components;


    use Symfony\UX\TwigComponent\ComponentInterface;


    class AlertComponent implements ComponentInterface


    {


    public static function getComponentName(): string


    {


    return 'alert';


    }


    }


    @weaverryan

    View full-size slide

  24. Create its template
    {# templates/components/alert.html.twig #}





    I'm an alert component!



    @weaverryan

    View full-size slide

  25. Render it!
    {{ component(‘alert’) }}
    @weaverryan

    View full-size slide

  26. Let's make it a big more con
    fi
    gurable
    @weaverryan

    View full-size slide

  27. Add some properties
    class AlertComponent implements ComponentInterface


    {


    public string $type = 'success';


    public string $message;


    // ...


    }


    @weaverryan

    View full-size slide

  28. … use the properties
    {# templates/components/alert.html.twig #}





    {{ this.message }}



    The AlertComponent instance


    is available as "this"
    @weaverryan

    View full-size slide

  29. {{ component (‘alert’, {


    message: ‘I am a working success alert!’


    }) }}


    {{ component(‘alert’, {


    type:’danger’,


    message: ‘Danger Will Robinson’


    }) }}
    … and pass in the properties
    @weaverryan

    View full-size slide

  30. {{ component (‘alert’, {


    message: ‘I am a working success alert!’


    }) }}


    {{ component(‘alert’, {


    type:’danger’,


    message: ‘Danger Will Robinson’


    }) }}
    … and pass in the properties
    @weaverryan

    View full-size slide

  31. What about our "latest posts" as a component?
    @weaverryan

    View full-size slide

  32. Generating a component
    $ php bin/console make:component
    @weaverryan

    View full-size slide

  33. Pass only what's needed to the component
    {{ component ('latest_posts', {


    posts: posts,


    }) }}


    {{ component('latest_posts') }}
    @weaverryan

    View full-size slide

  34. class LatestPostsComponent implements ComponentInterface


    {


    private PostRepository $postRepository;


    public function __construct(PostRepository $postRepository)


    {


    $this->postRepository = $postRepository;


    }


    public function getPosts(): array


    {


    return $this->postRepository->findRecent();


    }


    // ...


    }


    Add a getter for "this.posts"
    components are services!

    View full-size slide

  35. class LatestPostsComponent implements ComponentInterface


    {


    private PostRepository $postRepository;


    public function __construct(PostRepository $postRepository)


    {


    $this->postRepository = $postRepository;


    }


    public function getPosts(): array


    {


    return $this->postRepository->findRecent();


    }


    // ...


    }


    Add a getter for "this.posts"
    Usable in the template as this.posts

    View full-size slide

  36. {# templates/components/latest_posts.html.twig #}


    Showing {{ posts|length }} of {{ totalPosts }}


    Showing {{ this.posts|length }} of {{ this.totalPosts }}


    {% for post in posts %}


    {% for post in this.posts %}


    Copy then adjust the template
    @weaverryan

    View full-size slide

  37. {# templates/components/latest_posts.html.twig #}


    Showing {{ posts|length }} of {{ totalPosts }}


    Showing {{ this.posts|length }} of {{ this.totalPosts }}


    {% for post in posts %}


    {% for post in this.posts %}


    Copy then adjust the template
    @weaverryan

    View full-size slide

  38. Let's make it more con
    fi
    gurable!
    @weaverryan

    View full-size slide

  39. Add new properties
    class LatestPostsComponent implements ComponentInterface


    {


    public int $limit = 3;


    public string $query = '';




    // ...


    public function getPosts(): array


    {


    return $this->postRepository->findRecent(


    $this->limit,


    $this->query


    );


    }


    }


    View full-size slide

  40. And pass them in:
    {{ component(‘latest_posts’, {


    limit: 5,


    query: ‘endis’,


    }) }}

    View full-size slide

  41. And pass them in:
    {{ component(‘latest_posts’, {


    limit: 5,


    query: ‘endis’,


    }) }}

    View full-size slide

  42. Let's Dream!
    Wouldn't it be cool if we
    added a search box…
    … and the component re-
    rendered as we typed?

    View full-size slide

  43. How would that work?
    @weaverryan

    View full-size slide

  44. /components/latest_posts?limit=5&query=endis
    class LatestPostsComponent implements LiveComponentInterface


    {


    /** @LiveProp() */


    public int $limit;


    /** @LiveProp() */


    public string $query;


    public string $otherProp = 'not controllable';


    public static function getComponentName(): string


    {


    return 'latest_posts';


    }


    }

    View full-size slide

  45. /components/latest_posts?limit=5&query=endis
    class LatestPostsComponent implements LiveComponentInterface


    {


    /** @LiveProp() */


    public int $limit;


    /** @LiveProp() */


    public string $query;


    public string $otherProp = 'not controllable';


    public static function getComponentName(): string


    {


    return 'latest_posts';


    }


    }

    View full-size slide

  46. Live Components
    Part 4
    What if you could expose a URL to a component?
    @weaverryan

    View full-size slide

  47. Installing Twig Component
    $ composer require symfony/ux-live-component
    (a pure PHP package)



    (with an optional JS library


    we'll talk about later)
    @weaverryan

    View full-size slide

  48. It's ALIVE!!!
    use Symfony\UX\LiveComponent\LiveComponentInterface;


    class LatestPostsComponent implements ComponentInterface


    class LatestPostsComponent implements LiveComponentInterface


    {


    // ...


    }


    @weaverryan

    View full-size slide

  49. Congratulations!
    Your component is now accessible via a URL!!
    @weaverryan

    View full-size slide

  50. Mark which properties are "live"
    use Symfony\UX\LiveComponent\Attribute\LiveProp;


    class LatestPostsComponent implements LiveComponentInterface


    {


    /** @LiveProp() */


    public int $limit = 3;


    /** @LiveProp() */


    public string $query = '';


    public string $somethingElse = 'foo';


    private PostRepository $postRepository;


    // ...


    }
    } Will be read


    from the URL
    }NEVER read from


    the URL

    View full-size slide

  51. Let's see the URL!
    {# templates/components/latest_posts.html.twig #}





    Load this component
    /components/latest_posts


    ?limit=5


    &query=endis


    &_checksum=BKv54%3D
    A checksum?

    View full-size slide

  52. https://localhost:8000/components/latest_posts?limit=5&query=endis&_checksum=…

    View full-size slide

  53. https://localhost:8000/components/latest_posts?limit=5&query=qui&_checksum=…
    @weaverryan

    View full-size slide

  54. Choose properties that are changeable
    use Symfony\UX\LiveComponent\Attribute\LiveProp;


    class LatestPostsComponent implements LiveComponentInterface


    {


    /** @LiveProp() */


    public int $limit = 3;


    /** @LiveProp(writable=true) */


    public string $query = '';


    // ...


    }
    @weaverryan

    View full-size slide

  55. https://localhost:8000/components/latest_posts?limit=5&query=qui&_checksum=…

    View full-size slide

  56. Live Stimulus Controller
    Part 5
    Re-rendering Magic
    @weaverryan

    View full-size slide

  57. symfony/ux-live-component
    1) A PHP package to expose URLs to components
    2) A JavaScript library containing a Stimulus controller


    that smartly re-renders components.
    @weaverryan

    View full-size slide

  58. package.json
    {


    "devDependencies": {


    "@symfony/ux-live-component": "file:vendor/symfony/ux-live-component/assets"


    }


    }
    @weaverryan

    View full-size slide

  59. assets/bootstrap.js
    // assets/bootstrap.js


    // ...


    import LiveController from '@symfony/ux-live-component';


    import '@symfony/ux-live-component/styles/live.css';


    // registers a new Stimulus controller called "live"


    app.register('live', LiveController);


    @weaverryan

    View full-size slide

  60. Our current template
    {# templates/components/latest_posts.html.twig #}







    type="search"


    value="{{ this.query }}"


    >


    {% for post in this.posts %}


    {# ... #}


    {% endfor %}





    Typing in this box
    doesn’t do anything yet…

    View full-size slide

  61. Activate the live controller
    {# templates/components/latest_posts.html.twig #}







    type="search"


    data-action="live#update"


    data-model="query"


    value="{{ this.query }}"


    >








    Initializes the


    "live" controller
    Update the query property and


    re-render this entire component

    View full-size slide

  62. Demo???
    @weaverryan

    View full-size slide

  63. Sorry! Video demos don't work in PDF's ;)

    View full-size slide

  64. Let's add some loading behavior


    {{ init_live_component(this) }}


    >






    {% for post in this.posts %}


    {# ... #}


    {% endfor %}








    @weaverryan

    View full-size slide

  65. Sorry! Video demos don't work in PDF's ;)

    View full-size slide

  66. Could we edit a Post from inside a component?
    @weaverryan

    View full-size slide

  67. EditPostComponent.php
    namespace App\Twig\Components;


    use App\Entity\Post;


    use Symfony\UX\LiveComponent\Attribute\LiveProp;


    use Symfony\UX\LiveComponent\LiveComponentInterface;


    final class EditPostComponent implements LiveComponentInterface


    {


    /**


    * @LiveProp()


    */


    public Post $post;


    public static function getComponentName(): string


    {


    return 'edit_post';


    }


    }


    LiveProps "persist"


    between renders
    Entity will be dehydrated to its id,


    which is sent over HTTP

    View full-size slide

  68. edit_post.html.twig





    value=“{{ this.post.title }}"


    data-model="post.title"


    data-action="live#update"


    >




    data-model="post.content"


    data-action="live#update"


    >{{ this.post.content }}





    {{ this.post.title }}


    {{ this.post.content|markdown_to_html }}








    View full-size slide

  69. But Post cannot be modi
    fi
    ed…
    namespace App\Twig\Components;


    use App\Entity\Post;


    use Symfony\UX\LiveComponent\Attribute\LiveProp;


    use Symfony\UX\LiveComponent\LiveComponentInterface;


    final class EditPostComponent implements LiveComponentInterface


    {


    /**


    * @LiveProp()


    */


    public Post $post;


    // ...


    }


    View full-size slide

  70. Allow parts of $post to change
    namespace App\Twig\Components;


    use App\Entity\Post;


    use Symfony\UX\LiveComponent\Attribute\LiveProp;


    use Symfony\UX\LiveComponent\LiveComponentInterface;


    final class EditPostComponent implements LiveComponentInterface


    {


    /**


    * @LiveProp(exposed={"title", "content"})


    */


    public Post $post;


    // ...


    }


    The $post itself (e.g. id 2) cannot be changed. But the title


    and content properties of the Post *can* be changed.

    View full-size slide

  71. Render it!
    {{ component(‘edit_post’, {


    post: somePostObject


    }) }}
    @weaverryan

    View full-size slide

  72. Sorry! Video demos don't work in PDF's ;)

    View full-size slide

  73. Could we validate the
    fi
    elds as we type?
    @weaverryan

    View full-size slide

  74. Add the validatable trait
    use Symfony\UX\LiveComponent\ValidatableComponentTrait;


    class EditPostComponent implements LiveComponentInterface


    {


    use ValidatableComponentTrait;


    /**


    * @LiveProp(exposed={"title", "content"})


    * @Assert\Valid()


    */


    public Post $post;


    // ...


    }


    View full-size slide

  75. Render errors in the template


    class="{{ this.getError('post.title') ? ‘is-invalid': '' }}"


    value="{{ this.post.title }}"


    data-model="post.title"


    data-action=“live#update"


    >


    {% if this.getError('post.title') %}





    {{ this.getError('post.title').message }}





    {% endif %}


    @weaverryan

    View full-size slide

  76. Sorry! Video demos don't work in PDF's ;)

    View full-size slide

  77. But, could we actually *save* the post


    from the component?
    @weaverryan

    View full-size slide

  78. Hello Actions!
    class EditPostComponent implements LiveComponentInterface


    {


    public bool $isSaved = false;


    /** @LiveAction() */


    public function save()


    {


    $this->isSaved = true;


    }


    }


    Actions can be executed via Ajax from the frontend.


    You do work, then the component renders like normal.

    View full-size slide

  79. Call the action from the template








    data-action="live#action"


    data-action-name="save"


    data-loading="addAttribute(disabled)"


    >Save



    View full-size slide

  80. Call the action from the template






    {% if this.isSaved %}


    {{ component('alert', { message: 'Post saved!' }) }}


    {% endif %}




    data-action="live#action"


    data-action-name="save"


    data-loading="addAttribute(disabled)"


    >Save



    Yes, I DID just reuse


    the component


    from earlier!

    View full-size slide

  81. … or add it to the form


    {{ init_live_component(this) }}


    data-action="live#action"


    data-action-name="prevent|save"


    >





    {% if this.isSaved %}


    {{ component('alert', { message: 'Post saved!' }) }}


    {% endif %}



    The "prevent"


    modi
    fi
    er calls


    preventDefault()
    @weaverryan

    View full-size slide

  82. Actions are… real controller actions…
    @weaverryan

    View full-size slide

  83. Validate then save!
    /** @LiveAction() */


    public function save(EntityManagerInterface $entityManager)


    {


    $this->validate();


    $entityManager->persist($this->post);


    $entityManager->flush();


    $this->isSaved = true;


    }
    action autowiring!
    Validates the component.


    Throws an exception &


    re-renders the component

    View full-size slide

  84. Sorry! Video demos don't work in PDF's ;)

    View full-size slide

  85. Or redirect instead
    You can extend


    AbstractController
    class EditPostComponent extends AbstractController implements LiveComponentInterface


    {


    /** @LiveAction() */


    public function save(EntityManagerInterface $entityManager)


    {


    $this->validate();


    $entityManager->flush();


    $this->addFlash('success', 'Post was saved!');


    return $this->redirectToRoute('app_presentation');


    }


    }


    @weaverryan

    View full-size slide

  86. Awesome! But does this work with


    Symfony's Form system?
    @weaverryan

    View full-size slide

  87. ComponentWithFormTrait
    class EditPostFormComponent extends AbstractController implements LiveComponentInterface


    {


    use ComponentWithFormTrait;


    /** @LiveProp(fieldName="initialData") */


    public Post $post;


    protected function instantiateForm(): FormInterface


    {


    return $this->createForm(PostType::class, $this->post);


    }


    }
    1) store anything needed to


    create the form as a LiveProp
    2) implement this method, which re-recreates


    the form each time it renders

    View full-size slide

  88. Boring Template


    {{ init_live_component(this) }}


    >


    {{ form_start(this.form) }}


    {{ form_row(this.form.title) }}


    {{ form_row(this.form.slug) }}


    {{ form_row(this.form.content) }}





    {{ field_value(this.form.content)|markdown_to_html }}





    Save


    {{ form_end(this.form) }}





    View full-size slide

  89. Boring Template


    {{ init_live_component(this) }}


    >


    {{ form_start(this.form) }}


    {{ form_row(this.form.title) }}


    {{ form_row(this.form.slug) }}


    {{ form_row(this.form.content) }}





    {{ field_value(this.form.content)|markdown_to_html }}





    Save


    {{ form_end(this.form) }}





    this.form holds


    the form!

    View full-size slide

  90. On change, update & re-render


    {{ init_live_component(this) }}


    data-action="change->live#update"


    >


    {{ form_start(this.form) }}


    {{ form_row(this.form.title) }}


    {{ form_row(this.form.slug) }}


    {{ form_row(this.form.content) }}





    {{ field_value(this.form.content)|markdown)to_html }}





    Save


    {{ form_end(this.form) }}





    Instead of adding data-
    action= to every
    fi
    eld, add
    it to a parent element.

    View full-size slide

  91. Sorry! Video demos don't work in PDF's ;)

    View full-size slide

  92. What else?
    Polling (future Mercure support)
    Automatic debouncing
    Automatic CSRF protection on actions
    Lifecycle hooks (e.g. PostHydrate)
    Custom hydrators & dehydrators

    View full-size slide

  93. Twig Components


    &


    Live Components
    Fun, Powerful, Experimental!
    @weaverryan

    View full-size slide

  94. Components Status:
    Handle rendering edge cases
    Validate the design by the community & core team
    Missing features


    - per-model loading control


    - transitions


    - web debug toolbar integration?


    - testing tools


    - ???
    @weaverryan

    View full-size slide

  95. Can I stop writing
    JavaScript completely?
    @weaverryan

    View full-size slide

  96. No, sorry ;)
    @weaverryan

    View full-size slide

  97. Write LESS JavaScript
    Use Live components to auto re-render an


    element from the server as you interact with it.
    Use Turbo to Ajaxify link clicks & form submits


    (for that smooth, SPA experience)
    Use Stimulus for anything else**
    ** including adding behavior to a "live component"
    + live update the page via Mercure & Streams

    View full-size slide

  98. Join the E
    ff
    ort!
    github.com/symfony/ux
    github.com/weaverryan/live-demo
    @weaverryan

    View full-size slide

  99. @kbond
    THANK YOU!
    Livewire:


    github.com/livewire/livewire
    Phoenix LiveView:


    github.com/phoenixframework/phoenix_live_view
    Rails ViewComponent:


    github.com/github/view_component
    @weaverryan

    View full-size slide

  100. Screencasts
    Stimulus:


    https://symfonycasts.com/screencast/stimulus
    @weaverryan
    Turbo:


    https://symfonycasts.com/screencast/turbo

    View full-size slide