Correct way to receive agent started talking events on the frontend

I’m having trouble making useAgent work, I am getting a cascading of errors on app launch before user connects:
Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn’t have a dependency array, or one of the dependencies changes on every render.

Here’s my setup:
<LiveKitRoom

      *serverUrl*={url}

token={token}

connect={true}

onConnected={() => console.log(“[ROOM] Connected to LiveKit room”)}

onDisconnected={onDisconnected}

onError={(error) => console.error(“[ROOM ERROR]”, error)}

audio={true}

>

<AgentStateWatcher onAgentState={setAgentState} />

and:

function AgentStateWatcher({

onAgentState,

}: {

onAgentState: (s: string) => void;

}) {

const agent = useAgent();

useEffect(() => {

onAgentState(agent.state ?? “idle”);

}, [agent.state, onAgentState]);

return null;

}

This error isn’t coming from useAgent itself, it’s caused by your effect re-triggering on every render because onAgentState is a new function reference each time.

In your setup:

<AgentStateWatcher onAgentState={setAgentState} />

If setAgentState comes from a parent useStateThat’s stable. But if you’re wrapping it (e.g. s => setAgentState(s)), that wrapper changes every render and causes your effect to loop.

Your effect depends on:

[agent.state, onAgentState]

If onAgentState changes → effect runs → parent state updates → rerender → new onAgentState → infinite loop → “Maximum update depth exceeded”.

:white_check_mark: Fix (recommended)

Remove onAgentState from the dependency list and guard the update:

useEffect(() => {
const next = agent.state ?? “idle”;
onAgentState(next);
}, [agent.state]);

Or even better, avoid the watcher entirely and read the state directly where needed:

const { state } = useAgent();

useAgent is designed to be consumed directly inside components (as shown in the React example on the Audio Visualizer page), not mirrored into React state unless you truly need it.

I already tried removing onAgentState from the dependency list. I’ve actually tried many different workarounds for this. No matter what I do, I get a cascading of “max depth exceeded” errors. This is what I need:

import { useState, useEffect } from "react";
import { fetch } from "@tauri-apps/plugin-http";
import { LiveKitRoom, VideoConference } from "@livekit/components-react";
import "@livekit/components-styles";
import { Canvas } from "@react-three/fiber";
import { useAgent } from "@livekit/components-react";
import { Center, OrbitControls } from "@react-three/drei";
import {
  EffectComposer,
  Outline,
  Selection,
  Select,
} from "@react-three/postprocessing";
import { BlendFunction } from "postprocessing";
import type { User } from "@supabase/supabase-js";
import { Yuki } from "../yuki";
import { useYukiStateMachine } from "../hooks/useYukiStateMachine";

interface MainAppProps {
  user: User;
  accessToken: string;
  onSignOut: () => void;
}

interface LiveKitTokenResponse {
  server_url: string;
  participant_token: string;
  room_name: string;
}

async function getLiveKitToken(
  accessToken: string,
  timezone: string,
): Promise<LiveKitTokenResponse> {
  const response = await fetch(`<url>`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ timezone }),
  });
  if (!response.ok) throw new Error(`Token server error: ${response.status}`);
  return response.json() as Promise<LiveKitTokenResponse>;
}

// My approach:
// reads agent.state from the LiveKit context and calls the setter so the
// state machine (which lives outside the LiveKit context) can react.
function AgentStateWatcher({
  onAgentState,
}: {
  onAgentState: (s: string) => void;
}) {
  const agent = useAgent();
  useEffect(() => {
    onAgentState(agent.state ?? "idle");
  }, [agent.state, onAgentState]);
  return null;
}

function YukiWithStateMachine({ agentState }: { agentState: string }) {
  const { currentAnimation, onAnimationComplete } =
    useYukiStateMachine(agentState);
  return (
    <Select enabled>
      <Center>
        <Yuki
          currentAnimation={currentAnimation}
          onAnimationComplete={onAnimationComplete}
        />
      </Center>
    </Select>
  );
}

