Diagnostics

LUM2001: Stale derived value

A value derived from a signal is computed once in setup and never updates.

Severity: warning · Category: reactivity (2xxx)

Reported by: the compiler. A warning. Does not apply to async components, whose body re-runs.

#What it means

A component's body runs once. const doubled = count() * 2 reads the signal at setup time and stores a number, so JSX that uses doubled shows the first value forever. A spread is a use too: const attrs = { title: props.title } spread as <div {...attrs} /> keeps the first title.

The compiler treats a zero-argument call of a local or imported binding (count(), user.name()) as a signal read, and so does a read of the component's props. Calls that can only run on the server are not reads, so server pages can store request data in constants:

  • lumiose's request APIs: url(), params(), locals(), cookies(), request(), response() and publicEnv(). The accessors of lumiose/router (url(), params(), searchParams(), …) are reactive inside SPA routes and still count, except in the page or layout of a static or server route, where they read the request too.
  • lumiose's control-flow APIs, which never return: notFound(), redirect() and error(), so const project = getProject(slug) ?? notFound() is fine.
  • Anything imported from a server-only module (*.server.ts, src/server/**), such as queries().getBook(isbn). Data functions from a workspace package (getDashboard() from @acme/data) are not known to be server-only: re-export them from a module under src/server/ (export { getDashboard } from "@acme/data";) and import them from there.
tsx
// src/routes/books/[isbn]/page.stsx (a server page): no warning
const user = locals().user ?? null;
const next = url().searchParams.get("next");
const featured = queries().featuredBooks(6);

A constant stored straight from a call of a function (const count = signal(props.start)) or of a member of an imported module (const toasts = Toast.createToastManager()) holds what a factory made, not a derived value: factories read their arguments on purpose. A call on an imported module still warns when its arguments read a signal or a prop (const day = format.date(props.date)). lumiose's APIs that make a value or return a constant are never reads, wherever they appear: useId(), ref(), signal(), createContext() and getAbortSignal(), so const context = { id: useId(), open: signal(false) } is fine.

Copying a signal's value into a constant warns too: const n = count() reads count once. The compiler reports the copies it can tell are reactive:

  • an accessor made in the same file with signal(), computed(), resource() or optimistic() (in the component or at the top level);
  • an accessor prop destructured from the props (const { value } = props; const v = value()), as it reports const v = props.value();
  • url(), searchParams() and isNavigating() from lumiose/router: a navigation that only changes the query keeps the page and changes the URL, and every navigation changes isNavigating().

Other copies are not reported. A zero-argument call of an imported function (const n = total()) may be a signal of a store module, a plain function or a defineState() getter (const cart = useCart()), which the compiler cannot tell apart. A copy of params(), route() or locale() from lumiose/router (const { id } = params()) is current for the whole life of a page, since a page with other params is a new page; only a layout's code sees them change, and the compiler cannot tell it from a page's. A value derived from them (const id = params().id) still warns, as any derivation from a call does. In those cases, read the value in the JSX all the same ({total()}, {params().id} in a layout).

count.peek() and untrack(…) read without tracking, so they are never reads for this diagnostic (or for LUM2003): const start = count.peek() keeps the first value on purpose.

Destructuring the props object at the top of the body (const { title, ...rest } = props) is rewritten like a destructured parameter and stays reactive. Destructuring a nested prop (const { name } = props.user), or bindings you reassign later, still read once and warn.

In a keyed row. A .map() callback with a key runs once per row, and the row stays when a new item with the same key replaces its item: the item updates in place (see Lists and keys). A value the callback reads from the item outside JSX and functions is a copy taken when the row was created, so the compiler reports it where the JSX uses it: const name = item.name, const label = format(item.price), or bindings destructured from the item with a rest element (({ id, ...rest }) => …). In a row, calls of your own functions count as reads too (a formatter of a field goes stale like the field). Not reported: copies of the key (const id = item.id with key={item.id}, or anything read from item.id), calls of lumiose's APIs (const open = signal(item.open) keeps the first value on purpose, as do untrack(() => …) and functions), values only an event handler reads, and rows without a key, which a new item replaces.

tsx
<ul>
  {products().map((item) => {
    const price = formatPrice(item.price); // LUM2001: the row keeps the first price
    return (
      <li key={item.id}>
        {item.name}: {price}
      </li>
    );
  })}
</ul>

Read the item where it is used ({formatPrice(item.price)}), derive lazily (const price = () => formatPrice(item.price), called as price()), or put the field in the key (key={`${item.id}:${item.price}`}) to make a new row when it changes.

Pages and layouts whose route mode is static or server render once per request (or build), so their own props are plain values too: deriving from them is fine. Routes in spa mode, islands and other components re-render with new props and still get the warning. @lumiose/vite, lumiose check and the language server read the route modes the same way (export const mode, the nearest layout's, then lumiose({ mode })).

tsx
// src/routes/books/[slug]/page.stsx (mode `static` or `server`): no warning
export default function Book(props: { params: { slug: string } }) {
  const title = props.params.slug.replaceAll("-", " ");
  return <h1>{title}</h1>;
}

#Example

tsx
export function Counter() {
  const count = signal(1);
  const doubled = count() * 2;
  const n = count();
  return (
    <button onClick={() => count.update((n) => n + 1)}>
      {n} × 2 = {doubled}
    </button>
  );
}

#How to fix it

Derive with a function or computed, and call it where you need the value. A plain copy needs neither: call the signal itself in the JSX.

tsx
const doubled = () => count() * 2; // cheap derivations
const total = computed(() => expensive(count())); // cached
return (
  <button>
    {count()} × 2 = {doubled()}
  </button>
);

For a spread, make the object a function and call it in the spread: const attrs = () => ({ title: props.title }) with <div {...attrs()} /> stays live.

If you really want the initial value only, read it with count.peek() or untrack(count) to make that explicit (const attrs = untrack(() => ({ title: props.title }))).