@jelhan’s answer works, so if you quite like it feel free to run with it—but I disagree with the advice quite strongly, and recommend a very different approach!
The rest of this answer has two parts: a concrete recommendation about how to solve these problems, and then a set of heuristics I use to think about these things, which provide the reasoning for why these are my recommendations. I strongly advise reading both! (I’m also going to extract these heuristics and work to get them integrated into a set of heuristics I can publish elsewhere as a more general resource.)
Recommendation
In both of these cases, I would actually take a step back and think about what your root state actually is, and then update your actions accordingly.
In Case 1, it’s clear from your description that you want to reach for getters because your mental model here is that the states are derived from each other. But I don’t think they are. It’s rather that there is a relationship between them. All of them are root state, but the root state is actually the composition of some related pieces of data: the base value, the multiplier, and the combination of the two into a final value. My solution here would be to lean into that, and to treat them as a coherent set of top-level state. Then your updates to that top-level state can be in terms of a single “pure” function, where you pass in the previous state and an update and produce a new state. Here’s how that might look (using TS to clarify the data relationships, but of course you can do exactly the same in normal JS):
interface Data {
base: number;
multiplier: number;
final: number;
}
class UI extends Component {
@tracked data: Data = {
base: 1,
multipler: 1,
final: 1,
};
@action update(type: keyof Data, value: number) {
// see below!
this.data = update(type, value, this.data);
}
}
// Given the previous `Data` and a specific value to update, produce new `Data`
function update(type: keyof Data, value: number, prev: Data): Data {
switch(type) {
case 'base':
return {
base: value,
multipler: prev.multiplier,
final: base * prev.multiplier,
};
case 'multiplier':
return {
base: prev.base,
multipler: value,
final: prev.base * value,
};
case 'final':
return {
base: value / prev.multiplier,
multipler: prev.multiplier,
final: value,
};
default:
throw new Error(`bad type "${type}" for update`);
}
}
At a practical level, you could equally do that with three separate actions on the backing class and/or three standalone functions to compute the update (updateBase
, updateMultiplier
, and updateFinal
), but I like this approach, because it keeps together the things which change together. The template ends up just looking like this:
<input
value={{this.data.base}}
{{on "change" (pick "target.value" (fn this.update "base"))}}
>
*
<input
value={{this.data.multiplier}}
{{on "change" (pick "target.value" (fn this.update "multiplier"))}}
>
=
<input
value={{this.data.final}}
{{on "change" (pick "target.value" (fn this.update "final"))}}
>
Benefits here:
- The standalone update function can be tested trivially on its own, and it’s always just a function of the previous state and the change to apply.
- This correctly captures the fact that the state is a coherent bundle, which must be kept in sync, and it allows you to test that in a way that doesn’t even require rendering.
- If you ever need to change the logic for this (rounding, for example, or whatever else), there’s one and only one place to change it.
- The backing class actually gets to be much smaller!
This approach means you can export that update
function locally and write unit tests against it and be confident that your logic will work, without worrying about the rest of the component behavior, including UI concerns.
For Case 2, I would do almost exactly the same same, with the same motivations and the same kinds of outcomes. You can have a blob of state which represents the frequency
and counter
values, and always update them together, with, again, a single action to invoke which knows how the two should relate.
Heuristics & Reasoning
As mentioned at the top, this recommendation flows out of a set of heuristics I use for thinking about how to design state (and reactivity):
-
Things which change together should live together. In OOP contexts, this is often used to describe how to think about what things should be in a class together. I apply that heuristic to state changes, too! This is one reason (though not the most important; see below) not to push state changes down into setters: now you’ve spread your changes to related state across three different setters, and nothing makes it obvious if you go to fix a bug or add a feature in one that you may also need to go do the same in the other two. Having a single function which handles it all together does make that obvious.
-
Not all related state is derived state. Sometimes, as in these examples, you have a set of state which is closely related, but where the actual root state is in fact the combination of all three of those values. In terms of thinking about whether you have derived state or related root state, the question is: which state is never set directly, but are simply functions of the other pieces of state? That’s derived state. (And there’s often more state which is just derived than Ember Classic led folks to realize, which can in turn lead us to overcorrect when there is a set of related root state.)
-
Each @tracked
property should represent independent state. I often see folks try to have all of their reactive state live as top-level properties on a class (whether the class is a Component
or just a standalone class to manage other tracked state). Sometimes that’s grand! Sometimes, as here, it actually doesn’t make sense, because these properties aren’t independent of each other. It doesn’t make any sense in this context to change base
without changing final
, etc. Here you want one @tracked
property which represents the whole set of reactive state, because that set always has to stay in sync and change together. (This is the Octane/autotracking-specific application of the previous two heuristics.)
-
Setters are evil. I say that with a little bit of humorous exaggeration—but in all seriousness I have never found even a single case where a property setter which affects anything besides the setter itself is a good idea. An action (or more generally a method) much more obviously is going to do arbitrary things to state. For a setter to go change values that are, from the perspective of writing this.foo = 12
, totally unrelated, substantially degrades your ability to understand the code locally. There’s no way to understand when reading this.foo = 12
that it also updates bar
and baz
(and even more so when it’s off in a separate context like a template!). You have to read the implementation of foo
to know that. It’s a kind of “spooky action at a distance.”