Introduction: Bringing Intelligence to Life on the Frontend
Welcome back, intrepid developer! In our previous chapters, we laid the groundwork for integrating AI into our React and React Native applications. We explored how to consume AI model APIs, craft effective prompts, and even run models directly in the browser using tools like Transformers.js. Now, it’s time to elevate our game and dive into the fascinating world of agentic AI and how to orchestrate these intelligent systems directly from our frontend.
This chapter is all about empowering your UI to interact with AI agents. Unlike simple request-response models, AI agents can reason, plan, perform multiple steps, and even decide to use “tools” to achieve a goal. From the frontend, our role shifts from merely displaying AI output to actively managing the agent’s workflow, presenting its thought process, and handling its interactions with the user and external systems. We’ll focus on how to manage the dynamic state of an agent, process streaming responses, and even display tool calls, all while ensuring a smooth and responsive user experience.
By the end of this chapter, you’ll have a strong grasp of how to build UIs that don’t just use AI, but collaborate with it, leading to truly dynamic and intelligent applications. Get ready to make your frontend smarter than ever!
Core Concepts: Understanding Client-Side Agent Orchestration
Agentic AI systems are a leap beyond simple language models. Think of a traditional LLM as a brilliant calculator: you give it a problem, it gives you an answer. An AI agent, however, is more like a personal assistant: it understands your goal, breaks it down into steps, might consult various resources (tools), and then presents a solution or asks for clarification.
What are Agentic AI Systems?
At their core, AI agents are LLMs augmented with capabilities for:
- Reasoning: They can analyze a prompt, understand the intent, and formulate a plan.
- Memory: They can remember previous interactions and context, making conversations more coherent.
- Tool Use: They can decide to use external functions or APIs (tools) to gather information or perform actions.
- Iteration: They can execute steps, observe results, and refine their plan until the goal is met.
From a frontend perspective, this means we’re no longer just sending a single prompt and waiting for a single response. Instead, we’re interacting with a multi-step process.
The Frontend’s Role in Agent Orchestration
While the complex reasoning and heavy computation of an AI agent typically happen on a secure backend (to protect API keys, manage resources, and ensure scalability), the frontend plays a crucial role in orchestrating the user experience of this agent.
The frontend is responsible for:
- Sending User Input: Capturing the user’s initial prompt or subsequent instructions.
- Receiving Streaming Responses: Displaying the agent’s thought process, tool calls, and intermediate steps in real-time. This is essential for transparency and user engagement, especially for long-running tasks.
- Managing Agent State: Keeping track of the current conversation, the agent’s status (thinking, calling tool, idle), and any relevant context or memory.
- Displaying Tool Calls: Informing the user when the agent decides to use a tool, and potentially prompting for confirmation or displaying the tool’s output.
- Handling Asynchronous Flows: Managing loading states, cancellation requests, retries, and graceful fallbacks when things go wrong.
Let’s visualize this interaction:
Streaming Responses for Real-Time Feedback
For agentic systems, streaming responses are paramount. Why? Because agents can take time to think, call multiple tools, and generate complex outputs. Waiting for a single, monolithic response can lead to a perceived delay and a poor user experience. Streaming allows us to:
- Provide immediate feedback: Show “thinking…” or “processing…” messages.
- Display intermediate steps: Let the user see the agent’s thought process or tool calls as they happen.
- Improve responsiveness: The UI feels faster and more interactive.
In modern web development, we typically use two main approaches for streaming:
- Server-Sent Events (SSE) via
EventSource: This is a dedicated API for one-way communication from a server to a client. It’s excellent for simple text streams. fetchAPI withReadableStream: This is more versatile and allows processing raw response bodies as they arrive. It’s ideal for more complex data streams, especially when each chunk might be a JSON object representing a different step or message type. As of React 19 and React Native 0.73/0.74,fetchwithReadableStreamis a robust and widely supported option for this.
We’ll focus on fetch with ReadableStream as it offers more control for parsing structured agent responses.
Tool Calling from the UI Perspective
When an agent decides to use a tool, the backend typically sends a specific message type in its stream, indicating the tool name and its arguments. The frontend’s responsibility is to:
- Render a clear indicator: Show the user that a tool is being called (e.g., “Searching Wikipedia for ’latest React features’”).
- Handle potential user interaction: In some advanced scenarios, the frontend might need to prompt the user for confirmation before a sensitive tool is executed (e.g., “Do you want to send this email?”). However, for most cases, the agent executes tools autonomously on the backend.
- Display tool output: Once the tool returns a result (which the backend will send back to the frontend via the stream), display this information to the user.
Crucial Security Note: Never expose API keys or sensitive credentials for backend tools directly in your client-side code. The frontend merely receives instructions about tool calls; the actual execution, including credential handling, must occur on your secure backend.
Managing AI State, Memory, and Context in React
For an agent to be truly intelligent, it needs memory – the ability to remember previous turns in a conversation or relevant context. In a React application, managing this state effectively is key.
- Conversation History (Memory): The most fundamental piece of “memory” is the list of past messages exchanged between the user and the agent. This history needs to be sent with each new user prompt to the backend agent so it can maintain context.
- Agent Status: Is the agent currently
idle,thinking,calling_tool, orerror? This status drives UI elements like loading spinners or progress indicators. - Current Streamed Output: As the agent streams responses, we need a temporary state to accumulate these chunks before they are finalized into a full message.
- Context Window Awareness: While the backend agent handles the actual “context window” (how much past conversation it can process), the frontend needs to ensure it’s sending a manageable and relevant history. For very long conversations, the backend might summarize or use a sliding window, and the frontend just needs to send what it has.
We’ll primarily use React’s useState and potentially useReducer for managing these aspects, ensuring our components re-render efficiently as the agent’s state evolves.
Step-by-Step Implementation: Building a Simple Agentic Chat UI
Let’s build a basic chat interface that simulates interaction with an agent. We’ll focus on handling streaming responses and displaying different “steps” from the agent.
For simplicity, our “backend agent” will be simulated with a JavaScript function that yields different types of messages over time. In a real application, this would be an actual API endpoint.
Step 1: Basic Chat UI Setup
First, let’s create a new React component for our chat. We’ll use React 19 for this example.
// src/components/AgentChat.jsx
import React, { useState, useRef, useEffect } from 'react';
const AgentChat = () => {
const [messages, setMessages] = useState([]); // Stores all chat messages
const [input, setInput] = useState(''); // Current user input
const [isAgentThinking, setIsAgentThinking] = useState(false); // Agent status
const messagesEndRef = useRef(null); // For auto-scrolling
// Function to scroll to the bottom of the chat
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(scrollToBottom, [messages]); // Scroll whenever messages update
// Handle sending a message
const handleSubmit = (e) => {
e.preventDefault();
if (!input.trim()) return;
const newUserMessage = { id: Date.now(), text: input, sender: 'user' };
setMessages((prevMessages) => [...prevMessages, newUserMessage]);
setInput(''); // Clear input field
// Simulate agent response (we'll implement this next)
simulateAgentResponse(newUserMessage.text);
};
// Placeholder for our agent simulation
const simulateAgentResponse = async (userPrompt) => {
setIsAgentThinking(true);
// Agent logic will go here
setIsAgentThinking(false); // This will be moved inside the streaming logic
};
return (
<div style={{ maxWidth: '600px', margin: '20px auto', border: '1px solid #ccc', borderRadius: '8px', display: 'flex', flexDirection: 'column', height: '80vh' }}>
<div style={{ flexGrow: 1, overflowY: 'auto', padding: '10px', backgroundColor: '#f9f9f9' }}>
{messages.map((msg) => (
<div key={msg.id} style={{
marginBottom: '10px',
textAlign: msg.sender === 'user' ? 'right' : 'left',
}}>
<span style={{
display: 'inline-block',
padding: '8px 12px',
borderRadius: '15px',
backgroundColor: msg.sender === 'user' ? '#007bff' : '#e0e0e0',
color: msg.sender === 'user' ? 'white' : 'black',
}}>
{msg.text}
</span>
</div>
))}
{isAgentThinking && (
<div style={{ marginBottom: '10px', textAlign: 'left' }}>
<span style={{ display: 'inline-block', padding: '8px 12px', borderRadius: '15px', backgroundColor: '#e0e0e0', color: 'black' }}>
Agent is thinking...
</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', padding: '10px', borderTop: '1px solid #eee' }}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
style={{ flexGrow: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px', marginRight: '10px' }}
disabled={isAgentThinking}
/>
<button type="submit" disabled={isAgentThinking} style={{ padding: '8px 15px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
Send
</button>
</form>
</div>
);
};
export default AgentChat;
Explanation:
useStatehooks:messages(to store chat history),input(for the message input field),isAgentThinking(to show a loading state).useRef:messagesEndRefis used to automatically scroll to the bottom of the chat whenever new messages arrive.useEffect: CallsscrollToBottomafter every render ifmessageschanges.handleSubmit: Prevents default form submission, adds the user’s message tomessages, clears the input, and callssimulateAgentResponse.- The UI renders user and agent messages differently based on
sender. - A placeholder “Agent is thinking…” message is displayed when
isAgentThinkingis true.
Step 2: Simulating a Streaming Agent Response
Now, let’s enhance simulateAgentResponse to mimic a streaming API. We’ll use fetch with a ReadableStream and TextDecoder to process chunks. Our simulated backend will return JSON objects with type and content to represent different agent steps.
// src/components/AgentChat.jsx (add to existing file)
import React, { useState, useRef, useEffect, useCallback } from 'react'; // Add useCallback
const AgentChat = () => {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const [isAgentThinking, setIsAgentThinking] = useState(false);
const [currentAgentResponse, setCurrentAgentResponse] = useState(''); // To accumulate streamed chunks
const messagesEndRef = useRef(null);
const abortControllerRef = useRef(null); // To allow cancellation
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(scrollToBottom, [messages]);
// Function to process streamed chunks from our simulated API
const processStream = useCallback(async (response) => {
if (!response.body) {
console.error("Response body is null");
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = ''; // Buffer to handle partial JSON objects
let agentMessageId = Date.now(); // Unique ID for the agent's full response
// Add a placeholder for the agent's accumulating response
setMessages((prevMessages) => [
...prevMessages,
{ id: agentMessageId, text: '', sender: 'agent', type: 'response' }
]);
while (true) {
const { value, done } = await reader.read();
if (done) {
console.log("Stream complete");
break;
}
buffer += decoder.decode(value, { stream: true });
// Attempt to parse complete JSON objects from the buffer
let boundary = buffer.indexOf('\n');
while (boundary !== -1) {
const line = buffer.substring(0, boundary).trim();
buffer = buffer.substring(boundary + 1);
if (line) {
try {
const data = JSON.parse(line);
console.log("Received streamed data:", data);
// Update the agent's message based on the type of data
setMessages((prevMessages) => {
const updatedMessages = prevMessages.map((msg) => {
if (msg.id === agentMessageId) {
let newText = msg.text;
if (data.type === 'thought') {
newText += `\n*Agent thought: ${data.content}*`;
} else if (data.type === 'tool_call') {
newText += `\n**Calling tool: ${data.tool_name} with args: ${JSON.stringify(data.args)}**`;
} else if (data.type === 'tool_result') {
newText += `\n_Tool returned: ${data.content}_`;
} else if (data.type === 'final_answer') {
newText += `\n${data.content}`;
} else {
newText += data.content; // Default for plain text chunks
}
return { ...msg, text: newText };
}
return msg;
});
return updatedMessages;
});
} catch (error) {
console.warn("Could not parse JSON from stream chunk:", line, error);
// If it's not JSON, it might be raw text, append it.
setMessages((prevMessages) => {
const updatedMessages = prevMessages.map((msg) => {
if (msg.id === agentMessageId) {
return { ...msg, text: msg.text + line };
}
return msg;
});
return updatedMessages;
});
}
}
boundary = buffer.indexOf('\n');
}
}
}, []); // Empty dependency array for useCallback
// Simulate a streaming API endpoint
const simulateAgentApiCall = useCallback(async (userPrompt, signal) => {
// In a real app, this would be your fetch call to the backend agent API
// For now, we'll create a mock ReadableStream
return new Response(new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const send = (data) => controller.enqueue(encoder.encode(JSON.stringify(data) + '\n'));
await new Promise(resolve => setTimeout(resolve, 500));
send({ type: 'thought', content: `User asked about "${userPrompt}". I need to break this down.` });
await new Promise(resolve => setTimeout(resolve, 800));
send({ type: 'thought', content: 'First, I will try to find some general information.' });
await new Promise(resolve => setTimeout(resolve, 1200));
send({ type: 'tool_call', tool_name: 'search_engine', args: { query: userPrompt } });
await new Promise(resolve => setTimeout(resolve, 1500));
send({ type: 'tool_result', content: 'Search results indicate the topic is complex, requiring further analysis.' });
await new Promise(resolve => setTimeout(resolve, 1000));
send({ type: 'thought', content: 'Now, I will synthesize the information and provide a concise answer.' });
await new Promise(resolve => setTimeout(resolve, 2000));
send({ type: 'final_answer', content: `Okay, based on my analysis of "${userPrompt}", here's what I found: [Detailed summary of ${userPrompt} goes here. This could be a multi-paragraph response explaining the topic in depth].` });
await new Promise(resolve => setTimeout(resolve, 500));
controller.close();
},
cancel() {
console.log("Stream cancelled by client.");
}
}));
}, []);
const simulateAgentResponse = async (userPrompt) => {
setIsAgentThinking(true);
setCurrentAgentResponse(''); // Clear any previous partial response
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
try {
const response = await simulateAgentApiCall(userPrompt, signal); // Pass signal
await processStream(response);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch aborted by user or component unmount');
} else {
console.error("Error during agent streaming:", error);
// Add an error message to the chat
setMessages((prevMessages) => [
...prevMessages,
{ id: Date.now(), text: `Error: ${error.message}`, sender: 'agent', type: 'error' }
]);
}
} finally {
setIsAgentThinking(false);
abortControllerRef.current = null;
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (!input.trim() || isAgentThinking) return; // Prevent sending if agent is busy
const newUserMessage = { id: Date.now(), text: input, sender: 'user' };
setMessages((prevMessages) => [...prevMessages, newUserMessage]);
setInput('');
// Send the entire conversation history to the agent for context
const conversationHistory = [...messages, newUserMessage].map(msg => ({
role: msg.sender === 'user' ? 'user' : 'assistant',
content: msg.text
}));
simulateAgentResponse(conversationHistory); // Pass history instead of just prompt
};
// Cleanup: Abort any ongoing fetch request if the component unmounts
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
return (
<div style={{ maxWidth: '600px', margin: '20px auto', border: '1px solid #ccc', borderRadius: '8px', display: 'flex', flexDirection: 'column', height: '80vh' }}>
<div style={{ flexGrow: 1, overflowY: 'auto', padding: '10px', backgroundColor: '#f9f9f9' }}>
{messages.map((msg) => (
<div key={msg.id} style={{
marginBottom: '10px',
textAlign: msg.sender === 'user' ? 'right' : 'left',
}}>
<span style={{
display: 'inline-block',
padding: '8px 12px',
borderRadius: '15px',
backgroundColor: msg.sender === 'user' ? '#007bff' : '#e0e0e0',
color: msg.sender === 'user' ? 'white' : 'black',
}}>
{msg.text}
</span>
</div>
))}
{isAgentThinking && (
<div style={{ marginBottom: '10px', textAlign: 'left' }}>
<span style={{ display: 'inline-block', padding: '8px 12px', borderRadius: '15px', backgroundColor: '#e0e0e0', color: 'black' }}>
Agent is thinking...
</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', padding: '10px', borderTop: '1px solid #eee' }}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
style={{ flexGrow: 1, padding: '8px', border: '1px solid #ccc', borderRadius: '4px', marginRight: '10px' }}
disabled={isAgentThinking}
/>
<button type="submit" disabled={isAgentThinking} style={{ padding: '8px 15px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
Send
</button>
</form>
</div>
);
};
export default AgentChat;
Explanation of Changes:
currentAgentResponsestate: A new state variable to temporarily hold the accumulating text for the current agent response being streamed.simulateAgentApiCall(Mock Backend):- This
useCallbackfunction now returns aResponseobject containing aReadableStream. - Inside the
ReadableStream’sstartmethod, weenqueue(send) different JSON objects representing agentthought,tool_call,tool_result, andfinal_answerat timed intervals. This simulates the real-time nature of an agent. - Each object is stringified and followed by a newline
\nto make parsing easier.
- This
processStream(Frontend Stream Processor):- This
useCallbackfunction takes theResponseobject and reads itsbodyusingresponse.body.getReader(). - A
TextDecoderis used to convert the rawUint8Arraychunks into human-readable strings. - A
bufferis maintained to handle cases where a JSON object might be split across multiple network chunks. We look for\nto identify complete lines. - When a complete JSON line is parsed, we check its
type(e.g.,thought,tool_call) and append formatted text to thetextof the current agent message in themessagesstate. - We initially add an empty agent message to
setMessagesand then update it incrementally.
- This
simulateAgentResponse(Orchestration Logic):- Sets
isAgentThinkingtotrue. - Creates an
AbortControllerto allow cancellation of thefetchrequest (important for cleanup and user experience). - Calls
simulateAgentApiCalland thenprocessStream. - Includes
try...catch...finallyfor robust error handling and to ensureisAgentThinkingis reset.
- Sets
handleSubmit: Now passes theconversationHistorytosimulateAgentResponse, demonstrating how the frontend maintains agent memory.useEffectfor Cleanup: AnuseEffecthook is added to abort any ongoingfetchrequest if the component unmounts, preventing memory leaks and unwanted state updates.isAgentThinkingdisabled inputs: The input field and send button are disabled while the agent is processing to prevent multiple simultaneous requests.
Now, when you type a message and send it, you’ll see the agent’s thought process, tool calls, and final answer stream in real-time!
Mini-Challenge: Enhancing Agent Status Display
Our current “Agent is thinking…” message is a bit generic. Let’s make it more descriptive.
Challenge: Modify the AgentChat component to display specific agent statuses (e.g., “Agent is thinking…”, “Agent is calling tool…”, “Agent is processing results…”) based on the type of the last streamed message received from the agent.
Hint:
- Introduce a new
useStatevariable,agentStatusText, initialized to an empty string. - Update
agentStatusTextinside theprocessStreamfunction whenever athought,tool_call, ortool_resulttype message is received. - Display
agentStatusTextinstead of the generic “Agent is thinking…” whenisAgentThinkingis true. Ensure to resetagentStatusTextwhen the agent is done.
What to observe/learn: This challenge reinforces dynamic UI updates based on streamed data and improves the user experience by providing more granular feedback about the agent’s actions.
Common Pitfalls & Troubleshooting
- Incomplete Streamed JSON Chunks:
- Pitfall: You might receive partial JSON objects if network packets split a JSON string. Directly calling
JSON.parse()on every raw chunk will lead to errors. - Troubleshooting: As implemented in
processStream, use abufferto accumulate chunks. Look for a delimiter (like\nin our example, often\n\nfor SSE or specific custom delimiters for other stream types) to identify complete messages before parsing.
- Pitfall: You might receive partial JSON objects if network packets split a JSON string. Directly calling
- Excessive Re-renders / UI Jank:
- Pitfall: Updating React state very frequently (e.g., for every single character in a stream) can cause your UI to become unresponsive, especially in React Native.
- Troubleshooting:
- Batch Updates: React 18+ automatically batches state updates within event handlers. For asynchronous updates (like in a stream loop), you might need
ReactDOM.unstable_batchedUpdates(for web) or ensure updates are not too granular. - Debounce/Throttle: If you’re updating a visual element based on character-by-character stream, consider debouncing the UI update to only happen every N milliseconds.
- Optimize
setMessages: In our example, we are creating a new array formessageson every stream chunk update. While necessary for React to detect changes, if messages contain very large objects or if updates are extremely frequent, this can be slow. Consider usingimmeroruseReducerfor more efficient immutable updates, or only updating thetextproperty of the last message directly (if appropriate for your UI).
- Batch Updates: React 18+ automatically batches state updates within event handlers. For asynchronous updates (like in a stream loop), you might need
- Memory Leaks with
fetchanduseState:- Pitfall: If a component that initiated a
fetchrequest unmounts before the request completes, thesetStatescalls within the.then()orprocessStreamchain will attempt to update state on an unmounted component, leading to warnings and potential memory leaks. - Troubleshooting: Use
AbortController(as shown in our example) to cancel thefetchrequest when the component unmounts. TheuseEffectcleanup function is the perfect place for this.
- Pitfall: If a component that initiated a
Summary: Orchestrating Frontend Intelligence
Phew! You’ve just taken a significant step in building truly intelligent frontend applications. Here’s a quick recap of what we covered:
- Understanding Agentic AI: We differentiated agents from simple models, recognizing their capabilities for reasoning, memory, and tool use.
- Frontend as an Orchestrator: You learned that your React/React Native application is responsible for managing the user’s interaction with the agent, displaying its multi-step process, and handling its dynamic state.
- Streaming for Engagement: We explored why streaming responses are critical for agentic UIs, providing real-time feedback and improving perceived performance.
- Handling Tool Calls: You saw how to display agent tool calls to the user, enhancing transparency, while understanding the security implications of keeping tool execution (and credentials) on the backend.
- Managing AI State: We delved into using React’s
useStateto manage conversation history, agent status, and accumulating streamed responses, effectively giving your agent “memory” on the client side. - Practical Implementation: You built a functional agentic chat interface that simulates a streaming backend, processing different types of agent steps and updating the UI incrementally.
- Common Pitfalls: We discussed how to handle incomplete streamed data, prevent UI jank, and avoid memory leaks in asynchronous AI interactions.
You now have the foundational knowledge and practical experience to integrate dynamic, multi-step AI agent interactions into your frontend applications. In the next chapter, we’ll build on this by exploring advanced asynchronous patterns, robust error handling, and crucial guardrails to make your AI-powered applications production-ready.
References
- React 19 Official Documentation
- React Native Official Documentation
- MDN Web Docs: Using the Fetch API
- MDN Web Docs: ReadableStream
- MDN Web Docs: AbortController
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.