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()andpublicEnv(). The accessors oflumiose/router(url(),params(),searchParams(), …) are reactive inside SPA routes and still count, except in the page or layout of astaticorserverroute, where they read the request too.lumiose's control-flow APIs, which never return:notFound(),redirect()anderror(), soconst project = getProject(slug) ?? notFound()is fine.- Anything imported from a server-only module (
*.server.ts,src/server/**), such asqueries().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 undersrc/server/(export { getDashboard } from "@acme/data";) and import them from there.
// 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()oroptimistic()(in the component or at the top level); - an accessor prop destructured from the props (
const { value } = props; const v = value()), as it reportsconst v = props.value(); url(),searchParams()andisNavigating()fromlumiose/router: a navigation that only changes the query keeps the page and changes the URL, and every navigation changesisNavigating().
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.
<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 })).
// 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
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.
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 }))).