Managing State in Polymer 2.0 - Beyond Parent / Child Binding

Share state between separated DOM elements without Redux

Comments Posted by Captain Codeman in web-development on Jul 6, 2017

To demonstrate how simple a framework is to use and how productive you can be with it, the examples provided are often deliberately simple to fit the easy-use cases. It makes sense to keep things simple for people learning but it can sometimes leave a gap when you need to step beyond the trivial and start building more complex, real-world apps.

One place where this seems to be especially apparent is when it comes to state-management and Polymer is of course no exception. The typical examples you’ll see most often involve a single parent and one or more child elements and some binding between them. But what if things are not so simple? How do you pass state between things? Do you need to start adding Redux to do it?

You’ll often hear principles such as “properties down, events up” and “use the mediator pattern” and these definitely make sense but can miss some nuance - they work within a group of elements that operate together as a unit, but they are less applicable when your elements need to share state but they are separated in the DOM, possibly not even in the same branch of the tree. You also don’t really want to be passing a chain of property bindings all the way down through elements that don’t care and shouldn’t be concerned with them, just to reach the element deep down that does.

Some frameworks have come up with specific solutions to this, some rather heavyweight:

Angular 2, erm 2+, or 4 (whatever it is up-to now) would suggest you keep the state in a shared service, available to components that need it through dependency injection.

React would push you more to having a single central store using Redux with views that components can bind to.

Both of these approaches are fundamentally a type of singleton. Now some people are adamant that “singletons are bad” (the implication being that they must be avoided) which is incorrect - the whole point of shared state is for there to be “a single truth” of something. Of course any design pattern can be misused but the real issue with singletons is managing the access to the state and keeping everything interested in-sync, not the fact that the state is being shared.

The browser already provides us with a collection of singletons such as window and we can stuff our own state in there. It works great as a name-spacing system as long as you pick something likely to be unique to your apps (using “googleMaps” for your own lib might not be a good idea). So why not do this? Well, the problem becomes the synchronization - if we set a value, how does something somewhere else know that it’s changed? We really don’t want to poll on a timer (!) and firing and subscribing to lots of events could be a little cumbersome.

Let’s stop talking about theory and look at a concrete example to explore the options. Suppose we want to have an ‘options panel’ in our Polymer Starter Kit app-drawer-layout (highlighted in red):

options panel in app-drawer-layout

Although the view that’s consuming and displaying the option is oh so close on the screen (“look, code, it’s right THERE!”), it’s a million miles away from it DOM-wise with various iron-pages, app-drawer, headers and other pieces in between.

Here’s the code for the options panel:

<link rel="import" href="../bower_components/polymer/polymer-element.html">
<link rel="import" href="../bower_components/paper-checkbox/paper-checkbox.html">

<dom-module id="my-options">
  <template>
    <style>
      :host {
        display: block;
        padding: 16px;
      }
      h3, p {
        margin: 8px 0;
      }
    </style>
    <h3>Options</h3>
    <p>
      <paper-checkbox checked="{{ options.subscribe }}">Send Notifications</paper-checkbox>
    </p>
  </template>

  <script>
    class MyOptions extends Polymer.Element {
      static get is() { return 'my-options'; }

      static get properties() {
        return {
          options: {
            type: Object,
            value: () => ({
              subscribe: false
            })
          }
        }
      }
    }

    window.customElements.define(MyOptions.is, MyOptions);
  </script>
</dom-module>

We’d like to make the options property object value available elsewhere so if anything needs to access the subscribe child property (or anything else we add) it can do so and it will be kept updated with any changes, but we don’t want to just have everything public - we want to control access to it.

We could of course start looking at using something like Redux and while it is definitely an option, it also comes with a considerable price in terms of additional build requirement and code complexity. It does have some very compelling features and if it makes sense to use it in your app for other reasons, it can be a good choice but it can also be overkill and can introduce as many issues as it solves, especially when added to a library that hasn’t been built around it’s design.

One of the reasons to love Polymer is that it is built on the platform so we’ll see what we can achieve just using it and some plain JavaScript.

This is a good point to reach for an IIFE or Immediately Invoked Function Expression which provides a way for us to run some code and declare some variables but without exposing them. This is the basic structure wrapping our class declaration - it declares a function and then immediate executes it (exactly like it says on the tin):

(function() {
  // existing code
}());

