Streaming Best Practices
Production-ready patterns for building streaming components.
This page provides copy-paste ready component patterns for common streaming scenarios. Each pattern shows what belongs in the schema vs state, and why.
Pattern 1: Read-Only Display
For components that display AI-generated content without user interaction.
Schema: All display props State: None needed
import { z } from "zod";
import { type TamboComponent, useTamboStreamStatus } from "@tambo-ai/react";
const SummaryCardPropsSchema = z.object({
title: z.string().describe("Brief title for the summary."),
highlights: z.array(z.string()).describe("Key points to display."),
conclusion: z.string().describe("Final takeaway."),
});
type SummaryCardProps = z.infer<typeof SummaryCardPropsSchema>;
export const SummaryCard: TamboComponent<SummaryCardProps> = {
name: "SummaryCard",
description: "Displays a summary with key highlights.",
propsSchema: SummaryCardPropsSchema,
component: function SummaryCardComponent({
title,
highlights,
conclusion,
}: SummaryCardProps) {
const { streamStatus, propStatus } =
useTamboStreamStatus<SummaryCardProps>();
return (
<article aria-busy={streamStatus.isStreaming}>
{propStatus.title?.isStreaming && (
<div className="h-6 w-48 animate-pulse rounded bg-gray-200" />
)}
{propStatus.title?.isSuccess && <h2>{title}</h2>}
<ul>
{highlights?.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
{propStatus.conclusion?.isStreaming && (
<div className="h-4 w-full animate-pulse rounded bg-gray-200" />
)}
{propStatus.conclusion?.isSuccess && <p>{conclusion}</p>}
{streamStatus.isError && (
<p className="text-red-500">Failed to load summary.</p>
)}
</article>
);
},
};Pattern 2: AI-Generated → User-Editable
For components where AI generates initial content that users can edit.
Schema: subject, body (AI generates these)
State (visible to AI): Same keys - user edits override AI values
Why: AI sees user's edits for follow-up messages like "make it more formal"
import * as React from "react";
import { z } from "zod";
import {
type TamboComponent,
useTamboComponentState,
useTamboStreamStatus,
} from "@tambo-ai/react";
const EmailComposerPropsSchema = z.object({
subject: z.string().describe("Short, clear subject line."),
body: z.string().describe("Draft email body in plain text."),
});
type EmailComposerProps = z.infer<typeof EmailComposerPropsSchema>;
type EmailDraft = {
to: string;
subject: string;
body: string;
};
export const EmailComposer: TamboComponent<EmailComposerProps> = {
name: "EmailComposer",
description: "Compose and edit an email before sending.",
propsSchema: EmailComposerPropsSchema,
component: function EmailComposerComponent({
subject,
body,
}: EmailComposerProps) {
const { streamStatus, propStatus } =
useTamboStreamStatus<EmailComposerProps>();
// Seed state from props as they stream in
// Once user edits, their changes take precedence
const [draft, setDraft] = useTamboComponentState<EmailDraft>(
"emailDraft",
{ to: "", subject: "", body: "" },
{ to: "", subject: subject ?? "", body: body ?? "" },
);
const isStreaming = streamStatus.isStreaming;
function handleChange(field: keyof EmailDraft) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target.value;
setDraft((prev) => ({ ...prev, [field]: value }));
};
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
// Send draft to your backend
await sendEmail(draft);
} catch (err) {
console.error("Failed to send:", err);
}
}
return (
<form aria-busy={isStreaming} onSubmit={handleSubmit}>
<label>
To
<input
type="email"
value={draft.to}
onChange={handleChange("to")}
disabled={isStreaming}
/>
</label>
<label>
Subject
{propStatus.subject?.isStreaming && (
<span className="ml-2 text-gray-400">generating...</span>
)}
<input
type="text"
value={draft.subject}
onChange={handleChange("subject")}
disabled={isStreaming}
/>
</label>
<label>
Body
{propStatus.body?.isStreaming && (
<span className="ml-2 text-gray-400">generating...</span>
)}
<textarea
value={draft.body}
onChange={handleChange("body")}
disabled={isStreaming}
/>
</label>
<button type="submit" disabled={isStreaming}>
Send
</button>
</form>
);
},
};// Placeholder - implement your own email sending
async function sendEmail(draft: EmailDraft): Promise<void> {
// Send to your backend API
}Pattern 3: AI Options + User Selection
For components where AI generates choices and users select from them.
Schema: question, options (AI generates the choices)
State (visible to AI): selectedIndices (which options user picked)
Why: Track indices, not option values - avoids sync issues when props update
import * as React from "react";
import { z } from "zod";
import {
type TamboComponent,
useTamboComponentState,
useTamboStreamStatus,
} from "@tambo-ai/react";
const OptionSelectorPropsSchema = z.object({
question: z.string().describe("The question to answer."),
options: z.array(z.string()).describe("Available choices."),
});
type OptionSelectorProps = z.infer<typeof OptionSelectorPropsSchema>;
type SelectionState = {
selectedIndices: number[];
};
export const OptionSelector: TamboComponent<OptionSelectorProps> = {
name: "OptionSelector",
description: "Multi-select from AI-generated options.",
propsSchema: OptionSelectorPropsSchema,
component: function OptionSelectorComponent({
question,
options,
}: OptionSelectorProps) {
const { streamStatus, propStatus } =
useTamboStreamStatus<OptionSelectorProps>();
// Track indices only - options come from props
const [selection, setSelection] = useTamboComponentState<SelectionState>(
"optionSelection",
{ selectedIndices: [] },
);
function toggleOption(index: number) {
setSelection((prev) => {
const indices = prev.selectedIndices;
const newIndices = indices.includes(index)
? indices.filter((i) => i !== index)
: [...indices, index];
return { selectedIndices: newIndices };
});
}
const isStreaming = streamStatus.isStreaming;
return (
<fieldset aria-busy={isStreaming}>
<legend>{question}</legend>
{options?.map((option, i) => (
<label key={i} className="block">
<input
type="checkbox"
checked={selection.selectedIndices.includes(i)}
onChange={() => toggleOption(i)}
disabled={isStreaming}
/>
{option}
</label>
))}
{propStatus.options?.isStreaming && (
<p className="text-gray-400">Loading more options...</p>
)}
</fieldset>
);
},
};Pattern 4: Private/Sensitive State
For components with data that should NOT be visible to the AI.
Schema: Display content only
State (useTamboComponentState): Non-sensitive user state
State (useState): Passwords, API keys, tokens, secrets
No Rehydration
Regular useState won't rehydrate when re-rendering thread history. If users
navigate away and back, that state is lost. Handle persistence yourself if needed.
import * as React from "react";
import { useState } from "react";
import { z } from "zod";
import {
type TamboComponent,
useTamboComponentState,
useTamboStreamStatus,
} from "@tambo-ai/react";
const ApiConfigPropsSchema = z.object({
serviceName: z.string().describe("Name of the service to configure."),
instructions: z.string().describe("Setup instructions for the user."),
});
type ApiConfigProps = z.infer<typeof ApiConfigPropsSchema>;
export const ApiConfig: TamboComponent<ApiConfigProps> = {
name: "ApiConfig",
description: "Configure API credentials for a service.",
propsSchema: ApiConfigPropsSchema,
component: function ApiConfigComponent({
serviceName,
instructions,
}: ApiConfigProps) {
const { streamStatus, propStatus } = useTamboStreamStatus<ApiConfigProps>();
// Visible to AI - helps with follow-up questions
const [config, setConfig] = useTamboComponentState("apiConfig", {
isConfigured: false,
lastError: "",
});
// PRIVATE - never sent to AI, won't rehydrate
const [apiKey, setApiKey] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
// Validate and save the key (your implementation)
await saveApiKey(serviceName, apiKey);
setConfig({ isConfigured: true, lastError: "" });
setApiKey(""); // Clear from memory
} catch (err) {
setConfig({
isConfigured: false,
lastError: err instanceof Error ? err.message : "Failed to save",
});
}
}
if (streamStatus.isPending) {
return <div className="animate-pulse">Loading configuration...</div>;
}
return (
<div>
<h3>{serviceName} Configuration</h3>
{propStatus.instructions?.isSuccess && <p>{instructions}</p>}
{config.isConfigured ? (
<p className="text-green-600">✓ API key configured</p>
) : (
<form onSubmit={handleSubmit}>
<label>
API Key
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Enter your API key"
/>
</label>
{config.lastError && (
<p className="text-red-500">{config.lastError}</p>
)}
<button type="submit">Save</button>
</form>
)}
</div>
);
},
};
// Placeholder - implement your own storage
async function saveApiKey(service: string, key: string): Promise<void> {
// Store securely, e.g., in encrypted storage or backend
}Summary
| Pattern | Schema | useTamboComponentState | useState |
|---|---|---|---|
| Read-only | All props | — | — |
| AI → Editable | Initial values | User's edited values | — |
| AI options + selection | Options | Selected indices | — |
| Sensitive data | Display content | Non-sensitive state | Passwords, keys |
Key principles:
- Schema defines what AI generates
useTamboComponentState= AI-visible + rehydrates on thread re-renderuseState= private + no rehydration (handle storage yourself)- For editable content, use
setFromPropto seed state from AI, then user edits take over