Build Your Own Observable, Part 2: Containers, and Intuition

In this short article, we’ll spend a little more time building up an intuition for what observables are, and why we even need them in the first place.

Build Your Own Observable, Part 2: Containers, and Intuition

Welcome to part 2 of this series on building your own observable. If you haven’t already checked out part 1 of this series, please do so. It covers a lot of foundational material on Arrays that will come in handy here.

In this short article, we’ll spend a little more time building up an intuition for what observables are, and why we even need them in the first place.
With the proper background and mindset, the code for an observable will start to write itself.

A good mental model for observables is that they are like asynchronous arrays. Think about the Array.prototype.forEach method. What does it do? It takes a callback function, and it executes it once for each item in the array. This is done synchronously.

const arr = [1,2,3,4,5];

arr.forEach(element => console.log(element));

// 1,2,3,4,5

Nothing shocking here. Let’s introduce some asynchronicity.
Think about how this following code works:

const someDomElement = document.getElementById('someDomElement');

const handle = someDomElement.addEventListener('click', (event) => { console.log('got a click event'); });

// assuming the user clicks on someDomElement 5 times, you'll see
// got a click event
// got a click event
// got a click event
// got a click event
// got a click event

We’ve passed a callback function to addEventListener, which is called each time a click event happens on the element with the id someDomElement. It’s almost like that handler is called once for each time a click event happens…

addEventListener acts like a forEach function for this DOM element, which is called each time a click event happens. The only conceptual difference between this example and Array.prototype.forEach is that the event listener happens asynchronously.

But the mental model is essentially the same; If only we had some nice container that we could compose over like arrays, and that could also handle asynchronous data like events…

As we’ll see, we’ll be able to compose observables, and use functions like map and filter, similar to what we did with arrays in part 1.

Let’s pause and take this comparison a step further, to make sure we go into the observable implementation in the next article with the proper mindset.

Imagine that you are building a Netflix clone. Netflix has lots of movies. Here’s a not-so-random selection of four movies, as they might be represented in the application which inexplicably recommends only 1980s films. Since JSON is cumbersome, let’s also represent those movies with colored circles.

Movies represented as colored circles for convenience.

Now, there is currently nothing special about these movies. Assume that we just happened to glance at this part of our database, and found these four movies. There’s no special relationship among them. No context.

But, now, let’s put them in a container. Let’s use an array to hold all of these movies.

Placing these movies into an array gives them a logical relationship. For instance, could be a user’s favorite movies.

Now, we have something. This array could represent the favorited movies of our favorite fictional user, Mary. Or they could represent the four most popular movies of the 80s. The point is, we now have some logical context for thinking about these movies since we have containerized them in an array. It means something to us when we look at that array. Hey, those are Mary’s favorite films!

But there’s more. There’s also an engineering benefit to containerizing these movies. Since Array.prototype provides methods like map and filter, we can now perform aggregate operations on the movies by composing methods on their container.

By placing the movies inside of a collection, we can now compose operations on that collection, such as map, filter, concat, etc.

Just want the titles? map away. Only want films directed by the late, great John Hughes? filter to your heart’s content.

But what about events? JavaScript didn’t give us a way to containerize events! What if we need to make an XHR request to get each one of those movies from our API layer? How can we containerize values that arrive over time?

A stream of values arriving over time.
A stream of values arriving over time.

Why, by using observables, of course. This is the fundamental benefit of observables, they let us containerize and compose asynchronous events.

Observables let us containerize an async stream of events. In this case, a user’s list of favorited movies.

In the case where this observable represents Mary’s favorited videos, this is perfect! Maybe she’s currently clicking through the app, favoriting videos like crazy. With an observable, we can model this as a stream, and constantly keep the UI updated with a list of her favorited videos.

Asynchrony shall not floor us!

And of course, since observables come with plenty of operators, we can now compose operations over that stream.

If this style of composition is new to you, here’s a trick that may help you develop some intuition for it when working with arrays.

Avoid loops, unless you have no other choice.

Why? Check this out.

Consider that task of filtering a collection of movies by director, and mapping to only their names and year:

favoriteMovies.filter(movie => movie.director === "John Hughes")
              .map(movie => ({name: movie.name, year: movie.year}));

// We end up with a container with the values:
// { name: "The Breakfast Club", year: 1986 }
// { name: "Ferris Bueller's Day Off", year: 1987 }
//

Now, ask yourself the following:

Is the collection (favoriteMovies) that code operates on synchronous, or asynchronous?

You don’t really know, do you?

Well, that’s the whole the point! Arrays have map and filter, but I just said observables have those as well. Sure, if I added this line:

const favoriteMovies = [{}...{}];

Then you’d know it’s operating over a synchronous array. But just glancing at the operations being performed (a filter, then a map), you have no idea what their implementation details are. You just know a container is being filtered, then mapped. Could be async, could be sync. This is the power of choosing to write declarative code over imperative code.

Contrast the above with this:

let favoriteMovies = [
  { title: ‘The Breakfast Club’, year: 1986, director: ‘John Hughes’,
  cast: [ ‘Ally Sheedy’, ‘Emilio Estevez’, ‘Judd Nelson’]
  },
  { title: “ferris buellers day off”, year: 1987, director: ‘John Hughes’,
  cast: [ ‘Matthew Broderick’, ‘Mia Sara’, ‘Alan Ruck’]
  },
  { title: ‘Wargames’, year: 1983, director: ‘John Badham’,
  cast: [ ‘Matthew Broderick’,‘Ally Sheedy’]
  } 
];

let filteredAndMappedArray = [];

for (let i = 0; i < favoriteMovies.length; i++) {
  if (favoriteMovies[i].director === "John Hughes") {
    let mapped = {
      name: favoriteMovies[i].name,
      year: favoriteMovies[i].year
    };
    
    filteredAndMappedArray.push(mapped);
  }
}

// We end up with a (very synchronous) container with the values:
// { name: "The Breakfast Club", year: 1986 }
// { name: "Ferris Bueller's Day Off", year: 1987 }
// ...but we could've done better :)

Big surprise there! Loops are about as imperative as it gets, and they are practically etched into the synchronous world. You cannot hope to reason about loops at a higher level, because you’ll always be bogged down with implementation details.

As we’ll see, observables free us from this burden when we are dealing with async data.

Summary

  • Collections like arrays and observables make it easier for us to compose operations, like map and filter
  • We can free ourselves from implementation details by using a more declarative, composable approach like map and filter, as opposed to writing imperative loops

In next part, we will look at the Observer pattern, as implemented by RxJS, and then we’ll start writing some creational methods for our observable class.

Part 3: Observer Pattern and Creational Methods