So that effectively doesn’t really change anything other than hiding our MyOptions class from the outside world (which doesn’t matter, because the only thing we care about seeing it is notified via the call to window.customElements.define).

There is only going to be one instance of MyOptions in our app and other elements are going to need a way to get access to it, so we’ll add a variable to reference it and set the reference in the constructor when the element is created:

let optionsInstance = null;

// in class definition:
constructor() {
  super();
  if (!optionsInstance) optionsInstance = this;
} 

The optionsInstance is still hidden within our IIFE but now anything else we put in there can access the instantiated MyOptions instance.

We want the instance to be in charge of the options value so we’ll need it to keep track of subscribers that are interested. To do this we’ll add an array property to track them and instance methods they can use to register and unregister themselves:

// in properties:
subscribers: {
  type: Array,
  value: () => []
}

// in class definition:
register(subscriber) {
  this.subscribers.push(subscriber);
  subscriber.options = this.options;
  subscriber.notifyPath('options');
}

unregister(subscriber) {
  var i = this.subscribers.indexOf(subscriber);
  if (i > -1) this.subscribers.splice(i, 1)
}

Note that when a subscriber registers, we add them to the list but also initialize their local reference to the options object. This is where we also get into Polymer change notification - setting the property itself doesn’t notify the subscriber that it’s there which is why we need the notifyPath call. We’re also going to want to notify subscribers when any sub-properties of the options object are changed (i.e. if “subscribe” is toggled, not just the object reference being changed) and for that we use an observer with a wildcard to say that we want “all changes”:

static get observers() {
  return [
    'optionsChanged(options.*)'
  ]
}

optionsChanged(change) {
  for(var i = 0; i < this.subscribers.length; i++) {
    this.subscribers[i].notifyPath(change.path);
  }
}

The notification part is simple - whatever the change path was for the call to our observer is the same path we need to notify our subscribers so we simply loop through them and call notifyPath for each.

We now have the hooks and notifications we need for subscribers to use and we have two options. Let’s start with creating an accessor element which goes within the same IIFE (so it can find the optionsInstance):

class MyOptionsValue extends Polymer.Element {
  static get is() { return 'my-options-value'; }

  static get properties() {
    return {
      options: {
        type: Object,
        notify: true
      }
    }
  }

  connectedCallback() {
    super.connectedCallback();
    optionsInstance.register(this);
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    optionsInstance.unregister(this);
  }
}

window.customElements.define(MyOptionsValue.is, MyOptionsValue);

The connected / disconnected callbacks are the perfect places to register and unregister these instances. Doing this means that elements that might be widely far apart in the DOM tree can keep direct references to each other and avoid the chain of property binding if we limited ourselves to using the DOM structure for communication.

An instance would be used within an element by importing it:

<link rel="import" href="my-options.html">

and binding to it:

<my-options-value options="{{ options }}"></my-options-value>
<p>Send notifications option is: <b>[[ options.subscribe ]]</b></p>

The two-way (child to parent) binding denoted with the {{ }} brackets is why we needed the notify: true option set in the property declaration. The MyOptions instance tells the MyOptionsValue instance(s) about the change, and they need to in turn notify whichever element has included them.

This works and we can check and uncheck the box and the view updates but we have an extra element, extra binding and have to add the options property to each view element if we don’t want lint warnings about undefined properties:

class MyView extends Polymer.Element {
  static get is() { return 'my-view'; }

  static get properties() {
    return {
      options: {
        type: Object
      }
    }
  }
}

oh look, yet another “options” property …

One way to make things a little simpler is by using a mixin. A mixin is similar to class inheritance and provides a way to combine element definitions so code can be re-used instead of being repeated (they were previous known as behaviors in Polymer 1.0).

Instead of our view element creating an accessor element and binding to the options property that it provides, our view element becomes an accessor element - it has it’s own options property and handles registering / unregistering itself with no extra code beyond adding the mixin to the class definition:

class MyView extends MyOptionsMixin(Polymer.Element) {
  static get is() { return 'my-view'; }
}

We still need to import my-options.html but our view is also simpler without needing the intermediate accessor element:

<p>Send notifications option is: <b>[[ options.subscribe ]]</b></p>

So now, anytime an element needs access to the options value, we just need to add the mixin to it to provide the property that will be automatically kept updated. No redux required.

This approach actually has a name and is called the “mono-state” pattern. There are some existing elements like iron-meta that provide a generic approach but I find it simpler, clearer and quicker to make implementations specific to the app - they are often easier to adapt to specific cases and seem simpler to understand than having more intermediate components to follow.

