assertchris.io

Better components in Laravel

Published on 2018-11-16

One of the things we’ve started doing in Gitstore; is designing everything as components. That way, it’s easier to see what they look like on their own, and to compose interfaces our of pre-built components.

I hadn’t really used components much before this. To me, they looked needlessly complicated. I’d probably end up writing front-end code to replace most of the server-rendered markup.

But, when I started with Gitstore I was determined to make a commerce application that didn’t lean so heavily on JavaScript. I’ve built things with Spark, and those things always felt like JavaScript projects instead of Laravel projects.

Components are similar to includes, except that they have a kind of “default” content:

@php
    assert(in_array($type, ['primary', 'secondary']));
@endphp

<button
    type="{{ $type }}"
    class="
        p-2 text-white rounded mr-2
        @if ($type == 'primary')
            bg-green-light hover:bg-green
        @endif
        @if ($type == 'secondary')
            bg-grey hover:bg-grey-dark
        @endif
    "
>
    {{ $slot }}
</button>

This component renders a button, in one of two states. It’s used like this:

@component('components.button', ['type' => 'primary'])
    {{ __('Log in') }}
@endcomponent

That {{ $slot }} variable is a substitute for {{ __('Log in') }}, and any other children we could put between @component and @endcomponent.

I’ve also been putting assertions in @php, for a bit of type safety. I like that it helps me to remember which “properties” the component needs, but I feel like it could be done better.

This is a great approach to centralising things like CSS utility classes and markup structure. It doesn’t always work, though…

@php
    assert($items instanceof \Illuminate\Support\Collection);
    assert(!isset($empty) || is_string($empty));
@endphp

<div class="w-full flex flex-col">
    @if ($items->count())
        @foreach ($items as $item)
            <-- make this area customisable -->
        @endforeach
    @else
        <div class="p-2">
            @if (isset($empty))
                {{ $empty }}
            @else
                {{ __('There are no items to show.') }}
            @endif
        </div>
    @endif
</div>

Imagine we wanted to create this list component. The “empty” string could be generic, and optionally overridden. We could event expect a list of items to iterate over. The question becomes; how do we allow developers to use this while defining what happens to $item?

Front-end libraries, like Vue and React, solve this in similar and elegant ways. They have solutions like scoped slots and render props.

If we tried to do something like this:

@component('components.list', ['items' => $items])
    {{ $item->name }}
@endcomponent

…we’d get an error message. That’s because of how Blade renders components and slots. The generated code looks like this:

<?php $__env->startComponent('components.list', ['items' => $items]); ?>
    <?php echo e($item->name); ?>
<?php echo $__env->renderComponent(); ?>

Blade exports the the array of view parameters, starts output buffering, and includes the view. The components.list view is rendered in the same scope, so that it can access variables defined in the scope where the component was “started”. $items isn’t defined there. It’s defined inside the component view…

renderComponent stops output buffering and stores the buffered output inside a $slot variable. This is what is given to the component view, to render when {{ $slot }} is called there.

We could do something similar to React’s render props:

@component('components.list', [
    'items' => $items,
    'render' => function($item) {
        return view('components.list-item', [
            'item' => $item
        ]);
    }
])
    // render prop
@endcomponent

…then, in the component would could do:

@php
    assert($items instanceof \Illuminate\Support\Collection);
    assert($render instanceof \Closure);
    assert(!isset($empty) || is_string($empty));
@endphp

<div class="w-full flex flex-col">
    @if ($items->count())
        @foreach ($items as $item)
            {!! $render($item) !!}
        @endforeach
    @else
        <div class="p-2">
            @if (isset($empty))
                {{ $empty }}
            @else
                {{ __('There are no items to show.') }}
            @endif
        </div>
    @endif
</div>

This passes the closure through to the component, and the component can validate and call it with each $item object. This gives us the customisation we need. But, it could be better…

Let’s look at how Blade gets that $slot data:

protected function componentData($name)
{
    return array_merge(
        $this->componentData[count($this->componentStack)],
        ['slot' => new HtmlString(trim(ob_get_clean()))],
        $this->slots[count($this->componentStack)]
    );
}

This is from Illuminate/View/Concerns/ManagesComponents.php

What id we were to allow the use of closures, to generate what gets given to $slot

protected function componentData($name)
{
    $contents = trim(ob_get_clean());

    if (starts_with($contents, 'ƒ-')) {
        $slot = resolve($contents);
    } else {
        $slot = new HtmlString($contents);
    }

    return array_merge(
        $this->componentData[count($this->componentStack)],
        ['slot' => $slot],
        $this->slots[count($this->componentStack)]
    );
}

resolve(...) fetches something out of the Container. If we store “render” closures in there (starting with something fairly collision-safe, like ƒ-), then we could resolve and use those to render the slot data instead.

Of course, we’d have to put those closures in there to begin with:

Blade::directive('render', function($expression) {
    return "<?php
        \$fn = new class({$expression}) {
            private \$closure;

            public function __construct(\$expression)
            {
                \$this->closure = \$closure;
            }

            public function __invoke(...\$params)
            {
                return (\$this->closure)(...\$params);
            }
        };

        \$hash = spl_object_hash(\$fn);
        \$name = \"ƒ-{\$hash}\";

        app->instance(\$name, \$fn);

        echo \$name;
    ";
});

This means we’d be able to change that render prop from being a component prop to being a child:

@component('components.list', ['items' => $items])
    @render(function($item) {
        return view('components.list-item', [
            'item' => $item
        ]);
    })
@endcomponent

We could take this even further, making @render accept a closure or a string view name and optional array of view parameters.

This seems like a much nicer way to compose components, but I don’t know whether it’ll catch on or not. For now, I’ve proposed it on the ideas repo. I guess we’ll see…