Implementing Dynamic Multiselect with Laravel Livewire and Alpine using ChoicesJS

Implementing Dynamic Multiselect with Laravel Livewire and Alpine using ChoicesJS

I recently had a need to populate the available options for a multiselect component with values from a remote API. ChoicesJS supports this, though it's documentation leaves much to be desired. As such, a new post detailing how I made my dynamic multiselect work seemed important.

What exactly do I mean by "dynamic multiselect" you ask? Simply that, it's a select component allowing multiple choices, whose options are fetched - dynamically - from somewhere else, not preloaded up front.

The scenario here is that my users need to be able to select one or more resources, but they may have hundreds or thousands in the list of available resources, so loading all the options up front to populate the list of selectable options was out of the question. That would have greatly simplified things of course.

So, what is the desired functionality, and how do you build it?

I'm glad you asked. The desired behavior is that when a user enters their search term into the input field ChoicesJS presents us, we want to trigger an API call that will fetch the matched resources and populate our list of available options, allowing them to make one or more selections. Then, they can optionally search again, selecting more options, and so forth.

Since we're using Livewire, all of the api interaction is going to happen server side, the beauty of this is that no credentials need to be exposed to the client side, and any heavy data manipulation will also occur server side.

Ok, first things first, we need a livewire component

<?php

namespace App\Http\Livewire;

use Livewire\Component;
use MyApi\Client;

class Select extends Component
{
  public $options = [];
  public $selections = [];

  public function render()
  {
    return view('livewire.select');
  }
}

That's the basics done, but we also need to write a method to call when the user executes a search:

public search($term)
{
  $results = Client::search($term);

  $preserve = collect($this->options)
                ->filter(fn ($option) =>
                    in_array(
                      $option['value'],
                      $this->selections
                    )
                  )
                ->unique();

  $this->options = collect($results)
                     ->map(fn ($item) => 
                             [
                               'label' => $item->name,
                               'value' => $item->id
                             ])
                     ->merge($preserve)
                     ->unique();

  $this->emit('select-options-updated', $this->options);
}

What's happening here?

  1. We pass the entered search phrase to the search functionality of our api client. This could also be something as basic as a whereLike query against a laravel model.
  2. We look at the existing options, and pluck out any that are currently selected.
  3. We map the relevant data from the search results into the format expected by our ChoicesJS widget, and then merge in the options that we grabbed in the previous step.
  4. Emit an event so that the JS widget can update its options list.

Pretty straightforward... but why step 2, and merging results of it into the search query results?

Well, because the way we have choices wired up, it isn't aware of choices that aren't presently in the available options list. You could probably store the entire object in there if you really wanted to, but then you'd be hauling around a lot more data on subsequent request/response calls... and this is pretty easy - and neat.

That's the PHP side sorted, now on to the view

@props([
  'options' => [],
])

@once
  @push('css')
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css" />
  @endpush
  @push('js')
    <script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js"></script>
  @endpush
@endonce

<div x-data="{}" x-init="">
  <select x-ref="select"></select>
</div>

There's our basic view framework stubbed out. Notice the use of @once, and @push to push the requisite css & js into relevant stacks, and ensure they're only added one time, so if you had multiple instances of this component on you page you wouldn't be polluting the DOM with several copies of the same script. Glorious. It's all the little niceties like this that make me love Laravel...

Ok, so we're loading the choices script and styles, now we need to point them at our lonely little select element and make it go. One piece at a time here..

Lets flesh out the x-data

x-data="{
  value: @entangle('selections'),
  options: {{ json_encode($options) }},
  debounce: null,
}"
  • The value gets entangled, linked with, the livewire state.
  • options get populated with any pre-defined values we may have set by default in our php component
  • debounce will be used as a target to track a setTimeout() to debounce user input, you don't really need to define it, but for clarity I've included it.

Now the meat of the implementation, the x-init

x-init="this.$nextTick(() => {
  const choices = new Choices(this.$refs.select, {
    removeItems: true,
    removeItemButton: true,
    duplicateItemsAllowed: false,
  })

  const refreshChoices = () => {
    const selection = this.value

    choices.clearStore()

    choices.setChoices(this.options.map(({ value, label }) => ({
      value,
      label,
      selected: selection.includes(value),
    })))
  }


  this.$refs.select.addEventListener('change', () => {
    this.value = choices.getValue(true)
  })

  this.$refs.select.addEventListener('search', async (e) => {
    if (e.detail.value) {
      clearTimeout(this.debounce)
      this.debounce = setTimeout(() => {
        $wire.call('search', e.detail.value)
      }, 300)
    }
  })

  $wire.on('select-options-updated', (options) => {
    this.options = options
  })

  this.$watch('value', () => refreshChoices())
  this.$watch('options', () => refreshChoices())

  refreshChoices()
})"

