Ramonda

@StableProps

Names the props that are values rather than references. The framework then hands back the same identity for as long as their contents are equal, and the call site can write the plain literal.

@StableProps("key")
class Lookup extends Hook<{ key: readonly unknown[] }> {}

class UserCard extends Component<{ id: number }> {
  private user = this.use(Lookup, (self: UserCard) => ({ key: ["user", self.props.id] }));

  render() {
    return <p>{this.props.id}</p>;
  }
}

What goes wrong without it

["user", 7] written again is a new array. Every prop is a signal and a signal compares by reference, so the hook is handed a changed key every time that callback runs — which is every time anything the callback reads moves.

For a data hook that is not a small waste. A changed key means a different question: the cache entry is a different one, so the answer already in hand is thrown away and the request goes out again. Measured across three renders of the owner, a @compute reading a rebuilt array runs three times where one reading a scalar runs once.

With key declared a value, the framework compares the contents and hands back the identity it already had. Nothing downstream sees a change, and the call site keeps writing the plain literal.

Why the receiver declares it, and not the call site

That a query key is a value — ["user", 7] built again is the same question — is the hook's knowledge. Declaring it once settles every call site instead of asking each one to know.

It takes names, not a predicate, and that is deliberate: @ShouldUpdateOnPropsChange takes a rule an app can get wrong in the direction that matters — a component that stops rendering when it should. The worst a wrong name here can do is fail to type-check.

The names are checked against the props of whatever it is on, with no type argument to write: @StableProps("kye") is a compile error that names "kye".

Components too, by the same sentence

A component's props arrive from the parent's JSX, where an object literal is a fresh reference every render. <Panel filter={{ q }} /> hands the child a changed prop every time. Declaring filter a value settles it there exactly as it settles a hook's.

A context says it at its creation

createContext hands back a class rather than a declaration site, so a Provider takes the same declaration where the context is made:

const [ConfProvider, ConfConsumer] = createContext(
  { conf: { dense: false } },
  { stableProps: ["conf"] },
);

It is the same list on the same class, and the context can do one thing the decorator cannot: its keys are the default value's keys, so a name that is not one of them is refused rather than ignored.

What it refuses, and what it will not cover

No names at all. @StableProps() is a compile error.

Functions. Two closures with the same body are not equal by any comparison that is safe to make, so a listed function prop is left exactly as it came and RMD022 still reports it. Pass a bound method, or @memoized when it has to be built per argument.

Two declarations on one class are merged rather than refused — the decorator names a set, and the union is the unambiguous reading — but it is reported as RMD046 so the awkward spelling does not stay.

What it costs

A contents comparison to a bounded depth on every prop update, per listed name. A deeply nested literal gets a fresh reference rather than a wrong one, which is the safe direction.

Next

  • Props — why every prop is a signal.
  • Context — the stableProps option, in place.
  • Writing a hook — where the declaration usually belongs.