Reacting to changes in parent data

I have a parent controller with a child component. The child has data that will be displayed if the little side caret is toggled. The parent needs to have an expand all/ collapse all option. I have actions set up on the child to expand/collapse, which just switched the “expand” property to true/false. I’ve most recently been using Vue, where I could pass a variable into the child for expanding and collapsing and just watch those values and update the child’s “expand” property as needed. I’m not sure what the Ember way to handle this is. I tried this computed property, but since it’s never used, it is never actually computed:

expanded: computed('expandAll', 'contractAll', function(){
  if (this.expandAll) {
    this.set('expand', true);
  }
  if (this.contractAll) {
    this.set('expand', false);
  }
})

Perhaps because my brain is still in Vue-land, I am not getting anything useful when googling. (This is also not the first time I have tripped on wanting to “watch” a property like happens in Vue. The other time, I just threw the value into a hidden div to make it calculate, which is … not ideal to say the least!)

Hi, there!

Prior to Ember Octane, I think I’d have let the child component have an observer to listen to an external state. (Not a computed property that sets some other value, like you tried in the code example, because this creates a side effect that can be hard to debug.)

In Octane, using the {{did-update}} modifier provides one option—one that follows your current approach. You will need to install the @ember/render-modifiers addon to get this modifier.

Your child component might look like,

{{!-- app/components/my-component.hbs --}}
<li
  {{did-update this.reactToOutsideCommand @outsideCommand}}
