Skip to content

Reasoning: passing duration once streaming ends logs "changing from uncontrolled to controlled" #496

Description

@orcunbalcilar

Summary

Reasoning keeps duration in Radix useControllableState, where undefined means "uncontrolled". A caller that only learns the real duration when reasoning finishes naturally passes duration={undefined} while streaming and a number afterwards. React then logs this in development for every finished reasoning block:

undefined is changing from uncontrolled to controlled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.

(undefined is the hook's caller name, which Reasoning does not pass.)

Source: packages/elements/src/reasoning.tsx, line 78:

const [duration, setDuration] = useControllableState<number | undefined>({
  defaultProp: undefined,
  prop: durationProp,
});

Reproduction

const [part, setPart] = useState({ streaming: true, seconds: undefined as number | undefined });
// later, when the model finishes thinking:
setPart({ streaming: false, seconds: 3 });

<Reasoning isStreaming={part.streaming} duration={part.seconds}>
  <ReasoningTrigger />
  <ReasoningContent>…</ReasoningContent>
</Reasoning>

The header renders correctly ("Thought for 3 seconds"), but the warning fires on the transition.

Why the caller can't avoid it

undefined is the only way to say "not known yet", and it is also what makes the prop uncontrolled. The alternatives change behaviour:

  • Passing a number while streaming (e.g. 0) turns the component controlled from the start. The internal timer can then never supply a value, and the default getThinkingMessage treats duration === 0 as still thinking.
  • Remounting with a key when streaming ends resets the open state, so a panel the user opened collapses.

Suggested fix

duration has no onChange, so the controllable-state machinery adds nothing here. Keep the internal timer in plain state and let the prop win when it is set:

const [measuredDuration, setDuration] = useState<number | undefined>(undefined);
const duration = durationProp ?? measuredDuration;

This gives the same value useControllableState produces in both modes (the prop when it is defined, otherwise the internal timer), without the mode switch. We ship this as a local patch and it has been fine.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions