Slide 1

Slide 1 text

A Dynamic Frontend with Twig and Zero JavaScript? Introducing… by your friend Ryan Weaver @weaverryan

Slide 2

Slide 2 text

> 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

Slide 3

Slide 3 text

The Great JavaScript Choice: Traditional Web App vs SPA Part 1 @weaverryan

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

It all comes back to Twig…

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

Twig Components? Part 2 @weaverryan

Slide 10

Slide 10 text

Let's build a reusable "latest posts" widget … and render it on our sidebar @weaverryan

Slide 11

Slide 11 text

{# posts/_latestPosts.html.twig #}
Showing {{ posts|length }} of {{ totalPosts }} {% for post in posts %} {{ post.title }} {{ post.createdAt|ago }} {% endfor %}
Create a template partial

Slide 12

Slide 12 text

public function homepage(PostRepository $postRepository) { return $this->render('main/homepage.html.twig',[ 'posts' => $postRepository->findRecent(), 'totalPosts' => $postRepository->countPosts(), ]); } Pass variables from the controller

Slide 13

Slide 13 text

{{ include (‘posts/_latestPosts.html.twig’, { posts: posts, totalPosts: totalPosts }) }}
Render the partial

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

What about render(controller())? @weaverryan

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

{{ render(controller( ‘App\\Controller\\PresentationController::_latestPosts’ )) }}
Render the controller

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

Let's create a simple, reusable "alert" component @weaverryan

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

Create its template {# templates/components/alert.html.twig #}
I'm an alert component!
@weaverryan

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

Add some properties class AlertComponent implements ComponentInterface { public string $type = 'success'; public string $message; // ... } @weaverryan

Slide 28

Slide 28 text

… use the properties {# templates/components/alert.html.twig #}
{{ this.message }}
The AlertComponent instance is available as "this" @weaverryan

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

Pass only what's needed to the component {{ component ('latest_posts', { posts: posts, }) }} {{ component('latest_posts') }} @weaverryan

Slide 34

Slide 34 text

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!

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

{# 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

Slide 37

Slide 37 text

{# 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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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 ); } }

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

How would that work? @weaverryan

Slide 44

Slide 44 text

/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'; } }

Slide 45

Slide 45 text

/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'; } }

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

Installing Twig Component $ composer require symfony/ux-live-component (a pure PHP package) … (with an optional JS library we'll talk about later) @weaverryan

Slide 48

Slide 48 text

It's ALIVE!!! use Symfony\UX\LiveComponent\LiveComponentInterface; class LatestPostsComponent implements ComponentInterface class LatestPostsComponent implements LiveComponentInterface { // ... } @weaverryan

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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?

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

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

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

Live Stimulus Controller Part 5 Re-rendering Magic @weaverryan

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

package.json { "devDependencies": { "@symfony/ux-live-component": "file:vendor/symfony/ux-live-component/assets" } } @weaverryan

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

Our current template {# templates/components/latest_posts.html.twig #}
{% for post in this.posts %} {# ... #} {% endfor %}
Typing in this box doesn’t do anything yet…

Slide 61

Slide 61 text

Activate the live controller {# templates/components/latest_posts.html.twig #}
Initializes the "live" controller Update the query property and re-render this entire component

Slide 62

Slide 62 text

Demo??? @weaverryan

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

Let's add some loading behavior
{% for post in this.posts %} {# ... #} {% endfor %}
@weaverryan

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

edit_post.html.twig
{{ this.post.content }}

{{ this.post.title }}

{{ this.post.content|markdown_to_html }}

Slide 69

Slide 69 text

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; // ... }

Slide 70

Slide 70 text

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.

Slide 71

Slide 71 text

Render it! {{ component(‘edit_post’, { post: somePostObject }) }} @weaverryan

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

Add the validatable trait use Symfony\UX\LiveComponent\ValidatableComponentTrait; class EditPostComponent implements LiveComponentInterface { use ValidatableComponentTrait; /** * @LiveProp(exposed={"title", "content"}) * @Assert\Valid() */ public Post $post; // ... }

Slide 75

Slide 75 text

Render errors in the template {% if this.getError('post.title') %}
{{ this.getError('post.title').message }}
{% endif %} @weaverryan

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

But, could we actually *save* the post from the component? @weaverryan

Slide 78

Slide 78 text

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.

Slide 79

Slide 79 text

Call the action from the template
Save

Slide 80

Slide 80 text

Call the action from the template
{% if this.isSaved %} {{ component('alert', { message: 'Post saved!' }) }} {% endif %} Save
Yes, I DID just reuse the component from earlier!

Slide 81

Slide 81 text

… or add it to the form {% if this.isSaved %} {{ component('alert', { message: 'Post saved!' }) }} {% endif %} The "prevent" modi fi er calls preventDefault() @weaverryan

Slide 82

Slide 82 text

Actions are… real controller actions… @weaverryan

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

Awesome! But does this work with Symfony's Form system? @weaverryan

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

Boring Template
{{ 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) }}

Slide 89

Slide 89 text

Boring Template
{{ 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!

Slide 90

Slide 90 text

On change, update & re-render
{{ 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.

Slide 91

Slide 91 text

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

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

Twig Components & Live Components Fun, Powerful, Experimental! @weaverryan

Slide 94

Slide 94 text

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

Slide 95

Slide 95 text

Can I stop writing JavaScript completely? @weaverryan

Slide 96

Slide 96 text

No, sorry ;) @weaverryan

Slide 97

Slide 97 text

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

Slide 98

Slide 98 text

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

Slide 99

Slide 99 text

@kbond THANK YOU! Livewire: github.com/livewire/livewire Phoenix LiveView: github.com/phoenixframework/phoenix_live_view Rails ViewComponent: github.com/github/view_component @weaverryan

Slide 100

Slide 100 text

Screencasts Stimulus: https://symfonycasts.com/screencast/stimulus @weaverryan Turbo: https://symfonycasts.com/screencast/turbo