DEV Community

Cover image for Extending Collections
Toby Parent
Toby Parent

Posted on

Extending Collections

So in the last article, I wrote about how a Collection is an abstract constructor (not a constructor, but a builder of objects) that, in essence, builds an array with some added functionality. We are using it to store things, but we're also wrapping those stored things with a unique _id tag, so we can find them later.

At this point, we haven't actually made those _id values unique - the downside to using an array for the internal implementation. That is a thing we can address in this one, whether we simply add a check or use a different internal store within the Collection.

What Does That Even Mean?

When we say a thing is Observable, we are saying "this thing will have some actions (or events) that we can observe, and we can act on the result of those actions."

When we write event handlers in javascript:

const handleButtonClick = (event) => {
  event.target.classList.add("someClass");
  console.log(`the ${event.id} button was clicked!`);
}

document.querySelectorAll("button").forEach((button)=>{
  // we *observe* the click event on each button
  button.addEventListener( "click", handleButtonClick )
})
Enter fullscreen mode Exit fullscreen mode

That is adding a listener on (or subscribing to) an event that might, at some point, take place on that <button> element. The button is an Observable, and our handleButtonClick is the observer.

The neat thing is, this is not exclusive to the core javascript language. This is a thing we can do ourselves, and pretty easily. Just... with a little knowledge and application.

A Little Background

In order to make something observable, we need to define at what points our stuff can be observed, and how we want to handle that observation. In the case of the Collection, we don't need to observe the find events, as that isn't changing anything - so perhaps the add, remove and update functions are good observation points.

And how might we do that? We'll steal a page from the Events API, with a string to identify which action and a function to run when that action occurs. If you aren't sure about passing in functions, take a look at Understanding Higher-Order Functions to see what I mean. We'll take this same idea and build upon it.

So, looking at our Collection, we have this:

// helper function to generate random UUID
const createUUID = function b(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,b)}

const Collection = (title='Default Collection', _id=createUUID()) => {
  let stuff = [];

  const add = (item, _id=createUUID() )=> {
    const thing = Object.freeze({_id, data: item})
    stuff = [...stuff, thing];
  }
  const remove = (id) => {
    stuff = stuff.filter(thing => thing._id != id);
  }
  const findById = (id) => stuff.find(thing=>thing._id === id)
  const find = (func) => stuff.filter(({data})=>func(data) );
  const findAll = () => [...stuff];
  const update = (id, updateFunc) => {
    stuff = stuff.map(
      (thing) => thing._id===id ?
        Object.freeze({_id, data: updateFunc(thing.data)}) :
        thing
      );
    return findById(id);
  }

  return {
    get _id(){ return _id; },
    get title(){ return title;},
    add,
    remove,
    find,
    findAll,
    update
  }
}
Enter fullscreen mode Exit fullscreen mode

To begin, we'll need to have some way to keep track of those functions. As we're expecting a string for the action and a function, an object might be useful:


const Collection = (title='Default Collection', _id=createUUID()) => {
  let stuff = [];
  let observers = {};

  const add = (item, _id=createUUID() )=> {
    const thing = Object.freeze({_id, data: item})
    stuff = [...stuff, thing];
    return thing;
  }

  /* and all the rest of our handy-dandy functionality */
  return {
    get _id(){ return _id; },
    get title(){ return title;},
    add,
    remove,
    find,
    findAll,
    update
  }
}
Enter fullscreen mode Exit fullscreen mode

So we will add them to observers as we are given them. But we also need a couple of methods, just like addEventListener and removeEventListener. In our case, we will call them subscribe and unsubscribe, as the pattern we're using is often referred to as Publish/Subscribe or PubSub. So what might those two methods look like?


const Collection = (title='Default Collection', _id=createUUID()) => {
  let stuff = [];
  let observers = {};

  const add = (item, _id=createUUID() )=> {
    const thing = Object.freeze({_id, data: item})
    stuff = [...stuff, thing];
    return thing;
  }

  // when a function is subscribed, we simply add it to the
  //  observers object, in an array stored under the action.
  const subscribe = (action, observerFunction) => {
    if(!observers.hasOwnProperty(action)){
      observers[action]=[];
    }
    observers[action].push(observerFunction)
  }

  // and to unsubscribe, we reverse that. Just as with
  //  event listeners, we can only remove functions with
  //  the same reference by which we subscribed 'em.
  const unsubscribe = (action, observerFunction) => {
    observers[action] = observers[action].filter(
      func => func !== observerFunction
    );
  }

  /* and all the rest of our handy-dandy functionality */
  return {
    get _id(){ return _id; },
    get title(){ return title;},
    add,
    remove,
    find,
    findAll,
    update,
    subscribe,
    unsubscribe
  }
}
Enter fullscreen mode Exit fullscreen mode

That will let us add and remove observer functions, placing them into or removing them out of the observers object as we like. To use it, we might:

const myDictionary = Collection("Toby's Devils Dictionary");
myDictionary.subscribe("add", (item)=> console.log(`**${item.term}**: *${item.definition}*`))
Enter fullscreen mode Exit fullscreen mode

And that would add an inline function to our observers.add array. Note, though, as we've defined it as an inline function, we have no reference to it with which we can remove it! This is exactly the same behavior as we see in Events API, so I'm not too worried about it.

If we wanted to be able to remove it later, we would need to keep a reference:

const logInUpperCase = (item) => console.log(`**${item.term.toUpperCase()}**: *${item.definition}*`);

myDictionary.subscribe("add", logInUpperCase)

// and later, if we wanted:
myDictionary.unsubscribe("add", logInUpperCase)
Enter fullscreen mode Exit fullscreen mode

So long as we have a reference to the function we subscribed, we can always unsubscribe it.

Well Yeah, But What Do We DO With It?

