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. > 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
  2. 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
  3. Misconception If I want a *truly* interactive site, I need

    to build it in a frontend-framework. @weaverryan
  4. 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
  5. Twig is Fantastic It's continued to evolve for the past

    10 years … but can it do more for us? @weaverryan
  6. {# 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
  7. 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
  8. 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
  9. 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
  10. Add some properties class AlertComponent implements ComponentInterface { public string

    $type = 'success'; public string $message; // ... } @weaverryan
  11. … 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
  12. {{ component (‘alert’, { message: ‘I am a working success

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

    alert!’ }) }} {{ component(‘alert’, { type:’danger’, message: ‘Danger Will Robinson’ }) }} … and pass in the properties @weaverryan
  14. Pass only what's needed to the component {{ component ('latest_posts',

    { posts: posts, }) }} {{ component('latest_posts') }} @weaverryan
  15. 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!
  16. 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
  17. {# 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
  18. {# 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
  19. 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 ); } }
  20. Let's Dream! Wouldn't it be cool if we added a

    search box… … and the component re- rendered as we typed?
  21. /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'; } }
  22. /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'; } }
  23. Live Components Part 4 What if you could expose a

    URL to a component? @weaverryan
  24. Installing Twig Component $ composer require symfony/ux-live-component (a pure PHP

    package) … (with an optional JS library we'll talk about later) @weaverryan
  25. 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
  26. 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?
  27. 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
  28. 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
  29. 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
  30. 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…
  31. 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
  32. 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
  33. 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
  34. 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>
  35. 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; // ... }
  36. 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.
  37. Add the validatable trait use Symfony\UX\LiveComponent\ValidatableComponentTrait; class EditPostComponent implements LiveComponentInterface

    { use ValidatableComponentTrait; /** * @LiveProp(exposed={"title", "content"}) * @Assert\Valid() */ public Post $post; // ... }
  38. 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
  39. 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.
  40. 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>
  41. 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!
  42. … 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
  43. 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
  44. 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
  45. 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
  46. 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>
  47. 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!
  48. 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.
  49. What else? Polling (future Mercure support) Automatic debouncing Automatic CSRF

    protection on actions Lifecycle hooks (e.g. PostHydrate) Custom hydrators & dehydrators
  50. 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
  51. 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