Skip to content

Instantly share code, notes, and snippets.

@antishow
Last active May 18, 2018 18:41
Show Gist options
  • Save antishow/809a875383d30084f87b674b4b8db546 to your computer and use it in GitHub Desktop.
Save antishow/809a875383d30084f87b674b4b8db546 to your computer and use it in GitHub Desktop.
Promises and Schrodinger’s Asynchronous Operation

Promises and Schrodinger’s Asynchronous Operation

We’ve talked before about using Promises to write better asynchronous code and avoid “Callback Hell”, but it’s always a struggle to come up with a good example of Promise code that doesn’t feel super contrived.

BUT! Recently I made an update to the Duck Creek Content Exchange project that involved a change to one piece of logic in the middle of a chain of operations. In this moment, I knew my perfect Promise example had come.

Then I had to make yet another change that involved turning a simple synchronous operation into one that involved sometimes calling a third-party API, and I knew I made the right choice.

The Gist

The Content Exchange works pretty much like our Resource Center, but instead of displaying a grid populated from the WP REST API and a custom post type, it uses data from a third-party API called MindTouch.

There isn’t a huge amount of content, so to keep it feeling snappy we pre-load all of the data on the server side and then pass it to the front-end as JSON. All of the filtering and pagination are done in Javascript, so it happens instantly instead of making AJAX call every time the filter changes.

My first shot at implemeting this resulted in a monolithic function that worked, but had some conceptual issues.

The Code

export function getContentExchangePages(gridState) {
    //Grab a copy of the data (defined elsewhere in the module)
    let filteredData = [...DATA];

    //If any filters are in the grid state, only keep data items
    //that have at least one tag in common with the filter
    if (gridState.filter && gridState.filter.length > 0) {
        filteredData = filteredData.filter(
            p => intersection(gridState.filter, p.tags).length > 0
        );
    }

    //Then sort the filtered data
    let sortedData = filteredData.sort((a, b) => {
        /*
        Clipping out the implementation details for brevity's sake.
        Sort based on gridState.sortBy
        */
    });

    if (gridState.sortDir === 'desc') {
        sortedData = sortedData.reverse();
    }

    //Finally, just grab the page we want
    let startIndex = (gridState.page - 1) * gridState.postsPerPage;
    let total = sortedData.length;
    let ret = {
        results: sortedData.splice(startIndex, gridState.postsPerPage),
        total
    };

    //An earlier version of this was using AJAX, and I didn't want to rewrite
    //the code that was calling this function, so I'm still returing a Promise
    return Promise.resolve(ret);
}

Why This Function Sucks

This function is called getContentExchangePages(), but based on the actual body of the function a better name might be getContentExchangePagesButThenAlsoFilterAndSortAndPaginateThem(). Remember that it’s always better to favor code that composes many pieces of simple code over a small number of complicated ones.

A Better Way

First let’s just consider in plain-english what’s being done here. I have an array of “pages”. Then, I apply a filter to those pages to just get the ones matching my grid filter. Then, I sort the pages I have left. Then, I paginate the sorted pages and return that list of pages.

Instead of one function for this, use several: filterPages(), sortPages(), pagingatePages(). Now each of those functions is simple to write and understand.

function filterContentExchangePages(pages, filter) {
    let ret = pages;

    if (filter && filter.length > 0) {
        ret = pages.filter(p => intersection(filter, p.tags).length > 0);
    }

    //Looking ahead, I know these are going to be chained operations, so let’s
    //return a Promise instead of just the raw list.
    return Promise.resolve(ret);
}
function sortContentExchangePages(pages, sortBy, sortDir) {
    let sortFunction = null;
    //These sortPagesByX functions are just simple sort functions I’ve defined
    //elsewhere. The details of how they work aren’t important
    switch (sortBy) {
        case 'featured':
            sortFunction = sortPagesByFeatured;
            break;

        case 'name':
            sortFunction = sortPagesByName;
            break;

        case 'date':
            sortFunction = sortPagesByDate;
            break;

        default:
            sortFunction = () => 0;
            break;
    }

    let sortedData = pages.sort(sortFunction);

    if (sortDir === 'desc') {
        sortedData = sortedData.reverse();
    }

    return Promise.resolve(sortedData);
}
function paginateContentExchangePages(pages, pageNumber, postsPerPage) {
    let total = pages.length;
    let startIndex = (pageNumber - 1) * postsPerPage;

    //Be careful using splice, it mutates the array!
    //I use the spread operator here to splice a copy of the array instead.
    return Promise.resolve({
        results: [...pages].splice(startIndex, postsPerPage),
        total
    });
}

