DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

React 19 useTransition for Progressive AI Feature Rollouts: Non-Blocking State Updates Without Loading Spinners

When you have 9 AI features firing inference requests simultaneously in a multi-tenant dashboard, React's default behavior will choke. Every Claude API response triggers a state update that blocks user input, creating janky scrolling, delayed button clicks, and frustrated power users. I solved this at CitizenApp with useTransition, and it's a game-changer—but it's not what most people think it is.

useTransition isn't about showing loading spinners. It's about telling React: "this state update is less important than the user typing or clicking." React will batch these updates and deprioritize them, keeping your UI responsive while your AI features catch up in the background.

Why useTransition Matters for AI-Heavy Apps

Most React devs conflate useTransition with useOptimistic. They're not the same.

useOptimistic is for predicting immediate results (you hit "like," the count goes up instantly, then the server confirms). useTransition is for backgrounding expensive computations that you know will take time.

At CitizenApp, when a user triggers the content generation feature, our Claude call takes 2–5 seconds. The old approach:

const [generatedContent, setGeneratedContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);

async function handleGenerateContent(prompt: string) {
  setIsLoading(true);
  const result = await callClaude(prompt);
  setGeneratedContent(result);
  setIsLoading(false);
}
Enter fullscreen mode Exit fullscreen mode

This works until you have multiple AI features updating simultaneously. The user scrolls the dashboard while waiting for transcription, summarization, and tone analysis to complete—and the state updates from each feature lock up the thread.

With useTransition, React knows to keep the UI responsive:

const [generatedContent, setGeneratedContent] = useState<string>("");
const [isPending, startTransition] = useTransition();

async function handleGenerateContent(prompt: string) {
  startTransition(async () => {
    const result = await callClaude(prompt);
    setGeneratedContent(result);
  });
}
Enter fullscreen mode Exit fullscreen mode

The difference is subtle but profound: when startTransition wraps your state update, React marks it as non-urgent. User interactions (scrolling, typing, clicking) get priority. The inference result updates the DOM, but it won't block input handling.

The Real-World CitizenApp Example

Here's how I integrated this into our multi-tenant dashboard. We have a card-based interface where users can trigger different AI features on the same content:

// dashboard/ContentCard.tsx
import { useTransition } from "react";
import { generateSummary, generateTones, generateKeywords } from "@/api/claude";

interface ContentCardProps {
  contentId: string;
  initialContent: string;
}

export function ContentCard({ contentId, initialContent }: ContentCardProps) {
  const [summary, setSummary] = useState<string>("");
  const [tones, setTones] = useState<string[]>([]);
  const [keywords, setKeywords] = useState<string[]>([]);

  const [isPending, startTransition] = useTransition();

  // Each feature gets wrapped in startTransition
  async function analyzeSummary() {
    startTransition(async () => {
      const result = await generateSummary(initialContent);
      setSummary(result);
    });
  }

  async function analyzeTones() {
    startTransition(async () => {
      const result = await generateTones(initialContent);
      setTones(result);
    });
  }

  async function analyzeKeywords() {
    startTransition(async () => {
      const result = await generateKeywords(initialContent);
      setKeywords(result);
    });
  }

  return (
    <div className="space-y-4">
      {/* User can click these buttons and interact with the page freely */}
      <button 
        onClick={analyzeSummary} 
        disabled={isPending}
        className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        Generate Summary
      </button>
      <button 
        onClick={analyzeTones} 
        disabled={isPending}
        className="px-4 py-2 bg-green-600 text-white rounded disabled:opacity-50"
      >
        Detect Tones
      </button>
      <button 
        onClick={analyzeKeywords} 
        disabled={isPending}
        className="px-4 py-2 bg-purple-600 text-white rounded disabled:opacity-50"
      >
        Extract Keywords
      </button>

      {isPending && <p className="text-gray-500 text-sm">Processing...</p>}

      {summary && (
        <div className="p-3 bg-blue-50 rounded border border-blue-200">
          <h4 className="font-semibold mb-1">Summary</h4>
          <p>{summary}</p>
        </div>
      )}

      {tones.length > 0 && (
        <div className="p-3 bg-green-50 rounded border border-green-200">
          <h4 className="font-semibold mb-2">Detected Tones</h4>
          <div className="flex gap-2 flex-wrap">
            {tones.map((tone) => (
              <span key={tone} className="px-2 py-1 bg-green-200 rounded text-sm">
                {tone}
              </span>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The isPending flag tells you when any startTransition is active. This is crucial: you can disable buttons to prevent request spam without showing a traditional loader. The UI remains responsive because React deprioritizes the state updates that happen inside startTransition.

Backend Considerations: FastAPI + SQLAlchemy

Your frontend strategy only matters if your backend doesn't bottleneck. I always pair this with proper async handling:

# app/routes/claude.py
from fastapi import FastAPI, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from anthropic import AsyncAnthropic

app = FastAPI()
client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)

@app.post("/api/summarize")
async def summarize(
    request: SummarizeRequest,
    session: AsyncSession,
    background_tasks: BackgroundTasks
):
    # Return immediately with a task ID for polling
    task_id = str(uuid.uuid4())

    # Store placeholder result in cache
    await cache.set(f"task:{task_id}", {"status": "processing"}, ex=300)

    # Queue the heavy work
    background_tasks.add_task(
        run_claude_summarization,
        task_id=task_id,
        content=request.content,
        session=session
    )

    return {"task_id": task_id, "status": "queued"}

async def run_claude_summarization(task_id: str, content: str, session: AsyncSession):
    try:
        message = await client.messages.create(
            model="claude-3-5-sonnet-20241022",
            max_tokens=1024,
            messages=[
                {"role": "user", "content": f"Summarize this:\n\n{content}"}
            ]
        )

        result = message.content[0].text
        await cache.set(f"task:{task_id}", {"status": "complete", "result": result}, ex=3600)

        # Persist to database
        stmt = insert(AIResult).values(
            id=task_id,
            feature_type="summarization",
            result=result,
            created_at=datetime.utcnow()
        )
        await session.execute(stmt)
        await session.commit()
    except Exception as e:
        await cache.set(f"task:{task_id}", {"status": "error", "error": str(e)}, ex=300)
Enter fullscreen mode Exit fullscreen mode

This prevents the frontend from waiting on the backend. The AI processing happens asynchronously while the user gets immediate feedback.

Gotcha: useTransition with Async State Updates

Here's what burned me: useTransition doesn't magically make async operations non-blocking. If you wrap a setTimeout in startTransition, React will still block on it.

// ❌ This doesn't work as expected
startTransition(async () => {
  await new Promise(resolve => setTimeout(resolve, 2000));
  setState("done");
});
// React will still wait for the 2-second timeout before re-enabling urgent updates
Enter fullscreen mode Exit fullscreen mode

The non-blocking behavior only applies to the state update itself, not the async operation. For true background processing, you need server-side queueing (like the FastAPI example above) or use useTransition in tandem with polling via a separate hook.

What I Missed Initially

I thought isPending would be false while the async operation was running. Wrong. isPending becomes false as soon as React starts the state update, not when the Promise resolves. For visual feedback on long-running operations, pair useTransition with a separate loading state or use React Query's built-in pending states.

useTransition shines for batching multiple concurrent updates and preventing input lag—it's the difference between a dashboard that feels responsive and one that feels sluggish when 9 AI features are fighting for the main thread. Once you have this pattern in place, your users won't complain about inference latency; they'll complain when features aren't available.

Top comments (0)