At this point, we have an object of arrays, but we aren't doing anything with them. We're storing them, and they're there, but we haven't actually executed anything yet!

Patience. Here it comes.

When we add something to the Collection, we do a few things:

  • We change the array stored within the Collection,
  • We generate a new _id for the thing we're adding,
  • We return the thing we've added, in its new container.

When we call our "add" observer function, those are the things we will want to pass into it. Do note that the parameters we will pass change, depending on the action. If we're calling the "delete" observer, the data being passed will likely be different.

But in this case, let's work with the "add" observers, and see how we might use them. To begin, we need to tell the add() method about the its observers, if there are any:

const Collection = (title='Default Collection', _id=createUUID()) => {
  let stuff = [];
  let observers = {};

  const add = (item, _id=createUUID() )=> {
    const thing = Object.freeze({_id, data: item})
    stuff = [...stuff, thing];
    observers.add?.forEach(
      /* and in here, we do something for each observer function */
    )
    return thing;
  }


  /* and all the rest of our handy-dandy functionality */
  return {
    get _id(){ return _id; },
    get title(){ return title;},
    add,
    remove,
    find,
    findAll,
    update,
    subscribe,
    unsubscribe
  }
}
Enter fullscreen mode Exit fullscreen mode

Note the syntax of the line I added there: observers.add?.forEach(). That's a pretty funky syntax, when we look at it. What's that question mark doing in there?

The ?. is a pretty neat new feature referred to as Optional Chaining. What it says is, "If the thing before the ? exists, go ahead and run the function after the . - but if the thing before the ? does not exist, stop now and evaluate to undefined." In effect, if we have no add property on observers, we simply stop evaluating the expression and bypass the .forEach() bit.

This is well-supported in modern browsers, but if we wanted to avoid using newish features, we could:

  const add = (item, _id=createUUID() )=> {
    const thing = Object.freeze({_id, data: item})
    stuff = [...stuff, thing];
    if(observers.hasOwnProperty("add")){
      observers.add.forEach(
        /* and in here, we do something for each observer function */
      )
    }
    return thing;
  }
Enter fullscreen mode Exit fullscreen mode

Does exactly the same thing.

Either way, we now have a forEach() we need to handle. And each thing is a function that we've been passed, and that we want to execute, remembering to pass in the values we've outlined.

const Collection = (title='Default Collection', _id=createUUID()) => {
  let stuff = [];
  let observers = {};

  const add = (item, _id=createUUID() )=> {
    const thing = Object.freeze({_id, data: item})
    stuff = [...stuff, thing];
    observers.add?.forEach( (observerFunc) =>
      // we'll pass as arguments:
      //  - the wrapped item,
      //  - the collection as pure data
      observerFunc(thing, {_id, title, collection:[...stuff]} )
    )
    return thing;
  }


  /* and all the rest of our handy-dandy functionality */
  return {
    get _id(){ return _id; },
    get title(){ return title;},
    add,
    remove,
    find,
    findAll,
    update,
    subscribe,
    unsubscribe
  }
}
Enter fullscreen mode Exit fullscreen mode

With that, each time we run add(), we also execute each observers.add function, passing in the arguments we said we would. And just as with the Event API, we can't actually return anything from those functions.

To Recap

So the idea here is to add some functionality to the Collection, letting it execute functions on our behalf at some future time. As we've discussed, this is a Publish/Subscribe pattern, as we "publish" events to which other objects or functions can "subscribe". In a formal sense, this is an Observer pattern.

The usefulness of the pattern might be observed by how we could use it later:

// suppose we've defined each of the parts we'll use
//  as factories (a Collection factory)
//  or imported modules (TodoAppDom and saveToLocalStorage)
const myTodoApp = ((manager, dom, storage)=>{
  // create our Collection,
  const myDictionary = Collection("Toby's Devils Dictionary");

  // set specific parameters for the DOM and storage, perhaps:
  dom.container = document.querySelector('#my-todo-app');
  storage.key = myDictionary._id;

  // listen to collection changes and reflect them to the DOM
  myDictionary.subscribe("add", dom.addTodo );
  myDictionary.subscribe("remove", dom.displayAllTodos );
  myDictionary.subscribe("update", dom.updateOneTodo );

  // listen for those same changes, and update localStorage
  myDictionary.subscribe("add", storage.save );
  myDictionary.subscribe("remove", storage.save );
  myDictionary.subscribe("update", storage.save );

})(Collection('My Todo App'), TodoAppDom, saveToLocalStorage)
Enter fullscreen mode Exit fullscreen mode

If we know the function signature for each of those listeners, then we can write methods on the DOM module or the storage module to pass right in. And, in this case, the function signatures are pretty straightforward:

  • "add", function( wrappedItem, collectionObject)
  • "remove", function(deletedItemId, collectionObject)
  • "update", function(wrappedItem, collectionObject)

So each observer receives the same number of parameters, allowing us to define a saveToLocalStorage object:

const saveToLocalStorage = () =>{
  let key='Default Key';
  const load = () =>
    JSON.parse(localStorage.getItem(key));
  const save = async ( _, collection ) => 
    localStorage.setItem(
      key, JSON.stringify(collection)
    );
  return {
    save,
    load
    set key(value){key=value;}
  }
}
export default saveToLocalStorage;
Enter fullscreen mode Exit fullscreen mode

Note that I haven't defined the observers entirely - you get somehomework! How might the delete observer work? And the update?

How about, what kinds of actions might you be able to trigger on each action? What possibilities have you got? Adding a sound effect on save, perhaps, or causing a flyout notification?

What other actions might make sense? Is there a benefit to having a "beforeadd" and "afteradd" action? Or a more general, "before<action>" and "after<action>" type? How many make sense to you?

Let's have a conversation about this!

Top comments (0)