Here’s the final, complete code for our options classes which hopefully looks simpler. I should also apologize for using “subscribe” as the option name which in hindsight might be slightly confusing with the instance subscriptions (?). I did originally have it called “notify” which was even worse (as it’s the name of a Polymer property option):

<link rel="import" href="../bower_components/polymer/polymer-element.html">
<link rel="import" href="../bower_components/paper-checkbox/paper-checkbox.html">

<dom-module id="my-options">
  <template>
    <style>
      :host {
        display: block;
        padding: 16px;
      }
      h3, p {
        margin: 8px 0;
      }
    </style>
    <h3>Options</h3>
    <p>
      <paper-checkbox checked="{{ options.subscribe }}">Send Notifications</paper-checkbox>
    </p>
  </template>

  <script>
    (function() {

      let optionsInstance = null;

      class MyOptions extends Polymer.Element {
        static get is() { return 'my-options'; }

        static get properties() {
          return {
            options: {
              type: Object,
              value: () => ({
                subscribe: false
              })
            },
            subscribers: {
              type: Array,
              value: () => []
            }
          }
        }

        static get observers() {
          return [
            'optionsChanged(options.*)'
          ]
        }

        constructor() {
          super();

          if (!optionsInstance) optionsInstance = this;
        }

        register(subscriber) {
          this.subscribers.push(subscriber);
          subscriber.options = this.options;
          subscriber.notifyPath('options');
        }

        unregister(subscriber) {
          var i = this.subscribers.indexOf(subscriber);
          if (i > -1) this.subscribers.splice(i, 1)
        }

        optionsChanged(change) {
          for(var i = 0; i < this.subscribers.length; i++) {
            this.subscribers[i].notifyPath(change.path);
          }
        }
      }

      window.customElements.define(MyOptions.is, MyOptions);

      MyOptionsMixin = (superClass) => {
        return class extends superClass {
          static get properties() {
            return {
              options: {
                type: Object
              }
            }
          }

          connectedCallback() {
            super.connectedCallback();
            optionsInstance.register(this);
          }

          disconnectedCallback() {
            super.disconnectedCallback();
            optionsInstance.unregister(this);
          }
        }
      }
    }());
  </script>
</dom-module>

Consuming view:

<link rel="import" href="../bower_components/polymer/polymer-element.html">

<link rel="import" href="my-options.html">
<link rel="import" href="shared-styles.html">

<dom-module id="my-view2">
  <template>
    <style include="shared-styles">
      :host {
        display: block;
        padding: 10px;
      }
    </style>

    <div class="card">
      <div class="circle">2</div>
      <h1>View Two</h1>
      <p>Ea duis bonorum nec, falli paulo aliquid ei eum.</p>
      <p>Id nam odio natum malorum, tibique copiosae expetenda mel ea.Detracto suavitate repudiandae no eum. Id adhuc minim soluta nam.Id nam odio natum malorum, tibique copiosae expetenda mel ea.</p>

      <p>Send notifications option is: <b>[[ options.subscribe ]]</b></p>
    </div>
  </template>

  <script>
    class MyView2 extends MyOptionsMixin(Polymer.Element) {
      static get is() { return 'my-view2'; }
    }

    window.customElements.define(MyView2.is, MyView2);
  </script>
</dom-module>

NOTE: The example in this blog works because the options UI is always first in the DOM so the accessor subscribers can always find the existing instance. If this isn’t the case then it’s fairly simple to use a function instead so that the first caller creates the single instance - look at the iron-a11y-announcer for an example of this being done.

Also, in case it isn’t clear, although the MyOptionsMixin is defined within the IIFE, it’s actually globally scoped to window which is why the other elements outside the IIFE can see and use it (if we’d put var MyOptionsMixin ... then it wouldn’t be the case, it would only be visible inside it). I should have used window.MyOptionsMixin to make this clearer or, as is more common, used a global namespace (itself a child object of window) in the same way that Polymer itself does. You may already have one - they are useful for storing configuration settings. A safe way to check for and add to it is something like:

MyApp = windows.MyApp || { }
MyApp.MyOptionsMixin = ...

(then you’d always use MyApp.MyOptionsMixin when referencing it)

polymer redux

Pinterest
Reddit
LinkedIn
comments powered by Disqus