I would actually avoid doing that particular thing, as it’s doing the same thing as doing it in the constructor
does, and you end up needing to do extra “juggling” to keep state in sync over time—there’s no way to set it back to using whatever the parent value is.
One solution is to do something like this instead:
export default class Example extends Component {
// This means we always have the data if we need it, and it doesn't
// change over the life of the component. We could *change* that to
// have it dynamically updated to "now" whenever the getter below is
// invoked if that were the desired behavior instead.
#constructedAt = DateTime.now();
@tracked _startDate: DateTime;
get activeStartDate() {
const local = this._startDate;
const fromArgs = DateTime.fromISO(this.args.activeStartDate);
return local ?? fromArgs ?? this.#constructedAt;
}
@action setActiveStartDate(date: DateTime) {
this._startDate = date;
}
@action finalize() {
let value = this._startDate;
this.value = null;
this.args.save(value);
}
}
That makes it so you use any local value if it’s set, any passed-in default if the local value is not set, or a default if neither is set, and saves the value only when you’re ready—and resets the local state at that point as well. Note that this never sets local tracked state to a copy of the external tracked state. That kind of “forking” of tracked state is super difficult to keep in sync as the code evolves over time. Instead, this approach just knows how to merge local and external tracked state, which can even be extracted into a single “pure” function (no mutation, just inputs → outputs) and tested directly if it’s complicated enough.
However, while this is an improvement, that last action smells to me: if I forget to null out the local value, the behavior locally will be buggy. As a result, I actually prefer to push all of this state management back up to the parent. While it can feel nice to have the local class “encapsulating” its state, in my experience any change to how this state works involves changing both components… which means that it isn’t actually the right boundary; “code that changes together should live together.” Here’s a simplified example (using <template>
for convenience, and with slightly different mechanics than your code but in a way that hopefully translates pretty easily):
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
const Child = <template>
<form {{on "submit" @save}}>
<label>
Some data:
<input {{on "change" @change}} value={{@value}} />
</label>
<button type="submit">Save</button>
</form>
</template>;
const DEFAULT_VALUE = 'some default value';
export default class Parent extends Component {
@tracked state;
@tracked draftState;
get valueToUse() {
return this.draftState ?? this.state ?? DEFAULT_VALUE;
}
@action change({ target: { value } }) {
this.draftState = value;
}
@action save(event) {
event.preventDefault();
this.state = this.draftState;
this.draftState = null;
}
<template>
<Child
@value={{this.valueToUse}}
@change={{this.change}}
@save={{this.save}}
/>
</template>
}
This still means you have to keep straight the relationship between state
and draftState
, but this way that relationship all lives together. If at some you point stop needing that distinction, or if at some point you need to add more complexity to how it works, that’s all in one place.
It’s very intuitive to try to put draft form state “local” to a component, but in practice it seems to just end up causing more churn, and I think it’s precisely because it isn’t actually the case that the state relationship is local to the form field (or similar) in question. You can see that even more clearly in the case where saving should fail if the data isn’t valid: in that case it’s trivial for the Parent
class to just not clear out the draftState
, but doing that in the case where the draft state lives in the child is… well, it’s difficult at best.