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:
-
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.)
-
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.