Loading...

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

PatternSchemauseTamboComponentStateuseState
Read-onlyAll props
AI → EditableInitial valuesUser's edited values
AI options + selectionOptionsSelected indices
Sensitive dataDisplay contentNon-sensitive statePasswords, keys

Key principles:

  1. Schema defines what AI generates
  2. useTamboComponentState = AI-visible + rehydrates on thread re-render
  3. useState = private + no rehydration (handle storage yourself)
  4. For editable content, use setFromProp to seed state from AI, then user edits take over