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(), or user() 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, and const busy = isNavigating() for url(), searchParams() and isNavigating() from lumiose/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

tsx
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:

tsx
export function Badge(props: { kind: "new" | "sale" }) {
  if (props.kind === "new") return <New />;
  return <Sale />;
}

The same warning, through a local and a switch:

tsx
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:

tsx
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:

tsx
<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.