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>
  );
}
  • useState keeps 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 useEffect mirrors 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 useEffect to adopt registry updates so the UI reflects Tambo’s changes.
  • Only pass serializable props into the interactable—avoid callbacks or refs.