Extending Collections
Toby Parent
Posted on December 16, 2022
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 )
})
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
}
}
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
}
}
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
}
}
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}*`))
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)
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
}
}
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;
}
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
}
}
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)
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;
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!
Posted on December 16, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.