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
}Solution 1: Derive During Render (Recommended)
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.