# React State & Hydration Analysis — MCP App Lifecycle ## Executive Summary **The interactive state destruction is caused by a cascading chain of three interconnected bugs:** 1. Every tool call result replaces the entire UITree → full component tree teardown 2. Element keys from the server may not be stable across calls → React unmounts everything 3. All interactive components store state locally (`useState`) instead of in the shared context that was built specifically to survive re-renders --- ## Bug #1: `ontoolresult` → `setUITree()` Nuclear Replacement ### The Code (App.tsx:80-86) ```tsx app.ontoolresult = async (result) => { const tree = extractUITree(result); if (tree) { setUITree(tree); // ← REPLACES the entire tree object setToolInput(null); } }; ``` ### What Happens Every tool result that contains a UITree — even from a button click, search query, or tab switch — causes `setUITree(brandNewTreeObject)`. This triggers: 1. `App.tsx` re-renders with new `uiTree` state 2. `` receives a **new object reference** 3. `ElementRenderer` receives a **new `elements` map** (new reference, even if data is identical) 4. React walks the entire tree and reconciles **The critical question:** does it unmount/remount or just re-render? That depends entirely on **Bug #2** (keys). --- ## Bug #2: `key: element.key` — Keys Control Component Identity ### The Code (UITreeRenderer.tsx:37-40) ```tsx // Each component gets its key from the JSON tree return React.createElement(Component, { key: element.key, ...element.props }, childElements); // Children also keyed from the tree const childElements = element.children?.map((childKey) => React.createElement(ElementRenderer, { key: childKey, // ← from JSON elementKey: childKey, // ← from JSON elements, }), ); ``` ### The Problem React uses `key` to determine component identity. When keys change between renders: - **Same key** → React re-renders the existing component (state preserved) - **Different key** → React unmounts old, mounts new (STATE DESTROYED) If the MCP server generates keys like `contact-list-abc123` on call #1 and `contact-list-def456` on call #2, React sees them as **completely different components** and tears down the entire subtree. **Even if keys are stable**, the `elements` object reference changes every time, forcing re-renders down the entire tree. Not a state-loss issue by itself, but causes performance problems and can trigger effects/callbacks unnecessarily. --- ## Bug #3: Dual `uiTree` State — App.tsx vs MCPAppContext ### The Code ```tsx // App.tsx — has its own uiTree state const [uiTree, setUITree] = useState(null); // MCPAppContext.tsx — ALSO has its own uiTree state const [uiTree, setUITree] = useState(null); ``` ### The Problem There are **two completely independent `uiTree` states**: - `App.tsx` manages the actual rendering tree (used by ``) - `MCPAppContext` has its own `uiTree` + `setUITree` exposed via context, but **nobody calls the context's `setUITree`** The context's `uiTree` is **always null**. The context was designed to provide shared state (`formState`, `setFormValue`, `callTool`), but the tree management is disconnected. --- ## Bug #4: Interactive Components Use Local State That Gets Destroyed ### FormGroup (forms reset) ```tsx // FormGroup.tsx — all form values in LOCAL useState const [values, setValues] = useState>(() => { const init: Record = {}; for (const f of fields) { init[f.key] = ""; } return init; }); ``` **Chain:** User types → submits → `execute(submitTool, values)` → server returns new tree → `ontoolresult` → `setUITree(newTree)` → FormGroup unmounts → remounts → `values` resets to `{}` ### KanbanBoard (drag-and-drop fails) ```tsx // KanbanBoard.tsx — drag state + columns in LOCAL state const [columns, setColumns] = useState(initialColumns); const [dropTargetStage, setDropTargetStage] = useState(null); const [draggingCardId, setDraggingCardId] = useState(null); const dragRef = useRef(null); ``` **Chain:** User drags card → optimistic update → `callTool(moveTool, ...)` → server returns new tree → `ontoolresult` → `setUITree(newTree)` → KanbanBoard unmounts → remounts → drag state gone, optimistic move reverted, columns reset to server data The KanbanBoard even has a mitigation attempt that fails: ```tsx // This ref-based sync ONLY works if React re-renders without unmounting const prevColumnsRef = useRef(initialColumns); if (prevColumnsRef.current !== initialColumns) { prevColumnsRef.current = initialColumns; setColumns(initialColumns); // ← This runs, but after unmount/remount it's moot } ``` ### SearchBar (search clears) ```tsx // SearchBar.tsx — query in LOCAL state const [value, setValue] = useState(""); ``` **Chain:** User types query → debounced `callTool(searchTool, ...)` → server returns new tree → SearchBar unmounts → remounts → search input clears ### ActionButton (stops working) ```tsx // ActionButton.tsx — loading state in LOCAL state const [loading, setLoading] = useState(false); ``` **Chain:** User clicks → `setLoading(true)` → `callTool(...)` → server returns new tree → ActionButton unmounts → remounts → `loading` stuck at initial `false`, but the async `callTool` still has a stale closure reference. The `finally { setLoading(false) }` calls `setLoading` on an unmounted component. ### TabGroup (tab selection lost) ```tsx const [localActive, setLocalActive] = useState( controlledActiveTab || tabs[0]?.value || "", ); ``` **Chain:** User clicks tab → `setLocalActive(value)` → `callTool(switchTool, ...)` → new tree → TabGroup remounts → `localActive` resets to first tab ### ContactPicker (search results disappear) ```tsx const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [isOpen, setIsOpen] = useState(false); const [selected, setSelected] = useState(null); ``` ### InvoiceBuilder (entire form wiped) ```tsx const [selectedContact, setSelectedContact] = useState(null); const [lineItems, setLineItems] = useState(...); const [taxRate, setTaxRate] = useState(8.5); ``` --- ## Bug #5: Hydration Path Mismatch ### The Code (App.tsx:60-66) ```tsx useEffect(() => { const preInjected = getPreInjectedTree(); if (preInjected && !uiTree) { setUITree(preInjected); } }, []); ``` ### The Problem Not a React hydration mismatch (we use `createRoot`, not `hydrateRoot`), but a **data transition issue**: 1. App mounts → `uiTree = null` → shows "Connecting..." 2. `useEffect` fires → finds `window.__MCP_APP_DATA__` → `setUITree(preInjectedTree)` 3. Tree renders with pre-injected keys 4. `ontoolresult` fires with server-generated tree → `setUITree(serverTree)` 5. If keys differ between pre-injected and server tree → **full unmount/remount** The pre-injected path and the dynamic path produce trees with potentially different key schemas, causing a jarring full teardown on the first real tool result. --- ## Bug #6: MCPAppProvider Context Is Stable (NOT a bug) `MCPAppProvider` wrapping does **not** cause context loss. It's rendered consistently in all branches of the conditional render in App.tsx. The provider identity is stable. However, the provider creates a **new `value` object on every render**: ```tsx const value: MCPAppContextValue = { uiTree, setUITree, formState, setFormValue, resetFormState, callTool, isLoading, app, }; return {children}; ``` This causes every `useMCPApp()` consumer to re-render on every provider render, but it doesn't cause state loss — just unnecessary renders. --- ## The Kill Chain (Full Interaction Flow) ``` User clicks ActionButton("View Contact", toolName="get_contact", toolArgs={id: "123"}) │ ├─ ActionButton.handleClick() │ ├─ setLoading(true) │ └─ callTool("get_contact", {id: "123"}) │ └─ app.callServerTool({name: "get_contact", arguments: {id: "123"}}) │ ├─ MCP Server processes tool call, returns CallToolResult with NEW UITree │ ├─ app.ontoolresult fires │ ├─ extractUITree(result) → newTree (new keys, new elements, new object) │ └─ setUITree(newTree) ← App.tsx state update │ ├─ App.tsx re-renders │ └─ │ └─ ElementRenderer receives new elements map │ └─ For each element: React.createElement(Component, {key: NEW_KEY, ...}) │ └─ React sees different key → UNMOUNT old component, MOUNT new one │ ├─ ALL components unmount: │ ├─ FormGroup: values={} (reset) │ ├─ KanbanBoard: columns=initial, dragState=null (reset) │ ├─ SearchBar: value="" (reset) │ ├─ TabGroup: localActive=first tab (reset) │ ├─ ContactPicker: query="", results=[], selected=null (reset) │ └─ InvoiceBuilder: lineItems=[default], contact=null (reset) │ └─ Meanwhile, ActionButton's finally{} block calls setLoading(false) on an UNMOUNTED component → React warning + no-op ``` --- ## Fixes ### Fix 1: Deterministic Stable Keys (Server-Side) The MCP server must generate **deterministic, position-stable keys** for UITree elements. Keys should be based on component type + semantic identity, not random IDs. ```typescript // BAD: keys change every call { key: `card-${crypto.randomUUID()}`, type: "Card", ... } // GOOD: keys are stable across calls { key: "contact-detail-card", type: "Card", ... } { key: "pipeline-kanban", type: "KanbanBoard", ... } ``` ### Fix 2: Tree Diffing / Partial Updates Instead of Full Replace Don't replace the entire tree on every tool result. Diff the new tree against the old one and only update changed branches: ```tsx // App.tsx — instead of wholesale replace: app.ontoolresult = async (result) => { const newTree = extractUITree(result); if (newTree) { setUITree(prev => { if (!prev) return newTree; // Merge: keep unchanged elements, update changed ones return mergeUITrees(prev, newTree); }); } }; function mergeUITrees(oldTree: UITree, newTree: UITree): UITree { const mergedElements: Record = {}; for (const [key, newEl] of Object.entries(newTree.elements)) { const oldEl = oldTree.elements[key]; // Keep old reference if data is identical (prevents re-render) if (oldEl && JSON.stringify(oldEl) === JSON.stringify(newEl)) { mergedElements[key] = oldEl; } else { mergedElements[key] = newEl; } } return { root: newTree.root, elements: mergedElements }; } ``` ### Fix 3: Separate Data Results from UI Results Not every tool call should trigger a tree replacement. ActionButton clicks that return data (not a new view) should be handled by the component, not by `ontoolresult`: ```tsx // ActionButton.tsx — handle result locally, don't let ontoolresult replace tree const handleClick = useCallback(async () => { if (!toolName || loading || disabled) return; setLoading(true); try { const result = await callTool(toolName, toolArgs || {}); // Result handled locally — only navigate if result contains a NEW view if (result && hasNavigationIntent(result)) { // Let ontoolresult handle it } // Otherwise, toast/notification with result, don't replace tree } finally { setLoading(false); } }, [toolName, toolArgs, loading, disabled, callTool]); ``` ### Fix 4: Move Interactive State to Context Use the existing `formState` in MCPAppContext instead of local `useState`: ```tsx // FormGroup.tsx — use shared form state that survives remounts const { formState, setFormValue } = useMCPApp(); // Instead of local useState, derive from context const getValue = (key: string) => formState[`form:${formId}:${key}`] ?? ""; const setValue = (key: string, val: string) => setFormValue(`form:${formId}:${key}`, val); ``` ### Fix 5: Fix the Dual uiTree State Either: - **Option A:** Remove `uiTree` from MCPAppContext entirely (it's unused) - **Option B:** Have App.tsx use the context's uiTree and remove its local state ```tsx // Option B: App.tsx uses context state export function App() { const [hostContext, setHostContext] = useState(); // Remove local uiTree state — let context own it // In MCPAppProvider, connect ontoolresult to context's setUITree } ``` ### Fix 6: Memoize Context Value Prevent unnecessary re-renders of all context consumers: ```tsx // MCPAppContext.tsx const value = useMemo(() => ({ uiTree, setUITree, formState, setFormValue, resetFormState, callTool, isLoading, app, }), [uiTree, formState, callTool, isLoading, app]); ``` ### Fix 7: Add React.memo to Leaf Components Prevent re-renders when props haven't actually changed: ```tsx export const MetricCard = React.memo(({ label, value, trend, ... }) => { // ... }); export const StatusBadge = React.memo(({ label, variant }) => { // ... }); ``` --- ## Priority Order | # | Fix | Impact | Effort | |---|-----|--------|--------| | 1 | Stable keys (server-side) | 🔴 Critical — fixes unmount/remount | Medium | | 2 | Tree diffing/merge | 🔴 Critical — prevents unnecessary teardown | Medium | | 3 | Separate data vs UI results | 🟡 High — stops button clicks from nuking the view | Low | | 4 | Context-based interactive state | 🟡 High — state survives even if components remount | Medium | | 5 | Fix dual uiTree state | 🟢 Medium — removes confusion, single source of truth | Low | | 6 | Memoize context value | 🟢 Medium — performance improvement | Low | | 7 | React.memo leaf components | 🟢 Low — performance polish | Low |