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);
}
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);
});
}
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>
);
}
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)
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
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)