Ramonda

Every decorator, at a glance

Three questions come up about every decorator, and none of them is guessable from the name:

  • Where does it run — client, server, or both?
  • What can it go on — a component, a hook, or both?
  • How many times may it appear on one class?

The table

Runs onGoes onMore than once
@statebothcomponent · hookyes — one per field
@persistbothcomponent · hookyes — one per field
@computebothcomponent · hookyes
@memoizedbothcomponent · hookyes
@createdboth — env choosescomponent · hookyes, in order
@mountedboth — env choosescomponent · hookyes, in order
@destroyedclient in practice¹component · hookyes, reverse order
@updatedclient in practice¹component · hookyes
@watchPropclient in practice¹component · hookyes — one per selector
@deferHydrationclient (hydration only)component · hookyes — all are awaited
@catchErrorbothcomponent onlyno — a subclass may override
@ShouldUpdateOnPropsChangeclient in practice¹component onlyno — a subclass may override
@StablePropsclient in practice¹bothno — it takes a list
@Hostbothcomponent onlyno
@onElementclient only²component onlyyes
@onWindow / @onDocumentclient only²component · hookyes
@interval / @timeoutclient only²component · hookyes
your own subscriptionclient only²component · hookyes

¹ "client in practice" means the decorator is not gated to a side — it would run on the server — but a server render gives it no occasion to. A server render commits once and never unmounts, so there is no update for @updated or @watchProp to react to and no teardown for @destroyed.

² "client only" is different, and stronger: these are built on effects, and effects never attach during a server render. No env option changes that, and none of them has one.

Where it runs, in more detail

@created, @mounted and @destroyed take { env: "client" | "server" | "shared" }, and shared is the default — so an undecorated @mounted runs on both sides. See client / server / shared for choosing.

One consequence catches people out: on hydration a shared @created is skipped — the server already ran it and the state it wrote was restored from the page — while a shared @mounted runs again, because the DOM it touches was rebuilt as the client adopted the markup. That is why a route guard belongs in @mounted.

A server render, measured end to end, runs exactly: @created, @mounted, and any @compute something reads. Nothing else fires — not because it is forbidden, but for the reasons in the footnotes above.

What it goes on

Almost everything works on a hook. That is deliberate: a hook is where behaviour lives when it needs no element of its own, and behaviour needs state, lifecycle, derived values and listeners just as much as a component does.

Three do not, and each is refused twice: TypeScript rejects it at the decorator itself, and a build with no types throws at construction, with a message saying what to do instead. Neither is a lint you can talk past — the second exists because the first is not there in plain JavaScript.

why not
@HostIt names the element a component is. A hook adds no element — that is the point of a hook.
@onElementIt binds a listener to the component's host element. A hook has none. Use @onWindow / @onDocument, which work on both.
@ShouldUpdateOnPropsChangeIt gates a parent-driven prop update. A hook's props come from its this.use() callback and refresh on every owner render — there is nothing to gate.

@StableProps goes on both, and means one thing on each: these props are values, compare them by content. A hook's props are rebuilt by its own callback on every owner render; a component's are rebuilt by the parent's JSX. Both are a fresh reference for contents that did not move, and both are settled by naming the prop.

render takes none

Not one decorator goes on render, and each one is refused in development, where the class is defined. Unlike the three refusals above — which hold in every build — this check lives with the other decorator-argument checks, all of which are compiled out of production. The mistake is fixed at the source, so the moment you write it is the only moment worth refusing it.

They are not all shades of wrong, and two of them are allowed — @compute and @memoized cache the render, and development notes what that costs instead of refusing it. Measured, one class per decorator:

written on renderwhat it did
@computeCached the render on the signals it read. State and props still reach the DOM — so it looks fine — and anything it read that is NOT a signal freezes the page: measured, a plain field left old on screen where the same component without the decorator showed new. Silent, which is why the guard is not waiting for a crash.
@memoizedThe same as @compute now: the render is cached, a state write still reaches the DOM (measured, 12), and it freezes on anything the render read that is not a signal. It used to freeze on everything, before a memoised builder's reads invalidated their own entry.
@created, @mounted, @updated, @destroyedRegistered the render as a lifecycle callback, so it ran outside the render pass as well as inside it.
@catchErrorMade the render the handler for errors thrown by its own subtree.
@state, @persistMean "serialise me", which a render is not.

render is the one member Ramonda reserves. Put the behaviour on a member of its own and call it from render:

class Cart extends Component {
  @state items: { price: number }[] = [];

  @compute get total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }

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

How many times

Most decorators stack, and the order is defined:

  • @created and @mounted run in declaration order; @destroyed runs in reverse, so cleanup undoes setup in the order it was done.
  • @watchProp takes several selectors and may itself be repeated. One application with several selectors runs the method once when any of them changed, with the values as a tuple — @watchProp((p) => p.page, (p) => p.term). Repeating the decorator instead gives each application its own entry, so two that moved in the same update call the method twice; prefer the several-selector form. A single selector still hands a tuple of one, which is why handlers destructure: ([next]: [T]).
  • @deferHydration may appear several times; hydration waits for all of them.
  • @catchError is single: there is one answer to "who handles an error from below?". Two on one class are reported (RMD032), and the lowest is the one that runs — members initialise top to bottom, so it is applied last. A subclass declaring its own overrides the base's, which is not a duplicate and is not reported.
  • Listeners and timers stack freely — that is the normal way to bind several events.

Three are single:

  • @Host — a component is exactly one element, so there is one answer to which. Two on one class throws, in every build, because there is no correct program in which both are honoured. A subclass may declare its own, which overrides the base's — that is how a specialised component changes its element, and it is silent.
  • @ShouldUpdateOnPropsChange — there is one answer to "take these props?". Two on ONE class are reported in development (RMD040), and the highest is the one that decides — class decorators apply bottom-up, so it is applied last. That is the opposite line from @catchError above, and the same rule: whichever is applied last is the one that stands. A subclass may declare its own, which overrides the base's — that is not a duplicate and is not reported.
  • @StableProps — it takes as many names as you like, so a second one adds nothing you could not write in the first. Two on one class merge into the union and are reported as RMD046: the result is what you asked for, spelled twice, so it is a warning rather than a refusal — unlike @Host, where two element names have no union. A subclass may declare its own, and that one merges with what the parent declared rather than replacing it, which is the intended way to extend the list.

Two decorators that are not lifecycle

@state and @persist mark fields, not methods, so "runs on" means "is honoured on". Both are honoured on both sides: @state is serialized into the page by a server render and restored on hydration, and @persist is how a field that is not @state joins that payload.

Next