Alright there's a bunch going on there, but nothing to terribly complicated.

  1. We instantiate an instance of ChoicesJS, pointing at the select element we previously created by it's x-ref value 'select', with some basic config.
  2. Create a function that will refresh the available options whenever we call it, iterating through options and setting selected to true for any items whose value is in our selections wire model bucket.
  3. Set up an event listener to sync the selections whenever a change event fires.
  4. Set up an event listener to call our PHP search method, debounced with a 300ms timeout.
  5. Set up a listener for the livewire event that we emit when we've updated the options on the server side, and accordingly update them on the client side.
  6. Set up watchers that call our refresh function when the selections or options have changed.
  7. Finally, fire the refresh function to setup the initial state of the choicesJS widget

This is all wrapped in a $nextTick because the markup and JS libraries/etc need to be on the page before it will work.

I can't take credit for all of this, Caleb nicely outlined how to work with the choicesJS library along with alpine in an integrations write up on the alpineJS site. But the search and $wire.on bits are all me.

So that's it. When you type into the choicesJS widget, it'll hit the api client and fetch results, those results will populate into the choices list. When you make a selection it'll work the same is if it'd been part of a pre-populated list all along. When you run a new search, previously selected elements will be merged with new search results so that the prior selects stay visible in the choicesJS widget.

You probably noticed there's no wire:model defined on the select element... well, that's because we're managing the state with alpine, so it's not necessary. choicesJS is going to override that select element anyway.

Our final livewire component & view

Livewire PHP Component

<?php

namespace App\Http\Livewire;

use Livewire\Component;
use MyApi\Client;

class Select extends Component
{
  public $options = [];
  public $selections = [];

  public function render()
  {
    return view('livewire.select');
  }

  public search($term)
  {
    $results = Client::search($term);

    $preserve = collect($this->options)
                  ->filter(fn ($option) => 
                    in_array(
                      $option['value'],
                      $this->selections
                    )
                  )
                  ->unique();

    $this->options = collect($results)
                       ->map(fn ($item) =>
                         [
                           'label' => $item->name,
                           'value' => $item->id
                         ])
                       ->merge($preserve)
                       ->unique();

    $this->emit('select-options-updated', $this->options);
  }
}

Blade View

@props([
 'options' => [],
])

@once
  @push('css')
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js/public/assets/styles/choices.min.css" />
  @endpush
  @push('js')
    <script src="https://cdn.jsdelivr.net/npm/choices.js/public/assets/scripts/choices.min.js"></script>
  @endpush
@endonce


<div
  wire:ignore

  x-data="{
    value: @entangle('selections'),
    options: {{ json_encode($options) }},
    debounce: null,
  }"

  x-init="
    this.$nextTick(() => {
      const choices = new Choices(this.$refs.select, {
        removeItems: true,
        removeItemButton: true,
        duplicateItemsAllowed: false,
     })

     const refreshChoices = () => {
       const selection = this.value
  
       choices.clearStore()

       choices.setChoices(this.options.map(({ value, label }) => ({
         value,
         label,
         selected: selection.includes(value),
       })))
     }

     this.$refs.select.addEventListener('change', () => {
       this.value = choices.getValue(true)
     })

     this.$refs.select.addEventListener('search', async (e) => {
       if (e.detail.value) {
         clearTimeout(this.debounce)
         this.debounce = setTimeout(() => {
           $wire.call('search', e.detail.value)
         }, 300)
       }
     })

     $wire.on('select-options-updated', (options) => {
       this.options = options
     })

     this.$watch('value', () => refreshChoices())
     this.$watch('options', () => refreshChoices())

     refreshChoices()
   })">

  <select x-ref="select"></select>

</div>

Final Thoughts

This component could, and probably should, be cleaned up to be more reusable. Making the event listened to definable as a prop passed into the blade component and so forth, but this should give you enough of an overview to get something like it working on your project.

As always, huge thanks to the community that makes any of this possible. Laravel, Livewire, AlpineJS and Tailwind truly make development a dream - I am a huge huge fan of the TALL stack!


Share Tweet Send
0 Comments