Loading...

Common Streaming Component Pitfalls

Avoid runtime errors and infinite loops when handling streamed props.

Props stream incrementally during generation. This causes common runtime errors and infinite loops that are easy to fix once you know the patterns.

1. Component Not Re-rendering When Props Update

The most common mistake is not handling prop updates during streaming.

Using useState with props

State initialized from props only runs once - it won't update as props stream in:

// ❌ BAD: State initialized once, never updates when props stream in
function MyComponent({ title, content }: Props) {
  const [state, setState] = useState({ title, content });
  // title and content update during streaming, but state doesn't!
  return <div>{state.title}</div>; // Shows stale/empty value
}

Passing props directly to JSX

Even without state, you need to handle undefined props during streaming:

// ❌ BAD: Props may be undefined or partial during streaming
function MyComponent({ title, items }: Props) {
  return (
    <div>
      <h1>{title}</h1> {/* May show nothing or partial text */}
      {items.map((item) => (
        <Item key={item.id} {...item} />
      ))}{" "}
      {/* Crashes if items undefined */}
    </div>
  );
}

Solutions:

// ✅ Option 1: Use useTamboComponentState with setFromProp for editable content
function MyComponent({ title, content }: Props) {
  const [state, setState] = useTamboComponentState(
    "myState",
    { title: "", content: "" },
    title && content ? { title, content } : undefined,
  );

  return <div>{state.title}</div>; // Updates as props stream in
}

// ✅ Option 2: Use propStatus to wait for complete values
// Best when you need complete data before rendering (e.g., forms, IDs, required fields)
// Note: This hides content until complete - use Option 3 for progressive display
function MyComponent({ title, items }: Props) {
  const { propStatus } = useTamboStreamStatus<Props>();

  // Re-renders occur as propStatus changes during streaming
  return (
    <div>
      {propStatus.title?.isSuccess && <h1>{title}</h1>}
      {propStatus.items?.isSuccess &&
        items.map((item) => <Item key={item.id} {...item} />)}
    </div>
  );
}

// ✅ Option 3: Handle undefined with defaults (progressive display)
// Best for showing values as they stream in character-by-character
// Works because: thread state updates → parent re-renders → React reconciles
// your component with new props (same key/position = update, not remount)
function MyComponent({ title, items }: Props) {
  return (
    <div>
      <h1>{title ?? ""}</h1>
      {(items ?? []).map((item, i) => (
        <Item key={item.id ?? i} {...item} />
      ))}
    </div>
  );
}

2. Infinite useEffect Loops

When merging streamed props with existing state, it's easy to create infinite loops:

// ❌ BAD: Infinite loop - state changes trigger effect, effect changes state
function MultiSelect({ options }: Props) {
  const [state, setState] = useTamboComponentState("selections", {
    options: [],
    selected: [],
  });

  useEffect(() => {
    // This runs when options change, but also when state changes!
    setState((prev) => ({
      ...prev,
      options: options ?? [],
    }));
  }, [options, state, setState]); // state in deps = infinite loop
}

Don't store derived data in state. Calculate it during render:

// ✅ No useEffect needed - derive merged state during render
function MultiSelect({ options }: Props) {
  const { streamStatus } = useTamboStreamStatus<Props>();

  // Store ONLY what the user controls
  const [selected, setSelected] = useTamboComponentState<string[]>(
    "selected",
    [],
  );

  // Derive the full state during render
  const currentOptions = options ?? [];

  return (
    <div>
      {currentOptions.map((opt, i) => (
        <label key={i}>
          <input
            type="checkbox"
            checked={selected.includes(opt)}
            onChange={() => {
              setSelected((prev) =>
                prev.includes(opt)
                  ? prev.filter((s) => s !== opt)
                  : [...prev, opt],
              );
            }}
            disabled={streamStatus.isStreaming}
          />
          {opt}
        </label>
      ))}
    </div>
  );
}

Why this works: Props (AI-generated) and state (user selections) are kept separate. No syncing needed because you're not duplicating the props into state.

Solution 2: useRef Callback Pattern

When you must merge props into state, use a ref to avoid stale closures:

// ✅ Works in React 18/19.1 - ref always has latest callback
function useLatestCallback<T extends (...args: unknown[]) => unknown>(
  callback: T,
): T {
  const ref = useRef(callback);
  useLayoutEffect(() => {
    ref.current = callback;
  });
  return useCallback((...args: Parameters<T>) => ref.current(...args), []) as T;
}

function MultiSelect({ options }: Props) {
  const [state, setState] = useTamboComponentState("state", {
    options: [],
    selected: [],
  });

  const mergeOptions = useLatestCallback((incoming: string[]) => {
    // Always sees latest `state` without being a dependency
    return { ...state, options: incoming };
  });

  useEffect(() => {
    if (options !== undefined) {
      setState(mergeOptions(options));
    }
  }, [options, mergeOptions, setState]); // All deps listed, no infinite loop
}

