Zhenghao's Blog

Make a polling search component using generator functions

December 20, 2020

engineering

Intro

Generator is probably the most underused JavaScript feature. Despite its unpopularity, it has been around since ES6 and until this day it has over 96% browser support.

I learned about the number "96%" from this video

People do not use generator functions often partly because the legit use cases for generators are indeed rare. If you are not a library author you probably wouldn’t need to use generators for normal business requirements. But it might also be because generators are hard to understand. For one thing, they are your average functions that follow something called run-to-completion modal, which basically means that the function cannot be stopped before it finishes the last line, and the only way to exit is by returning from it or throwing an error. And if you call that function again, it will begin the execution from the top again.

In contrast, a generator is a function that can stop midway and then continue from where it stopped. Inside of a generator function, we can use the keyword yield to pass results to the outside, as well as entering values for the next iteration. It acts as a doorway so that we can control the output midway through. This feature makes generator functions a great candidate for doing tasks such as producing a sequence of results or being an observer that keeps observing for values and acts when it gets one.

You can read more about Generator functions here

Generators for Infinite Data Streams

Imagine we want to implement a polling search component, which talks to a back end that reacts to a specific search query and then returns results that match with the query as the response. The number of results can potentially be huge. Therefore we might not get an exhaustive list of results back immediately. Instead, we would get a batch of results back along with a token that indicates the next batch to fetch. We are fetching results in batches because we want to show them to the user as soon as possible so that they have something to work with immediately.

To me, this sounds like a good use case for generators: we are constantly polling the back end for more, but only end the function if the back end tells us it’s done.

Here I am using cursor-based pagination as the example, as opposed to offset based pagination. You can check out this blog post to learn their differences

Let’s first define the contracts between the client and the server by explicitly defining the type for what we would get back as the response of the querying.

type PollingResults = {
  results: Results[]
  done: boolean
  nextToken?: string
}

And we have a function called polling that takes two arguments: query and nextToken where query is the user input and nextToken is an optional string provided by the response of the previous iteration of polling.

As discussed before, our generator function would stop if the back end says there are no more results to fetch, which is communicated via the done boolean, or even better, we can some constraints on the front end to preemptively stop polling if, let’s say, we reach a certain amount of results we want to render on the UI.

Therefore, we can write such a generator function as following:

async function* getResults(query) {
  let state, isMax, nextToken, canContinue
  do {
    state = await polling({ query, nextToken })
    ;[isMax, nextToken] = yield {
      results: state.results,
      nextToken: state.nextToken,
    }
    canContinue = !isMax && !state.done
  } while (canContinue)
}

I know that this piece of syntax looks kind of funky.

 [isMax, nextToken] = yield {
      results: state.results,
      nextToken: state.nextToken
    };

As I said, yield opens up a door that can not only return a value to the outside but also accept a value from the outside. Here we are yielding the results along with nextToken as the output, and also we put what gets passed into the generator into a tuple, i.e. isMax and nextToken, where isMax indicates the UI has reached the limit of showing the results, and nextToken is from the previous iteration that we can use to fetch the next batch of results.

And then we invoke the generator function inside of an event handler function.

async function handleChange(event) {
  const value = event.target.value
  const resultsGen = getResults(value)
  let next,
    count = 0,
    nextToken
  do {
    const isMax = count >= 30 // We will stop polling if the number of results we got exceed 30
    next = await resultsGen.next([isMax, nextToken])
    if (typeof next.value !== "undefined") {
      next.value.results.map(append)
      count += next.value.results.length
      nextToken = next.value.nextToken
    }
  } while (!next.done)
}

One thing to note that, not to confuse next.done in the event handler function with state.done in the generator function. The former is returned by the generator function after it hit an implicitly return statement so it returns (instead of yielding) an object { value: undefined, done: true}. The latter is returned by the response from the back end API.

With the generator function implemented, we can append the results on the go with

function append(result) {
  const node = document.createElement("li")
  node.innerHTML = `<a href="${result.href}">${result.title}</a>`
  document.querySelector("#results")?.append(node)
}

Here is the demo you can play with. Excuse the absence of stylings, I suck at this part of web development

Bonus

I have been grinding Leetcode these days to prepare myself for the upcoming interviews. Here is one question that lends itself pretty well to Generators.

The question asks you to implement an iterator to flatten a potentially nested array of integers. The iterator needs to have two methods: 1. next to return the next integer in the flattened array; 2. hasNext to return a boolean that indicates whether or not we are at the end of teh array.

Here is my attempt using Generators. (I did a few modifications just to make the example run locally)

function isInteger(num) {
  return typeof num === "number" && Number.isInteger(num)
}
class NestedIterator {
  constructor(nestedList) {
    this.gen = this.listGenerator(nestedList)
    this.nextVal = this.gen.next()
  }

  hasNext() {
    return !this.nextVal.done
  }

  next() {
    const val = this.nextVal.value
    this.nextVal = this.gen.next()
    return val
  }

  *listGenerator(list) {
    for (const el of list) {
      if (isInteger(el)) yield el
      else yield* this.listGenerator(el)
    }
  }
}

const iterator = new NestedIterator([1, [2, 3], 4, [5, [6]]])

while (iterator.hasNext()) {
  console.log(iterator.next()) // 1, 2, 3, 4, 5, 6
}

The alternative is to use a plain array to hold the flattened array:

class NestedIterator {
  constructor(nestedList) {
    this.arr = []
    flatten(this.arr, nestedList)
  }

  hasNext() {
    return this.arr.length > 0
  }
  next() {
    return this.arr.shift()
  }
}

In my opinion, the first approach with Generators is better than second approach. For one thing, Generators give us the ability to performe lazy evaluation. As a result, it is more memory efficient. We generate only the values that are needed. With normal functions, we needed to pre-generate all the values, in this case pre-flattening the whole array and keep them around in case we use them later. However, with Generators, we can defer the computation till we need it.

Lazy evaluation is an evaluation model which delays the evaluation of an expression until its value is needed. That is, if we don’t need the value, it won’t exist. It is calculated as we demand it.