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:

  1. Reasoning: They can analyze a prompt, understand the intent, and formulate a plan.
  2. Memory: They can remember previous interactions and context, making conversations more coherent.
  3. Tool Use: They can decide to use external functions or APIs (tools) to gather information or perform actions.
  4. 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:

sequenceDiagram participant User participant Frontend (React/RN) participant Backend (Agent API) participant External Tool/API User->>Frontend (React/RN): Enters Prompt Frontend (React/RN)->>Backend (Agent API): Send Prompt + History Backend (Agent API)-->>Frontend (React/RN): Stream: "Thinking..." Backend (Agent API)->>Backend (Agent API): Agent Reasoning + Planning Backend (Agent API)-->>Frontend (React/RN): Stream: "Calling Tool X..." Backend (Agent API)->>External Tool/API: Execute Tool X External Tool/API-->>Backend (Agent API): Tool X Result Backend (Agent API)-->>Frontend (React/RN): Stream: "Tool X returned Y..." Backend (Agent API)->>Backend (Agent API): Agent Continues Reasoning Backend (Agent API)-->>Frontend (React/RN): Stream: Final Answer Frontend (React/RN)-->>User: Display Final Answer

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:

  1. 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.
  2. fetch API with ReadableStream: 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, fetch with ReadableStream is 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, or error? 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:

  • useState hooks: messages (to store chat history), input (for the message input field), isAgentThinking (to show a loading state).
  • useRef: messagesEndRef is used to automatically scroll to the bottom of the chat whenever new messages arrive.
  • useEffect: Calls scrollToBottom after every render if messages changes.
  • handleSubmit: Prevents default form submission, adds the user’s message to messages, clears the input, and calls simulateAgentResponse.
  • The UI renders user and agent messages differently based on sender.
  • A placeholder “Agent is thinking…” message is displayed when isAgentThinking is 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:

  1. currentAgentResponse state: A new state variable to temporarily hold the accumulating text for the current agent response being streamed.
  2. simulateAgentApiCall (Mock Backend):
    • This useCallback function now returns a Response object containing a ReadableStream.
    • Inside the ReadableStream’s start method, we enqueue (send) different JSON objects representing agent thought, tool_call, tool_result, and final_answer at timed intervals. This simulates the real-time nature of an agent.
    • Each object is stringified and followed by a newline \n to make parsing easier.
  3. processStream (Frontend Stream Processor):
    • This useCallback function takes the Response object and reads its body using response.body.getReader().
    • A TextDecoder is used to convert the raw Uint8Array chunks into human-readable strings.
    • A buffer is maintained to handle cases where a JSON object might be split across multiple network chunks. We look for \n to identify complete lines.
    • When a complete JSON line is parsed, we check its type (e.g., thought, tool_call) and append formatted text to the text of the current agent message in the messages state.
    • We initially add an empty agent message to setMessages and then update it incrementally.
  4. simulateAgentResponse (Orchestration Logic):
    • Sets isAgentThinking to true.
    • Creates an AbortController to allow cancellation of the fetch request (important for cleanup and user experience).
    • Calls simulateAgentApiCall and then processStream.
    • Includes try...catch...finally for robust error handling and to ensure isAgentThinking is reset.
  5. handleSubmit: Now passes the conversationHistory to simulateAgentResponse, demonstrating how the frontend maintains agent memory.
  6. useEffect for Cleanup: An useEffect hook is added to abort any ongoing fetch request if the component unmounts, preventing memory leaks and unwanted state updates.
  7. isAgentThinking disabled 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:

  1. Introduce a new useState variable, agentStatusText, initialized to an empty string.
  2. Update agentStatusText inside the processStream function whenever a thought, tool_call, or tool_result type message is received.
  3. Display agentStatusText instead of the generic “Agent is thinking…” when isAgentThinking is true. Ensure to reset agentStatusText when 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

  1. 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 a buffer to accumulate chunks. Look for a delimiter (like \n in our example, often \n\n for SSE or specific custom delimiters for other stream types) to identify complete messages before parsing.
  2. 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 for messages on 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 using immer or useReducer for more efficient immutable updates, or only updating the text property of the last message directly (if appropriate for your UI).
  3. Memory Leaks with fetch and useState:
    • Pitfall: If a component that initiated a fetch request unmounts before the request completes, the setStates calls within the .then() or processStream chain 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 the fetch request when the component unmounts. The useEffect cleanup function is the perfect place for this.

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 useState to 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


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.