URL-driven data visualizations

Use state hoisting to make our D3 chart responsive to the URL.

Transcript

In the last video we added a new interaction to our bar chart component. Users can now click on a bar to select it, and it will highlight itself and show this tooltip here.

Right now, the bar chart component manages all of this state internally. We can pass in an initial value for selectedLabel, but all of the logic around the click interaction is managed inside the component. And it's all transient - so, if we come here and click one of these bars and then refresh the page, all of that state is reset.

In this video we want to make our charts sharable via URLs. To do this, we'll first need to refactor our charts to follow the data-down-actions-up pattern. And once we do this, we'll be able to hoist the selectedLabel property outside of our bar chart component, and use it at the controller level to bind to our URL's query parameters. And this will make our charts stay in sync with the URL. So, let's get started.

First, let's come to our bar chart component, then come down to our click handler. Right now it toggles the component's own selectedLabel property. So first, let's comment this out, and instead let's have this click event handler invoke an on-click method, passing in the clicked label.

.on('click', data => {
  let clickedLabel = data.label;

  this.get('on-click')(clickedLabel);

  // if (clickedLabel === this.get('selectedLabel')) {
  //   this.set('selectedLabel', '');
  // } else {
  //   this.set('selectedLabel', clickedLabel);
  // }
  //
  // this.updateOpacities();
});

Now, this lets callers of this component pass in an action to the on-click named property, so that they can customize what happens when a bar is clicked. Let's save this, and we'll open our route's template. And for our first chart here, let's try out our new on-click property. And we'll just make a simple action that will mutate a controller property called selectedAuthor. And then we can use that new property here: if we have selectedAuthor, let's display it.

{{bar-chart data=authorData
  on-click=(action (mut selectedAuthor))}}

