Loading...
Syncing editable interactables
Let users edit an interactable component while Tambo keeps the canonical props in sync.
When you want a user-editable component to stay aligned with the props that Tambo controls, you only need a thin wrapper: keep the edited props in state, pass them straight into the interactable, and adopt any remote changes with a useEffect.
Why props fall out of sync
- The component stores user edits in local state, so Tambo never sees the new props.
- Tambo updates the registry, but the component keeps rendering the stale value.
Fix both directions by treating the wrapper state as the single source of truth.
Step by step
1. Presentational component
type NoteProps = {
title: string;
content: string;
color?: "white" | "yellow" | "blue" | "green";
};
function Note({ title, content, color = "yellow" }: NoteProps) {
return (
<section className={`note note-${color}`}>
<h3>{title}</h3>
<p>{content}</p>
</section>
);
}- No state, just props. Tambo controls whatever you pass in.
2. Register it with withInteractable
import { withInteractable } from "@tambo-ai/react";
import { z } from "zod";
const InteractableNote = withInteractable(Note, {
componentName: "Note",
description: "A note that both users and Tambo can edit",
propsSchema: z.object({
title: z.string(),
content: z.string(),
color: z.enum(["white", "yellow", "blue", "green"]).optional(),
}),
});- The schema tells Tambo exactly which props it can update.
3. Wrap it with local state + a sync effect
import { useEffect, useState } from "react";
import { useCurrentInteractablesSnapshot } from "@tambo-ai/react";
function EditableNote(initial: NoteProps) {
const [note, setNote] = useState(initial);
const [interactableId, setInteractableId] = useState<string | null>(null);
const snapshot = useCurrentInteractablesSnapshot();
// Pull in any updates that Tambo makes to this interactable.
useEffect(() => {
if (!interactableId) return;
const match = snapshot.find((item) => item.id === interactableId);
if (!match) return;
const next = match.props as Partial<NoteProps>;
setNote((prev) => ({ ...prev, ...next }));
}, [snapshot, interactableId]);
return (
<section>
<InteractableNote {...note} onInteractableReady={setInteractableId} />
<label>
Edit content
<textarea
value={note.content}
onChange={(event) =>
setNote((prev) => ({ ...prev, content: event.currentTarget.value }))
}
/>
</label>
</section>
);
}useStatekeeps the canonical props that both the UI and Tambo share.- Updating state immediately rerenders the interactable with the new props, so the registry stays current.
- The
useEffectmirrors any Tambo-driven updates back into the textarea.
4. Use it anywhere
export function Page() {
return (
<main>
<EditableNote
title="Release plan"
content="Ask Tambo to keep this note up to date."
color="yellow"
/>
</main>
);
}Full example
Show complete code
import { useEffect, useState } from "react";
import {
useCurrentInteractablesSnapshot,
withInteractable,
} from "@tambo-ai/react";
import { z } from "zod";
type NoteProps = {
title: string;
content: string;
color?: "white" | "yellow" | "blue" | "green";
};
function Note({ title, content, color = "yellow" }: NoteProps) {
return (
<section className={`note note-${color}`}>
<h3>{title}</h3>
<p>{content}</p>
</section>
);
}
const InteractableNote = withInteractable(Note, {
componentName: "Note",
description: "A note that both users and Tambo can edit",
propsSchema: z.object({
title: z.string(),
content: z.string(),
color: z.enum(["white", "yellow", "blue", "green"]).optional(),
}),
});
function EditableNote(initial: NoteProps) {
const [note, setNote] = useState(initial);
const [interactableId, setInteractableId] = useState<string | null>(null);
const snapshot = useCurrentInteractablesSnapshot();
useEffect(() => {
if (!interactableId) return;
const match = snapshot.find((item) => item.id === interactableId);
if (!match) return;
const next = match.props as Partial<NoteProps>;
setNote((prev) => ({ ...prev, ...next }));
}, [snapshot, interactableId]);
return (
<section>
<InteractableNote {...note} onInteractableReady={setInteractableId} />
<label>
Edit content
<textarea
value={note.content}
onChange={(event) =>
setNote((prev) => ({ ...prev, content: event.currentTarget.value }))
}
/>
</label>
</section>
);
}
export function Page() {
return (
<main>
<EditableNote
title="Release plan"
content="Ask Tambo to keep this note up to date."
color="yellow"
/>
</main>
);
}Quick checklist
- Update the wrapper state whenever the user edits; the interactable sees the new props on the same render.
- Use
useEffectto adopt registry updates so the UI reflects Tambo’s changes. - Only pass serializable props into the interactable—avoid callbacks or refs.