Solution 3: useEffectEvent (React 19.2+)

If you're on React 19.2+, use the official solution:

// ✅ Cleanest solution - requires React 19.2+
import { useEffectEvent } from "react";

function MultiSelect({ options }: Props) {
  const [state, setState] = useTamboComponentState("state", {
    options: [],
    selected: [],
  });

  // useEffectEvent captures latest state without being reactive
  const onOptionsChange = useEffectEvent((incoming: string[]) => {
    setState({ ...state, options: incoming });
  });

  useEffect(() => {
    if (options !== undefined) {
      onOptionsChange(options);
    }
  }, [options]); // Only reactive values in deps
}

Which solution should I use?

Solution 1 (Derive during render) is best when you can restructure to keep props and state separate. This is the React-recommended approach.

Solution 2 (useRef callback) works in all React versions when you must merge.

Solution 3 (useEffectEvent) is cleanest but requires React 19.2+.

3. Missing Keys in Streamed Arrays

When rendering arrays that stream in, items may not have IDs yet:

// ❌ ERROR: "Each child in a list should have a unique key prop"
{
  matches.map((match) => (
    <MatchCard key={match.id} match={match} /> // match.id may be undefined
  ));
}

Solutions:

// ✅ Option 1: Fallback to index (acceptable for append-only lists)
{
  matches.map((match, index) => (
    <MatchCard key={match.id ?? `temp-${index}`} match={match} />
  ));
}

// ✅ Option 2: Generate stable IDs when data arrives (better for reordering)
const matchesWithIds = useMemo(
  () =>
    matches.map((match, i) => ({
      ...match,
      _stableKey: match.id ?? `streaming-${i}`,
    })),
  [matches],
);

{
  matchesWithIds.map((match) => (
    <MatchCard key={match._stableKey} match={match} />
  ));
}

4. Undefined Nested Properties

Nested objects may be partially streamed:

// ❌ ERROR: "Cannot read properties of undefined (reading 'emblem')"
{
  match.competition.emblem && <img src={match.competition.emblem} />;
}

Solutions:

// ✅ Option 1: Optional chaining throughout
{
  match?.competition?.emblem && (
    <img src={match.competition.emblem} alt={match.competition.name} />
  );
}

// ✅ Option 2: Guard the entire block
{
  match?.competition && (
    <div>
      <img src={match.competition.emblem} />
      <span>{match.competition.name}</span>
    </div>
  );
}

// ✅ Option 3: Use propStatus to wait for completion
const { propStatus } = useTamboStreamStatus<MatchProps>();

{
  propStatus.competition?.isSuccess && <img src={match.competition.emblem} />;
}

5. Arrays Without Safe Access

Streamed arrays may be undefined initially:

// ❌ ERROR: "Cannot read properties of undefined (reading 'map')"
{
  items.map((item) => <Item key={item.id} item={item} />);
}

Solutions:

// ✅ Option 1: Optional chaining on array
{
  items?.map((item, i) => <Item key={item.id ?? i} item={item} />);
}

// ✅ Option 2: Default to empty array
{
  (items ?? []).map((item, i) => <Item key={item.id ?? i} item={item} />);
}

// ✅ Option 3: Guard with propStatus
const { propStatus } = useTamboStreamStatus<Props>();

{
  propStatus.items?.isSuccess &&
    items.map((item) => <Item key={item.id} item={item} />);
}

6. Acting on Incomplete Streamed Data

Sometimes you need to trigger an action (API call, validation, etc.) only after a streamed value is complete. Don't act on partial data:

// ❌ BAD: API called with partial/incomplete data during streaming
function SearchComponent({ query }: Props) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (query) {
      // This fires repeatedly as query streams in character by character!
      fetchSearchResults(query).then(setResults);
    }
  }, [query]);
}

Solution: Use propStatus to wait for completion:

// ✅ Only act when the prop is fully streamed
function SearchComponent({ query }: Props) {
  const { propStatus } = useTamboStreamStatus<Props>();
  const [results, setResults] = useState([]);

  useEffect(() => {
    // Only fetch when query is complete, not while streaming
    if (propStatus.query?.isSuccess && query) {
      fetchSearchResults(query).then(setResults);
    }
  }, [propStatus.query?.isSuccess, query]);

  return (
    <div>
      {propStatus.query?.isStreaming && <p>Generating search query...</p>}
      {propStatus.query?.isSuccess && (
        <>
          <p>Searching for: {query}</p>
          {results.map((r) => (
            <Result key={r.id} {...r} />
          ))}
        </>
      )}
    </div>
  );
}

For complete component patterns, see Streaming Best Practices.