<h2 class='f5 mt3 mb4'>
  Authors
  {{#if selectedAuthor}}
    ( {{selectedAuthor}} )
  {{/if}}
</h2>

And now when we click on the chart, we set the selectedAuthor property on our controller, and we're displaying it right here. So our new action is working great.

But, during this refactor we also broke part of our chart's functionality: the bars no longer highlight when you click on them. And that functionality is being driven by the selectedLabel property.

Let's come back to our template, and we'll pass our new selectedAuthor in as the selectedLabel.

{{bar-chart data=authorData
  selectedLabel=selectedAuthor
  on-click=(action (mut selectedAuthor))}}

This way, when the click action happens, and we mutate this value, we pass it back into the bar chart, so that it knows the value has changed. And this is the basic data-down-actions-up flow.

Now let's come back, and we'll try this out. Now it seems like the tooltip is sticking to the selected bar, but the bar is not not being highlighted.

If we come back to our bar chart component, we see that we commented out this updateOpacities() line down here, which we used to invoke after our original action updated the internal state of the selectedLabel. But now there's a new way that selectedLabel can be change - from our caller passing in a new attribute.

What we really want is updateOpacities to be called whenever that attribute changes. So let's come down here, and define a didUpdateAttrs hook where we can call updateOpacities anytime our bar chart component's attributes change.

didUpdateAttrs() {
  this.updateOpacities();
}

Let's save this, and try it out. Okay nice, our bars are being highlighted again.

So this line ensures that if a new selectedLabel property is ever passed in, our chart will render correctly.

Now, if we look at the app, we're able to select new bars, but we don't have the toggle functionality we had before. If we click on a bar that's already selected, nothing happens. And this is just because our chart component is now a "dumb component", and it just invokes the on-click action that we passed in whenever a bar is clicked.

So, let's come back to our route's template, and we'll make this action a bit smarter, so we can get that toggle behavior back. Instead of just muting the selectedAuthor property, let's write a new action called toggleBar, and we'll use this to perform the toggling logic of our selectedAuthor.

Let's open the controller, come down to actions, define toggleBar. This takes in the new label, and our original action was just to update the selectedAuthor to that new label.

Let's save this, and make sure we have all of this wired up correctly. Ok this is working, but we still don't have the toggle behavior. Let's come back, and now we'll reimplement the toggle logic. The new value will depend on the current value of selectedAuthor. If that current value equals the clicked label, then we'll null out the value; otherwise, we'll update it to the new value. And we'll set this here. And this should toggle the selectedAuthor property.

{{bar-chart data=authorData
  selectedLabel=selectedAuthor
  on-click=(action 'toggleBar')}}
toggleBar(label) {
  let newValue = this.get('selectedAuthor') === label ? null : label;

  this.set('selectedAuthor', newValue);
}

Selected, and deselected. Great!


Okay, let's recap what we've done here so far.

You might have heard the term "smart components" and "dumb components", and we've made our bar chart component "dumber" by telling it to invoke the passed-in on-click handler whenever a bar is actually clicked. We then manage this action and the corresponding state change in the parent controller, here and here.

Sometimes you'll hear this referred to as state hoisting - we want some part of our UI tree up here to interact with some piece of state that's lower in the tree, so we hoist the state up to the lowest common ancestor in our component tree, and then pass it down as an attribute to any nested components that need to read from it. This is really the data-down-actions-up pattern in a nutshell.

Now there is a tradeoff here. If we look at our other two bar chart invocations, right here and right here, they're very simple. These components were smart, so the callers got the toggling behavior of the bars for free. But now that we've made our bar chart dumber, callers have become responsible for reimplementing that logic themselves. So, state hoisting makes your components more flexible, but it can also make them less convenient to use in certain situations.

Now, if we come back to our app, open the console, and try to click on our other charts, we'll see an error. And this is because we've written our component to expect that every caller would pass in an on-click action. Usually I like to signal this in my components by coming up to the top, writing on-click, and setting it as a null property. And this just tells other developers that they can pass this in.

'on-click': null

But if we come back down to our click event, we can actually make this component a bit nicer to use. Why don't we make this event handler first check to see if an on-click action was passed in; and if it is, we'll invoke it, but otherwise we'll just run our original logic.

So here we'll check if this.get('on-click') is defined, run it; otherwise, run our original logic.

.on('click', data => {
  let clickedLabel = data.label;

  if (this.get('on-click')) {
    this.get('on-click')(clickedLabel);
  } else {
    if (clickedLabel === this.get('selectedLabel')) {
      this.set('selectedLabel', '');
    } else {
      this.set('selectedLabel', clickedLabel);
    }

    this.updateOpacities();
  }
});

If we save this and come back to our app, now we can interact with our other bar charts, just like before - but all that state is managed internally by the component. And our first bar chart is using the passed-in action, so the component is just delegating all that work to the caller.

This is a pretty nice pattern. If you ship your components with reasonable default behavior and managed internal state, it can make them really nice to use. But when callers need more control, you just give them a way to opt-in to that control, and then they can manage all of that state themselves. And in our case the way they opt-in is by passing in an on-click action.


Ok, let's come back to our template, and up here we'll get rid of this selectedAuthor bit, and let's work on getting these other two charts to hoist their state up to the controller.

Now right now, our toggleBar action is setting the selectedAuthor property directly. So instead of writing two new actions for selectedCategory and selectedPost, let's come to the controller action, and we'll make this action take in the property as the first parameter.

toggleBar(property, label) {
  let newValue = this.get(property) === label ? null : label;

  this.set(property, newValue);
}

And now we can use this action for all three charts. We'll save this, come to the template, and up here on line 7, we'll just pass in the property name of selectedAuthor.

on-click=(action 'toggleBar' 'selectedAuthor')

And let's make sure this works for our first chart. Nice.

Now this actually works because the action helper has the ability to curry functions. So this expression here grabs the toggleBar action, but returns a curried function with the first parameter bound to selectedAuthor. That way our bar chart component gets a function that takes in the second parameter here, which will be the label, which is why within our component, we're able to just invoke the on-click action with the single label parameter.

So this is pretty nice that we can partially apply functions at the template level - because now we can just grab this, come here, do the same thing for selectedCategory and for the selectedPost.

{{bar-chart data=categoryData color='green'
  selectedLabel=selectedCategory
  on-click=(action 'toggleBar' 'selectedCategory')}}

{{bar-chart data=commentsData color='red'
  selectedLabel=selectedPost
  on-click=(action 'toggleBar' 'selectedPost')}}

If we come back to our app, now all three charts are working with the toggling behavior, but the bar chart components are delegating all their state changes and event handling to the parent. This means all the state is owned by the controller - so these three properties all live on the controller now, which means we can bind them to the URL using query params.

So let's come to our controller, we'll come up here to the top, we'll define the queryParams array, and we'll add the selectedAuthor, selectedCategory and the selectedPost.

queryParams: ['selectedAuthor', 'selectedCategory', 'selectedPost'],

And now when we click on a bar, the URL shows us that the selected category is Programming! As well as the author, and the post title. And we can even navigate back and forth to drive these changes using the URL only. And if we refresh the page, the initial render of our charts is correct! So that's pretty cool.

Now we have one small bug here. When we toggle a bar off, we see that the URL shows null for the value, and that breaks our charts.

Whenever using query params on a controller, we want to set their default values here. So for these, we'll make them all null.

Save this, let's clear these out. And now we select one, it updates, we click on it again, it goes away. Refresh the page, and everything is working. Back and forward work as well.

And now our D3 visualizations are all being driven by the URL!


In this video we hoisted the selectedLabel state out of our chart, and used the data-down-actions-up pattern to give callers more control over how they can use our components. In our case, we stored the clicked label as properties on the controller, and then we bound them to the query params for this route. But you can think of other use cases too: so maybe when a user renders your component and then clicks on a bar, the application needs to first make an ajax request before re-rendering some part of your screen.

The point here is that by making our bar chart component "dumber" and refactoring it to follow the data-down-actions-up pattern, it has become more flexible and can accommodate more use cases that other developers may have for it in the future, that we couldn't have anticipated when we originally wrote the component.

Now, one thing I wanted to point out is that even though we hoisted the selectedLabel property outside of our bar chart, our chart component still manages its own internal hoveredLabel property, and we can see this because when we hover over a chart that doesn't have anything selected, the tooltip still follows it and part of the chart re-renders.

Now, this is perfectly fine - this state is local to the component instance and it's not needed or mutated by any other object in our system. There's no reason that callers should be responsible for reimplementing the hovering logic of our chart each time they render it, so leaving this state as local component state is the right move here.

In the next video, we'll use our new flexible charts to filter down the posts that we show in our main table.

Questions?

Send us a tweet:

Or ask us in Inside EmberMap, our private Slack workspace for subscribers.