Bringing Them Together

Now that I’ve abstracted by filter, sort, and pagination behavior out into Promise-returning functions. The getContentExchangePages() function itself becomes very simple!

export function getContentExchangePages({ filter, sortBy, sortDir, page, postsPerPage }) {
    return filterContentExchangePages(DATA, filter)
        .then(filteredPages => {
            return sortContentExchangePages(filteredPages, sortBy, sortDir)
        })
        .then(sortedPages => {
            return paginateContentExchangePages(sortedPages, page, postsPerPage));
        }
}

I wrapped the function arguments in {}’s so that I can just pass a configuration object like this:

{
   filter: [],
   sortBy: 'featured',
   sortDir: 'asc',
   page: 1,
   postsPerPage: 12
}

And use those keys as variables in the function. This is called “Destructuring”.

Taking Advantage of Promises

Okay, so that was a lot of work just to rearrange my code into smaller pieces and do the exact same thing. Now we can get into why changing it to work like this made it drastically easier for me to implement those all-too-common last-minute business logic changes.

Filtering by OR+AND instead of just OR

Originally the plan was for the filter logic to be simply: “If a page has any of the checked tags, it passes the filter”. Then, two weeks before launch, the client informs us that the filter needs to change to work like: “If a page has at least one tag from EACH group of checked tags, it passes the filter”.

Now that ALL of the filter code was in one easy-to-manage function, this change was super easy to work into the system. All I had to do was change the implementation in filterContentExchangePages() like so:

function filterContentExchangePages(pages, filterState) {
    let ret = pages;

    if (filterState.length > 0) {
        //groupArrayByPrefix() is used to turn ['A:1', 'A:2', 'B:1', 'B:2']
        //into [['A:1', 'A:2'], ['B:1', 'B:2']]
        let tagGroups = groupArrayByPrefix(filterState, ':');

        ret = pages.filter(page => pageMatchesFilter(page, tagGroups));
    }

    return Promise.resolve(ret);
}

//Returns TRUE if the page matches at least one tag in ALL groups
function pageMatchesFilter(page, filter) {
    return filter.reduce(
        (pass, tags) => pass && intersection(tags, page.tags).length > 0,
        true
    );
}

Asynchronous Search

And now we finally come to the raison d’Promises: I needed to add Search functionality. Unfortunately, the API payload we were using for the grid data only contained a limited amount of data and wasn’t suitable for doing a search any fancier than “The search term you entered was found in the page title”.

Luckily, the third-party API had a search endoint I could tap into! All I needed to do was work out a way to work that search into my promise chain. The first thing I did was make an endpoint on the WordPress REST API that would let me pass in a search term, which it would then use to query the external API and return an array of page IDs that matched the search.

With that, I was able to write searchContentExchangePages() like this:

function searchContentExchangePages(pages, searchTerm) {
    if (!searchTerm) {
        return Promise.resolve(pages);
    }

    if (searchTerm === lastSearch) {
        return Promise.resolve(lastSearchResult);
    }

    return fetch(`${window.SiteInfo.homeUrl}/wp-json/cx/search/${searchTerm}`)
        .then(response => response.json())
        .then(pageIds => {
            lastSearch = searchTerm;
            lastSearchResult = pages.filter(p => pageIds.includes(p.id));

            return lastSearchResult;
        });
}

If there’s no search term, or we’re just re-running the search, we can just immediately resolve the Promise with that data. If we need to fetch data from the API, then we return a Promise to do so.

Now to slip it into our original getContentExchangePages()...

export function getContentExchangePages({
    search,
    filter,
    sortBy,
    sortDir,
    page,
    postsPerPage
}) {
    //Currying the filter/sort/pagination functions for sexier code below
    let applyFilter = pages => filterContentExchangePages(pages, filter);
    let applySort = pages => sortContentExchangePages(pages, sortBy, sortDir);
    let applyPagination = pages => paginateContentExchangePages(pages, page, postsPerPage);

    //HNNNNGGGGGG
    return searchContentExchangePages(DATA, search)
        .then(applyFilter)
        .then(applySort)
        .then(applyPagination);
}

Simple! All I had to do was add searchContentExchangePages() to the beginning of the stack so that my filter function operates on the search results instead of my entire database. Because it returns a Promise instead of the raw value, this will work exactly the same whether the search function is async or not!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment