DEV Community

Helen Liutongco
Helen Liutongco

Posted on

A Simple(ish) Application of Javascript Generators in React w/ Redux

For about a year, I knew what Javascript generators were, but had no experience using them in the wild. I saw an opportunity to try them out when working on a game called Status Quote, which displays a series of video clips one-by-one.

This blog post presumes that you understand the basic syntax of a generator function, so if you are new to this topic, please check out my blog post describing the basics of generators.

The Goal

The end goal was to use a generator function when iterating through the collection of videos. The ability to pause the function (via the yield keyword) would come in handy for pausing the iteration in order to allow each video to finish playing before moving onto the next video.

The Setup

All this code is written inside a component called GameContainer. This component uses the generator function to render an array of VideoPlayer components.

First, we import an array of objects from a separate file and assign this array to a variable called 'clips'. Each object in the array contains information about a video clip: import {clips} from '../clips'

Second, we save two keys inside of state:

  state = {
    counter: 0,
    generatedObj: {value: null}
  }
  • The counter will be used to keep track of which element we want to grab inside the array of VideoPlayer components; this number is essentially the index number of the current element in the array.
  • The generatedObj key will keep track of the plain object that is returned from the generator object. In other words, this key stores the return value of .next() when it's called on the generator object.

We will use three lifecycle methods: componentDidMount, componentDidUpdate, and render.

We will also create two helper methods: one for creating the generator object and another for using the generator object.

Creating the Generator Object

Let's begin with creating a helper method called 'createVideoGenerator'.

Inside of this method, the first thing we'll want to create is the array of VideoPlayer components. We map over the 'clips' array in order to create a new array of components: const videoPlayers = clips.map((clip) => <VideoPlayer key={clip.id} clip={clip}/>)

Next is the generator function itself. Let's post the code in its entirety and then break it down line-by-line.

  createVideoGenerator = () => {
    const videoPlayers = clips.map((clip) => <VideoPlayer key={clip.id} clip={clip}/>)

    function* nextVideo(array){
      while(this.state.counter < array.length) {
        this.setState({ counter: this.state.counter + 1 })
        yield array[this.state.counter]
      }
    }
  }

Let's start with the first line: function* nextVideo(array){

This is simply the function declaration. The generator function is named nextVideo. When we invoke this function later on, the array argument that we pass in will be the videoPlayers array.

Next: while(this.state.counter < array.length) {

This is where we will use the counter that we are saving in state. If the counter is less than the array's length, this means there are still more VideoPlayer components that need to be rendered to the page.

Next: this.setState({ counter: this.state.counter + 1 })

Inside of the while loop, we increment the counter by 1 and then save this new number to state.

Lastly: yield array[this.state.counter]

Finally, we use the yield keyword to signify when the code should pause. In this case, the code should pause the while loop after returning the current element in the array.

// Caveat

You might have noticed something odd about those last two lines of code. After all, setState is asynchronous. This means that in this line of code: yield array[this.state.counter], we are not using the updated counter but rather the previous counter before the setState finished running. For instance:

// this.state.counter => 0 

this.setState({ counter: this.state.counter + 1 })
// after setState: this.state.counter => 1

yield array[this.state.counter]
// this.state.counter => 0

This still works because we want to return the array before incrementing the counter. In reality, it would be more accurate if we could reverse the order of those two lines of code:

yield array[this.state.counter]
this.setState({ counter: this.state.counter + 1 })

We want to first use the current value of the counter before using setState to increment the counter. Unfortunately, if incrementing through the array causes a re-render, then this will not work. In the case of my application, incrementing through the array causes a change in the Redux state, which causes a re-render in the GameContainer component. This means that any code after yield will never be run.

My workaround is to take advantage of the asynchronous nature of the setState function. Because it is asynchronous, the yield will always run before the setState is resolved. So in a sense, we are still using setState after the yield. It's a little hack-y, but it works!

// End Caveat

The last part of the createVideoGenerator function involves two steps:

  • Bind the context of the nextVideo generator function
  • Invoke the generator function

Inside of the nextVideo generator function, when we use the 'this' keyword (e.g. this.state), the value of 'this' needs to be the GameContainer component. Thus we need to use .bind in order to bind the nextVideo function to the context of GameContainer: this.nextVideo = nextVideo.bind(this)

Lastly, we invoke the nextVideo function, pass in the videoPlayers array as an argument. This line will also be the return value of the createVideoGenerator function, since a generator function returns a generator object.

This is the full code for our createVideoGenerator function:

  createVideoGenerator = () => {
    const videoPlayers = clips.map((clip) => <VideoPlayer key={clip.id} clip={clip}/>)

    function* nextVideo(array){
      while(this.state.counter < array.length) {
        this.setState({ counter: this.state.counter + 1 })
        yield array[this.state.counter]
      }
    }

    this.nextVideo = nextVideo.bind(this)
    return this.nextVideo(videoPlayers)
  }

Using the Generator Object

Next up, we'll make another helper function that uses the generator object that we created in createVideoGenerator. Let's call this function useGenerator. Here's the code for this function in its entirety:

  useGenerator = () => {
    this.setState({ generatedObj: this.createVideoGenerator().next() }, () => {
      if(!this.state.generatedObj.value){
        this.props.handleChange("ENDED")
      }
    })
  }

After declaring the useGenerator function, we setState using createVideoGenerator as a helper function to access the generator object. Let's take a closer look at the object we're passing as the first argument of setState:

{ generatedObj: this.createVideoGenerator().next() }

First, we invoke the createVideoGenerator function. The return value is the generator object. Generator objects have access to the function .next, which allows the code inside the generator function to continue running after pausing from the yield keyword.

So what's the return value of this whole line of code: this.createVideoGenerator().next()? It's another plain object! The object might look something like this: { value: <VideoPlayer/>, done: false }

As you can see, this object has a key called 'value', which holds the value of whatever we yield-ed in the generator function. In this case, the value key will hold either one of two things:

  • a VideoPlayer component
  • null

The value is null when the generator function completely finishes iterating through the videoPlayers array. We then save this object in the generatedObj key in state.

Let's take a look at the second argument that we're passing to setState:

() => {
      if(!this.state.generatedObj.value){
        this.props.handleChange("ENDED")
      }
    }

This is a callback that uses the value of the generatedObj in state. If generatedObj is null, we send data to the Redux state. This data essentially signals to other components that we have finished displaying all the videos.

And that's it! To recap, here is the code for both createVideoGenerator and useGenerator:

  createVideoGenerator = () => {
    const videoPlayers = clips.map((clip) => <VideoPlayer key={clip.id} clip={clip}/>)

    function* nextVideo(array){
      while(this.state.counter < array.length) {
        this.setState({ counter: this.state.counter + 1 })
        yield array[this.state.counter]
      }
    }

    this.nextVideo = nextVideo.bind(this)
    return this.nextVideo(videoPlayers)
  }

  useGenerator = () => {
    this.setState({ generatedObj: this.createVideoGenerator().next() }, () => {
      if(!this.state.generatedObj.value){
        this.props.handleChange("ENDED")
      }
    })
  }

Using the Helper Methods

Now that we have built out the helper methods, it's time to actually use them! For this part, we will utilize the componentDidMount and componentDidUpdate lifecycle methods.

The overall idea is to call the userGenerator function both when the component mounts (the very first video) and also whenever there is a change in props that signifies that the next video should be played (every video after the first one).

This is what the code looks like:

  componentDidMount(){
    this.useGenerator()
  }

  componentDidUpdate(){
    if(this.props.changeNextVideo){
      this.props.toggleChangeNextVideo(false)
      this.useGenerator()
    }
  }

In componentDidUpdate, changeNextVideo is a boolean that is stored in the Redux state. I set things up so that changeNextVideo toggles to true inside the VideoPlayer component whenever a video ends. Inside the above if statement, it toggles back to false. Finally, we invoke useGenerator() again in order to retrieve the next VideoPlayer component in the videoPlayers array.

Summary

Let's recap everything we did:

Creating the Generator Object

  createVideoGenerator = () => {
    const videoPlayers = clips.map((clip) => <VideoPlayer key={clip.id} clip={clip}/>)

    function* nextVideo(array){
      while(this.state.counter < array.length) {
        this.setState({ counter: this.state.counter + 1 })
        yield array[this.state.counter]
      }
    }

    this.nextVideo = nextVideo.bind(this)
    return this.nextVideo(videoPlayers)
  }
  • We created a helper function called createVideoGenerator. This function contains a generator function inside of it.
  • The generator function accepts an array as an argument. It contains a while loop that increments a counter during each iteration and continues running as long as the counter is not greater or equal to the length of the array argument.
  • Inside the while loop, we increment the counter and save it to state. Then the generator function yields an element of the array, using the counter as an index number.
  • Lastly, we bind the context of this to the GameContainer component and then invoke the generator function, passing the array of VideoPlayer components as the argument.
  • The return value of this function is the generator object.

Using the Generator Object

  useGenerator = () => {
    this.setState({ generatedObj: this.createVideoGenerator().next() }, () => {
      if(!this.state.generatedObj.value){
        this.props.handleChange("ENDED")
      }
    })
  }
  • We create another helper function in order to retrieve the generator object that is returned in createVideoGenerator.
  • We take the generator object and call the .next method, and save the resulting plain object to our state.
  • This plain object allows us to access the value that was yield-ed in the generator function (i.e. a VideoPlayer component). If this value is null, that means we have iterated through the entire videoPlayers array and we can finally end this functionality.

Using the Helper Methods

  componentDidMount(){
    this.useGenerator()
  }

  componentDidUpdate(){
    if(this.props.changeNextVideo){
      this.props.toggleChangeNextVideo(false)
      this.useGenerator()
    }
  }
  • We call the useGenerator function when the component mounts and also whenever the changeNextVideo prop (which is in the Redux state) toggles from false to true.
  • This allows the first VideoPlayer component in the videoPlayers array to render right when the GameContainer mounts, and it also allows the rest of the VideoPlayer components to render one after another.

And that's one application of a generator function in React with Redux! Of course, there are many different (and probably simpler) ways of achieving this same functionality without using a generator function. This purpose of this little experiment wasn't to write the most efficient code, but rather to satisfy my curiosity for using a generator function inside an actual web app. I hope you're inspired to try using generator functions in your own apps!

Top comments (2)

Collapse
 
gsto profile image
Glenn Stovall • Edited

for the issue with state being Async, you can also use a callback function to ensure you are setting it to the correct value.

    function* nextVideo(array){
      let nextVideo = null
      while(this.state.counter < array.length) {
        this.setState((prevState) => {
            nextVideo = array[prevState.counter + 1]
            return { counter: prevState.counter + 1 }
        })
        yield nextVideo
      }
    }
Collapse
 
tonixtuft profile image
Anton Bagdatyev (Tonix-Tuft) • Edited

You are yielding null initially on the very first iteration. Therefore I guess this code won't work because of this test:

  useGenerator = () => {
    this.setState({ generatedObj: this.createVideoGenerator().next() }, () => {
      if(!this.state.generatedObj.value){ // <--- this.state.generatedObj.value will be null instead of the first yielded video React element
        this.props.handleChange("ENDED")
      }
    })
  }