Select2 Autocomplete with AJAX

We love Select2, but ran into a bit of an issue using it with AJAX on a recent project. Here's how we got it working.

If you're looking for a really great selection box tool, Select2 is very strong option. It's easy to set up, easy to customize, and works great no matter what device your user is on. Even better, it's got built-in AJAX support, so, as the web site says "you can efficiently search large lists of items."

In our case, we were using Select2 within a Meteor application (our application framework of choice), so instead of using jQuery's default AJAX tools, we needed to use the optional transport option. Select2's documentation shows how to do that here. Combined with their documentation for a standard AJAX plugin, it seems like it should be pretty easy to get working, right?

Not Quite##

Our goal was to autofill a ZIP code dropdown from the list of approximately 43,000 potential codes. We'll start with the transport function we wrote to talk to our database. This is a Meteor call, but you could use whatever transport you wanted.

transport: function(params, success, failure) {
  Meteor.call('Zipcodes.match', params.data.q, function getZip(err, results) {
    if (err) {
      failure(err);
      return;
    }
    success(results);
  });
}

Zipcodes.match will return an array of ZIP codes that match the digits we send it. Now we just naïvely plug it into the outline in the AJAX documentation like so:

$(".js-data-example-ajax").select2({
  ajax: {
    transport: function(params, success, failure) {
        Meteor.call('Zipcodes.match', params.data.q, function getZip(err, results) {
          if (err) {
            failure(err);
            return;
          }
          success(results);
        });
      }
    },
    processResults: function (data, params) {
      // parse the results into the format expected by Select2
      // since we are using custom formatting functions we do not need to
      // alter the remote JSON data, except to indicate that infinite
      // scrolling can be used
      params.page = params.page || 1;

      return {
        results: data.items,
        pagination: {
          more: (params.page * 30) < data.total_count
        }
      };
    },
    cache: true
  },
  escapeMarkup: function (markup) { return markup; }, // let our custom formatter work
  minimumInputLength: 1,
  templateResult: formatRepo, // omitted for brevity, see the source of this page
  templateSelection: formatRepoSelection // omitted for brevity, see the source of this page
});

And then nothing works.

Why?##

Ok, so what did we do wrong? Well, first of all, we didn't do a very good job reading! We glazed right over omitted for brevity, see the source of this page in the templateResult and templateSelection lines. So it doesn't have a template. That must be the issue, right? It's pretty easy to fix, we'll just make a super-simple function for formatting the ZIP codes. Since we only need plain strings, we don't even need the escapeMarkup function that was also causing problems.

function formatZip(zip) {
  const markup = `${zip.text}`;
  return markup;
}

Unfortunately, this doesn't do the job either! When we tried the widget, there weren't any results.

Why (again?)##

It turns out there's one more bit we need, and it comes, again, from reading the comments in the code.
facepalm
Specifically, it's this bit Parse the results into the format expected by Select2 since we are using custom formatting functions we do not need to alter the remote JSON data, except to indicate that infinite scrolling can be used. While it's not immediately clear what Select2 requires, clearly there's some need to alter the remote JSON data.

It took us a fair amount of googling to figure out what exactly needed to happen (and we're embarrassed to admit we can't find the author to credit for this solution). It turns out that Select2 wants an array of objects, not just simple array entries. Each object needs an id property and a text property. id is a numeric identified, and text is the actual content of the entry. That meant that we needed to take the array that we received from Zipcodes.match and transform it into an array of objects with the id and text. Here's what we did:

function formatZip(zip) {
  const markup = `${zip.text}`;
  return markup;
}

$('#zip_search').select2({
  // This way we don't try to search 43,000 ZIPs on one key entry
  minimumInputLength: 3,
  multiple: false,
  ajax: {
    transport: function(params, success, failure) {
      Meteor.call('Zipcodes.match', params.data.q, function getZips(err, results) {
        if (err) {
          failure(err);
          return;
        }
        success(results);
      });
    },
    processResults: function(data) {
      const results = [];
      data.forEach(function makeResults(element, index) {
        results.push({
          id: index,
          text: element
        });
      });
      return {
        results: results
      };
    }
  },
  templateResult: formatZip,
  templateSelection: formatZip
});

Wrapping Up##

This took us a while to get right, but the results are pretty amazing. There's almost no lag time between the third keypress and a dropdown full of suggested autocompletions:
zoooom
Now, how did we get that ZIP code into a location? That's Mapbox. It's a great service and is super easy to use. That said, we did have to do some gymnastics to accommodate three- and four-digit ZIP codes—maybe we'll make that our next post.