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!

F5dfeeef276fcfd4751f4063487a5a3f?s=128

weaverryan

June 18, 2021
Tweet

Transcript

  1. A Dynamic Frontend with Twig and Zero JavaScript? Introducing… by

    your friend Ryan Weaver @weaverryan
  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
  3. The Great JavaScript Choice: Traditional Web App vs SPA Part

    1 @weaverryan
  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
  5. Misconception If I want a *truly* interactive site, I need

    to build it in a frontend-framework. @weaverryan
  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
  7. It all comes back to Twig…

  8. Twig is Fantastic It's continued to evolve for the past

    10 years … but can it do more for us? @weaverryan
  9. Twig Components? Part 2 @weaverryan

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

    it on our sidebar @weaverryan
  11. {# posts/_latestPosts.html.twig #} <div> <small>Showing {{ posts|length }} of {{

    totalPosts }}</small> {% for post in posts %} <a href="{{ path(‘app_post_show’), { id: post.id }) }}" > {{ post.title }} <small>{{ post.createdAt|ago }}</small> </a> {% endfor %} </div> Create a template partial
  12. public function homepage(PostRepository $postRepository) { return $this->render('main/homepage.html.twig',[ 'posts' => $postRepository->findRecent(),

    'totalPosts' => $postRepository->countPosts(), ]); } Pass variables from the controller
  13. <div class="col-3"> {{ include (‘posts/_latestPosts.html.twig’, { posts: posts, totalPosts: totalPosts

    }) }} </div> Render the partial
  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
  15. What about render(controller())? @weaverryan

  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
  17. <div class=“col-3”> {{ render(controller( ‘App\\Controller\\PresentationController::_latestPosts’ )) }} </div> Render the

    controller
  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
  19. What about the frontend-framework paradigm? Use objects to build HTML

    elements? @weaverryan
  20. Twig Components: A dead-simple friendship between an object & a

    template Part 3 @weaverryan
  21. Install Twig Component $ composer require symfony/ux-twig-component A shiny new

    bundle! @weaverryan
  22. Let's create a simple, reusable "alert" component @weaverryan

  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
  24. Create its template {# templates/components/alert.html.twig #} <div class="alert alert-success"> I'm

    an alert component! </div> @weaverryan
  25. Render it! {{ component(‘alert’) }} @weaverryan

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

  27. Add some properties class AlertComponent implements ComponentInterface { public string

    $type = 'success'; public string $message; // ... } @weaverryan
  28. … use the properties {# templates/components/alert.html.twig #} <div class="alert alert-{{

    this.type }}"> {{ this.message }} </div> The AlertComponent instance is available as "this" @weaverryan
  29. {{ component (‘alert’, { message: ‘I am a working success

    alert!’ }) }} {{ component(‘alert’, { type:’danger’, message: ‘Danger Will Robinson’ }) }} … and pass in the properties @weaverryan
  30. {{ component (‘alert’, { message: ‘I am a working success

    alert!’ }) }} {{ component(‘alert’, { type:’danger’, message: ‘Danger Will Robinson’ }) }} … and pass in the properties @weaverryan
  31. What about our "latest posts" as a component? @weaverryan

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

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

    { posts: posts, }) }} {{ component('latest_posts') }} @weaverryan
  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!
  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
  36. {# templates/components/latest_posts.html.twig #} <small>Showing {{ posts|length }} of {{ totalPosts

    }}</small> <small>Showing {{ this.posts|length }} of {{ this.totalPosts }}</small> {% for post in posts %} {% for post in this.posts %} Copy then adjust the template @weaverryan
  37. {# templates/components/latest_posts.html.twig #} <small>Showing {{ posts|length }} of {{ totalPosts

    }}</small> <small>Showing {{ this.posts|length }} of {{ this.totalPosts }}</small> {% for post in posts %} {% for post in this.posts %} Copy then adjust the template @weaverryan
  38. Let's make it more con fi gurable! @weaverryan

  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 ); } }
  40. And pass them in: {{ component(‘latest_posts’, { limit: 5, query:

    ‘endis’, }) }}
  41. And pass them in: {{ component(‘latest_posts’, { limit: 5, query:

    ‘endis’, }) }}
  42. Let's Dream! Wouldn't it be cool if we added a

    search box… … and the component re- rendered as we typed?
  43. How would that work? @weaverryan

  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'; } }
  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'; } }
  46. Live Components Part 4 What if you could expose a

    URL to a component? @weaverryan
  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
  48. It's ALIVE!!! use Symfony\UX\LiveComponent\LiveComponentInterface; class LatestPostsComponent implements ComponentInterface class LatestPostsComponent

    implements LiveComponentInterface { // ... } @weaverryan
  49. Congratulations! Your component is now accessible via a URL!! @weaverryan

  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
  51. Let's see the URL! {# templates/components/latest_posts.html.twig #} <!-- . .

    . --> <a href="{{ component_url(this) }}">Load this component</a> /components/latest_posts ?limit=5 &query=endis &_checksum=BKv54%3D A checksum?
  52. https://localhost:8000/components/latest_posts?limit=5&query=endis&_checksum=…

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

  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
  55. https://localhost:8000/components/latest_posts?limit=5&query=qui&_checksum=…

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

  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
  58. package.json { "devDependencies": { "@symfony/ux-live-component": "file:vendor/symfony/ux-live-component/assets" } } @weaverryan

  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
  60. Our current template {# templates/components/latest_posts.html.twig #} <div> <input type="search" value="{{

    this.query }}" > {% for post in this.posts %} {# ... #} {% endfor %} </div> Typing in this box doesn’t do anything yet…
  61. Activate the live controller {# templates/components/latest_posts.html.twig #} <div {{ init_live_component(this)

    }}> <input type="search" data-action="live#update" data-model="query" value="{{ this.query }}" > <!-- ... --> </div> Initializes the "live" controller Update the query property and re-render this entire component
  62. Demo??? @weaverryan

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

  64. Let's add some loading behavior <div {{ init_live_component(this) }} >

    <div data-loading=“addClass(low-opacity)"> {% for post in this.posts %} {# ... #} {% endfor %} </div> </div> @weaverryan
  65. Sorry! Video demos don't work in PDF's ;)

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

  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
  68. edit_post.html.twig <div {{ init_live_component(this) }}> <input value=“{{ this.post.title }}" data-model="post.title"

    data-action="live#update" > <textarea data-model="post.content" data-action="live#update" >{{ this.post.content }}</textarea> <div class="post-preview"> <h3>{{ this.post.title }}</h3> {{ this.post.content|markdown_to_html }} </div> </div>
  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; // ... }
  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.
  71. Render it! {{ component(‘edit_post’, { post: somePostObject }) }} @weaverryan

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

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

  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; // ... }
  75. Render errors in the template <input class="{{ this.getError('post.title') ? ‘is-invalid':

    '' }}" value="{{ this.post.title }}" data-model="post.title" data-action=“live#update" > {% if this.getError('post.title') %} <div class=“invalid-feedback"> {{ this.getError('post.title').message }} </div> {% endif %} @weaverryan
  76. Sorry! Video demos don't work in PDF's ;)

  77. But, could we actually *save* the post from the component?

    @weaverryan
  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.
  79. Call the action from the template <div {{ init_live_component(this) }}>

    <!-- ... --> <button data-action="live#action" data-action-name="save" data-loading="addAttribute(disabled)" >Save</button> </div>
  80. Call the action from the template <div {{ init_live_component(this) }}>

    <!-- ... --> {% if this.isSaved %} {{ component('alert', { message: 'Post saved!' }) }} {% endif %} <button data-action="live#action" data-action-name="save" data-loading="addAttribute(disabled)" >Save</button> </div> Yes, I DID just reuse the component from earlier!
  81. … or add it to the form <form {{ init_live_component(this)

    }} data-action="live#action" data-action-name="prevent|save" > <!-- ... --> {% if this.isSaved %} {{ component('alert', { message: 'Post saved!' }) }} {% endif %} </form> The "prevent" modi fi er calls preventDefault() @weaverryan
  82. Actions are… real controller actions… @weaverryan

  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
  84. Sorry! Video demos don't work in PDF's ;)

  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
  86. Awesome! But does this work with Symfony's Form system? @weaverryan

  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
  88. Boring Template <div {{ init_live_component(this) }} > {{ form_start(this.form) }}

    {{ form_row(this.form.title) }} {{ form_row(this.form.slug) }} {{ form_row(this.form.content) }} <div class=“markdown-preview"> {{ field_value(this.form.content)|markdown_to_html }} </div> <button>Save</button> {{ form_end(this.form) }} </div>
  89. Boring Template <div {{ init_live_component(this) }} > {{ form_start(this.form) }}

    {{ form_row(this.form.title) }} {{ form_row(this.form.slug) }} {{ form_row(this.form.content) }} <div class=“markdown-preview"> {{ field_value(this.form.content)|markdown_to_html }} </div> <button>Save</button> {{ form_end(this.form) }} </div> this.form holds the form!
  90. On change, update & re-render <div {{ 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) }} <div class=“markdown-preview"> {{ field_value(this.form.content)|markdown)to_html }} </div> <button>Save</button> {{ form_end(this.form) }} </div> Instead of adding data- action= to every fi eld, add it to a parent element.
  91. Sorry! Video demos don't work in PDF's ;)

  92. What else? Polling (future Mercure support) Automatic debouncing Automatic CSRF

    protection on actions Lifecycle hooks (e.g. PostHydrate) Custom hydrators & dehydrators
  93. Twig Components & Live Components Fun, Powerful, Experimental! @weaverryan

  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
  95. Can I stop writing JavaScript completely? @weaverryan

  96. No, sorry ;) @weaverryan

  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
  98. Join the E ff ort! github.com/symfony/ux github.com/weaverryan/live-demo @weaverryan

  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
  100. Screencasts Stimulus: https://symfonycasts.com/screencast/stimulus @weaverryan Turbo: https://symfonycasts.com/screencast/turbo