>
  <div>
    {{#if this.isExpanded}}
      {{!-- Display one thing --}}
    {{else}}
      {{!-- Display something else --}}
    {{/if}}
  </div>

  <button
    type="button"
    {{on "click" this.toggleIsExpanded}}
  >
    {{if this.isExpanded "Collapse" "Expand"}}
  </button>
</li>
// app/components/my-component.js
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

export default class MyComponent extends Component {
  @tracked isExpanded = true;

  @action reactToOutsideCommand(command) {
    switch (command) {
      case 'collapse-all': {
        this.isExpanded = false;
        break;
      }

      case 'expand-all': {
        this.isExpanded = true;
        break;
      }
    }
  }

  @action toggleIsExpanded() {
    this.isExpanded != this.isExpanded;
  }
}

Then, from your route template, you would invoke the component as follows:

{{!-- app/templates/my-route.hbs --}}
<div>
  <button
    type="button"
    {{on "click" this.setCommand "collapse-all"}}
  >
    Collapse All
  </button>

  <button
    type="button"
    {{on "click" this.setCommand "expand-all"}}
  >
    Expand All
  </button>
</div>

<ul>
  {{#each ...}}
    <MyComponent
      @outsideCommand={{this.command}}
    />
  {{/each}}
</ul>

Doh, I forgot to mention that I am pre-Octane. (3.2.9, I fear.) I’ll look into observers. I never delved into them because the documentation was all “Don’t use these really!” and so I haven’t dug into them yet. :slight_smile:

While the solution @ijlee2 posted will work, there’s actually a much simpler solution which doesn’t involve the modifiers at all (and see my note below after my suggested solutions)—and, more importantly, which keeps us in the world of one-way data flow.

The key here is identifying the owner of the state you care about. In this case, it’s a bit complicated because if its the parent—the controller—then the controller has a lot of extra state to manager; but if it’s mixed—the controller and the child component—then there’s a bit to do to keep them in sync properly.

Assuming that what you actually have here is a case where the parent controller can open or close all children, but children can be opened or closed independently, there are two ways you might approach this:

  1. The controller entirely manages the state, and the children simply render the state accordingly. (This is a useful pattern you should know, but isn’t the one I’d prefer here.)
  2. The child’s state is a mix derived from the controller’s state and its own internal state. (This is the one I recommend!)

1. Controller owns all state

In that scenario, you might have something like this on the controller (I’m assuming a well-known list of children, but you could do something similar if that was populated dynamically with an array):

import Controller from '@ember/controller';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';

class ChildOpenState {
  @tracked child1 = false;
  @tracked child2 = false;
  @tracked child3 = false;
}

export default class Parent extends Controller {
  isOpen = new ChildOpenState();

  @action toggleChild(child, state) {
    this.isOpen[child] = state;
  }
  
  @action toggleAll(state) {
    Object.keys(this.openChildren).forEach((key) => {
      this.openChildren[key] = false;
    });
  }
}

Then in the template you might have something like this:

<button {{on "click" (fn this.toggleAll true)}}>open</button>
<button {{on "click" (fn this.toggleAll false)}}>close</button>

<Child
  @data={{@model.child1}}
  @isOpen={{get this.isOpen 'child1'}}
  @open={{fn this.toggleChild 'child1' true}}
  @close={{fn this.toggleChild 'child1' false}}
/>

<Child
  @data={{@model.child2}}
  @isOpen={{get this.isOpen 'child2'}}
  @open={{fn this.toggleChild 'child2' true}}
  @close={{fn this.toggleChild 'child2' false}}
/>

<Child
  @data={{@model.child3}}
  @isOpen={{get this.isOpen 'child3'}}
  @open={{fn this.toggleChild 'child3' true}}
  @close={{fn this.toggleChild 'child3' false}}
/>

The child component might look like this:

<button {{on "click" @open}}>open</button>
<button {{on "click" @close}}>close</button>

{{#if @isOpen}}
  {{!-- show the data in the open state --}}
{{/if}}

Notice that in this case, the child component might not even need a backing class: it could be a purely presentational “template-only component”!

2. A mix of ownership

The other option you have is for the child to set its own internal state, but to revert to whatever the parent passes in when it changes again. The @localCopy decorator from tracked-toolbox does this for you automatically!

In that case, your controller might look like this:

import Controller from '@ember/controller';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';

export default class Parent extends Controller {
  @tracked allOpen = false;

  @action toggleAll(state) {
    this.allOpen = state;
  }
}

Its template would look very similar to the one before, but with a simpler invocation for the children:

<button {{on "click" (fn this.toggleAll true)}}>open</button>
<button {{on "click" (fn this.toggleAll false)}}>close</button>

<Child
  @data={{@model.child1}}
  @isOpen={{this.allOpen}}
/>

<Child
  @data={{@model.child2}}
  @isOpen={{this.allOpen}}
/>

<Child
  @data={{@model.child3}}
  @isOpen={{this.allOpen}}
/>

The component template would also be very similar to before, but it would refer to internal state and actions instead of to arguments:

<button {{on "click" this.open}}>open</button>
<button {{on "click" this.close}}>close</button>

{{#if this.isOpen}}
  {{!-- show the data in the open state --}}
{{/if}}

You’d add a backing class like this:

import Component from '@glimmer/component';
import { localCopy } from 'tracked-toolbox';

export default class Child extends Component {
  @localCopy('isOpen') isOpen;

  @action open() {
    this.isOpen = false;
  }

  @action close() {
    this.isOpen = false;
  }
}

This has the advantage of being a bit clearer about the relationship between parent and children, and is probably the one I’d pick between the two. The slight downside is that you’re adding a dependency to manage it.

However, you can implement the same logic yourself, it’s just a bit more to write out and keep track of!

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class Child extends Component {
  // A private property we use to track the last thing the parent
  // passed in. (`#foo` is the syntax for a private class field.)
  #previousParentState;

  // The component's *own* notion of whether it should be open
  // or closed.
  @tracked _isOpen;

  // The state we use in the template.
  get isOpen() {
    // If the last value we have from the parent and the value it has
    // now don't match, we update that value and set our own local
    // state tracking back to what the parent passed in.
    if (this.#previousParentState !== this.args.isOpen) {
      this.#previousParentState = this.args.isOpen;
      this._isOpen = this.args.isOpen;
    }

    // Because we've done that check and reset the value, we can now
    // return it safely, guaranteed it's up to date with both our own
    // changes and the latest thing the parent component passed in.
    return this._isOpen;
  }

  // When we open or close the component *internally*, we override
  // the state that's passed in, by referring to `_isOpen`.
  @action open() {
    this._isOpen = true;
  }

  @action close() {
    this._isOpen = false;
  }
}

You can see and play around with a working version of this in this Ember Twiddle.

Summary

In both of these scenarios, we’ve turned everything into true one-way data flow.

  • In the controller-owns-it-all scenario, the child component can be incredibly simple, but at the cost of pushing complexity into the controller: it has to understand much more about the state of the children.
  • In the mixed ownership scenario, where the controller owns the global open/closed state and the children own their own open/closed state, the controller gets to be much simpler and the child has to do a bit of extra juggling to keep itself in sync with its parent.

But in both cases, all you have to do is define the source of truth (or root state) with @tracked and then derive the rest of your state from it in getters and arguments to components.


Addendum: Note on modifiers

My rule of thumb is that if you’re using a modifier but the method you invoke with it doesn’t use the DOM element at all… you should refactor to a different solution! Modifiers exist to provide a bridge between Ember’s one-way data flow and declarative templates and the imperative DOM APIs.

1 Like

Ah, I just saw your reply about being pre-Octane after posting my answer. Two notes:

  1. Don’t use observers! You’ll just end up replacing them when you do migrate to Octane. :sweat_smile:

  2. You can do exactly the same kind of thing I showed in my example but using classic computed properties and get and set instead of using autotracking. It will work exactly the same way!

    For example, here’s how the second example with the component would look in Ember Classic back on Ember 3.2:

    import Component from '@ember/component';
    import { action, computed, set } from '@ember/object';
    
    export default Component.extend({
      _previousParentState: null,
      _isOpen: false,
      
      // Note here that we've made a different name than `isOpen` since classic
      // components don't have a separation between arguments and properties.
      isLocallyOpen: computed(
        '_isOpen',
        '_previousParentState',
        'isOpen',
        function() {
          // Here we can still use `this.<value>` to *get* values, but since we
          // don't have auto-tracking, we need to use `set`
          if (this._previousParentState !== this.isOpen) {
            set(this, '_previousParentState', this.isOpen);
            set(this, '_isOpen', this.isOpen);
          }
      
          // Because we've done that check and reset the value, we can now
          // return it safely, guaranteed it's up to date with both our own
          // changes and the latest thing the parent component passed in.
          return this._isOpen;
        }
      ),
    
      actions {
        open() {
          set(this, '_isOpen', true);
        },
    
        close() {
          set(this, '_isOpen', false);
        },
      },
    });
    

    This has mostly the same semantics and (more importantly!) will behave exactly the same, and when you migrate forward to Octane, all you’ll have to do is mark the root state as @tracked, remove @computed, switch the base class, and simplify things by having the args namespace!

I’m going to add onto @chriskrycho and @ijlee2’s excellent answers here with this example that I worked through recently: Ember Twiddle

In this Twiddle, we have a checkbox tree component. These are pretty common in UIs, especially parts of windows:

In this component, the state of a parent checkbox is dependent on the state of its children. This presents a bit of a communication problem, because parents render before children, but we don’t know the state of the children until after we finish rendering them (assuming we want the children to be able to take in arguments directly).

We could just model this as one component that we pass a JSON tree structure into, but that means we have to spend a lot of time thinking about how JSON maps into a component tree, and turns the API into a very config heavy one.

Alternatively, we could model it by using actions and observers to mutate state directly. Essentially, the children would either use observers or lifecycle hooks like didReceiveAttrs to propagate changes upward, and modify the state of the parent. This is tricky, because again the parent renders the state first, but it’s possible with enough finagling.

With Glimmer components though, we can model this a different way, more like @chriskrycho’s 1st solution. The parent checkbox owns all of the state, and manages the state of its children. It does this by having the children register their arguments when they first render. Since the args object is just tracked state, it’s perfectly valid to pass it up to the parent and hand it off, essentially. The parent then bases its own state on the state of the children’s args directly, so it can figure out if it is indeterminate or not.

The end result is a very clean solution IMO, and only needs a single extra render pass on initial render to make it all work (whereas other solutions would require an extra render pass for each render). I think we could also work on a way to make it so that we don’t even need that second render pass.

1 Like