export function MainApp({ user, accessToken, onSignOut }: MainAppProps) {
  const [token, setToken] = useState("");
  const [url, setUrl] = useState("");
  const [connected, setConnected] = useState(false);
  const [agentState, setAgentState] = useState("idle");

  const connect = async () => {
    try {
      const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
      const { server_url, participant_token } = await getLiveKitToken(
        accessToken,
        timezone,
      );
      setUrl(server_url);
      setToken(participant_token);
      setConnected(true);
    } catch (e) {
      console.error("[ROOM ERROR] Failed to connect:", e);
      alert("Failed to get LiveKit token. See console for details.");
    }
  };

  const onDisconnected = () => {
    setConnected(false);
    setToken("");
    setUrl("");
  };

  return (
    <div className="container" data-lk-theme="default">
      <div className="flex items-center justify-between px-4 py-2">
        <h1 className="text-xl font-semibold">Yuki</h1>
        <div className="flex items-center gap-3">
          <span className="text-sm text-muted-foreground">{user.email}</span>
          <button
            onClick={onSignOut}
            className="text-sm underline-offset-4 hover:underline"
          >
            Sign out
          </button>
        </div>
      </div>

      {/* 3D Model Display */}
      <div style={{ width: "100%", height: "400px", marginBottom: "20px" }}>
        <Canvas camera={{ position: [0, 1, 3], fov: 30 }}>
          <ambientLight intensity={0.08} color="#c8d0ff" />

          {/* Warm key light — upper-front-right, drives the main toon band split */}
          <directionalLight
            position={[2, 5, 3]}
            intensity={4.5}
            color="#fff8e8"
          />

          {/* Cool rim / backlight — upper-back-left, classic anime halo */}
          <directionalLight
            position={[-3, 3, -4]}
            intensity={1.8}
            color="#7090ff"
          />

          {/* Soft ground-bounce fill — lifts the chin/underside slightly */}
          <directionalLight
            position={[0, -2, 2]}
            intensity={0.25}
            color="#a0c8ff"
          />

          <Selection>
            <EffectComposer autoClear={false}>
              <Outline
                blendFunction={BlendFunction.ALPHA}
                edgeStrength={4}
                visibleEdgeColor={0x000000}
                hiddenEdgeColor={0x000000}
                blur={false}
                xRay={false}
              />
            </EffectComposer>

            <YukiWithStateMachine agentState={agentState} />
          </Selection>
          <OrbitControls
            target={[0, 0.5, 0]}
            minDistance={0.5}
            maxDistance={6}
            minPolarAngle={Math.PI / 6}
            maxPolarAngle={Math.PI / 1.8}
          />
        </Canvas>
      </div>

      {!connected ? (
        <div className="card">
          <h2>Join Room</h2>
          <button
            onClick={connect}
            style={{
              padding: "10px 20px",
              backgroundColor: "#28a745",
              color: "white",
              border: "none",
              borderRadius: 4,
              cursor: "pointer",
              fontWeight: "bold",
              fontSize: 16,
            }}
          >
            Connect to Agent
          </button>
        </div>
      ) : (
        <LiveKitRoom
          serverUrl={url}
          token={token}
          connect={true}
          onConnected={() => console.log("[ROOM] Connected to LiveKit room")}
          onDisconnected={onDisconnected}
          onError={(error) => console.error("[ROOM ERROR]", error)}
          audio={true}
        >
          <AgentStateWatcher onAgentState={setAgentState} />
          <VideoConference />
        </LiveKitRoom>
      )}
    </div>
  );
}

The useAgent hook is designed to be consumed directly in render, not mirrored into your own React state on every change. As shown in the Agents UI example, you typically read { state } from useAgent() and pass it down as props, without syncing it via useEffect and setState (Audio visualizer example).

function YukiWithStateMachine() {

  const { state } = useAgent();

  const { currentAnimation, onAnimationComplete } =

  useYukiStateMachine(state ?? "idle");

  return (

  <Select enabled>

  <Center>

  <Yuki

    currentAnimation={currentAnimation}

    onAnimationComplete={onAnimationComplete}

  />

</Center>

</Select>

);

}

Your AgentStateWatcher + setAgentState pattern can easily create a render loop if your state machine causes any upstream re-render that affects the agent context.

Instead of copying agent.state into the local state, read it where you need it:

gotcha. I figured it out. Turns out it was an unsolved issue relating to react-three-drei and .

I also switched to useVoiceAssistant for this to work.