Diagnostics
LUM2003: Reactive early return
A sync component returns early based on reactive state.
Severity: error · Category: reactivity (2xxx)
Reported by: the compiler. An error when the condition reads a signal, a warning when it reads only props. Does not apply to async components, whose body re-runs.
#What it means
The body of a sync component runs once, so if (!user()) return <Login /> is decided once and
never re-evaluated: the component keeps showing whichever branch it took first.
The compiler checks if statements and switch statements whose branches return. Their
condition (a switch's value and its case tests) can read reactive values directly or
through a local declared from one (const kind = props.kind): both forms get the same
diagnostic, and a read through a local also points at the local's declaration.
- A signal read is an error. A zero-argument call such as
count(),store.count()or an accessor prop (props.user(), oruser()destructured from the props) reads a signal, and so does a local that copies one:const n = count()for a signal made in the same file,const u = user()for a destructured accessor prop, andconst busy = isNavigating()forurl(),searchParams()andisNavigating()fromlumiose/router(see LUM2001 for what counts as a copy). The component is written to react, and the early return never does.count.peek()reads without tracking, on purpose: it is not a read. - A prop read is a warning.
props.kind, a destructured prop, or a method of a prop's value (props.title.trim()) only goes stale when the component renders in the browser (in an island or an SPA page) and a parent passes a value that changes, as in<Card kind={tab()} />. The compiler cannot see the call sites from the component. A component that only renders on the server, or whose parents pass constants, can keep the early return.
The default export of a page or layout whose route mode is static or server gets no
diagnostic for its own props, nor for lumiose/router's accessors, which read the request there:
it renders once per request or build, so they never change (as for LUM2001).
In a keyed row the .map() callback runs once per row too, and a kept row's item changes in
place when a same-key item replaces it (see Lists and keys).
An early return on the item (if (!item.visible) return null;) is a warning, as for a prop: it
keeps its first branch while the row stays. A condition on the key itself
(if (!item.id) … with key={item.id}) is fine.
#Example
export function Account(props: { user: () => User | null }) {
if (!props.user()) return <Login />;
return <Profile user={props.user()!} />;
}An error: props.user() reads a signal. A warning, since it reads only a prop:
export function Badge(props: { kind: "new" | "sale" }) {
if (props.kind === "new") return <New />;
return <Sale />;
}The same warning, through a local and a switch:
export function Badge(props: { kind: "new" | "sale" }) {
const kind = props.kind;
switch (kind) {
case "new":
return <New />;
}
return <Sale />;
}#How to fix it
Move the condition into the JSX, where it becomes a memoised branch:
export function Account(props: { user: () => User | null }) {
return <>{props.user() ? <Profile user={props.user()!} /> : <Login />}</>;
}
export function Badge(props: { kind: "new" | "sale" }) {
return <>{props.kind === "new" ? <New /> : <Sale />}</>;
}In a keyed row, move the condition into the returned element, or put the field in the key so a change makes a new row:
<ul>
{items().map((item) => (
<li key={item.id} hidden={!item.visible}>
{item.name}
</li>
))}
</ul>For a warning on a component that only renders on the server, or whose props never change, the
early return is fine as it is. Values that are not reactive, such as constants, request data
(url(), params() from lumiose) and imports of server-only modules, never trigger the
diagnostic.