React 19 useActionState for Form-Driven AI Features: Building Progressive Enhancement Without JavaScript Frameworks Complexity
Source: Dev.to
React 19 useActionState for Form-Driven AI Features: Building Progressive Enhancement Without JavaScript Frameworks Complexity
I’ve built nine AI features in CitizenApp, and I can tell you with certainty: most of them started as a tangled mess of useState hooks managing loading states, error states, and async operations simultaneously. The breaking point came when I realized I was writing the same form state machine over and over—loading spinner, validation errors, success message, then back to idle. That’s when I discovered useActionState in React 19, and it changed how I approach form-driven AI features.
Here’s the thing: useActionState isn’t just syntactic sugar. It fundamentally shifts your mental model from “manage component state, then handle side effects” to “declare what happens when the form submits.” For AI features especially—where you’re waiting on Claude API responses and need to show progressive feedback—this is exactly what you need.
Why useActionState, Not useState + useEffect?
The traditional pattern looks like this:
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [result, setResult] = useState(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch('/api/analyze', {
method: 'POST',
body: JSON.stringify({ text: input })
});
if (!response.ok) throw new Error('API failed');
const data = await response.json();
setResult(data.analysis);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
Enter fullscreen mode
Exit fullscreen mode
This works, but you’re manually orchestrating four separate state updates. Race conditions hide in there. The form is uncontrolled by the server. Validation errors from Claude get mixed with network errors. By the time you add optimistic updates, you’ve got spaghetti.
With useActionState, you declare the action once and bind it directly to the form:
const [state, formAction, isPending] = useActionState(
async (prevState, formData) => {
const text = formData.get('text') as string;
if (!text.trim()) {
return { error: 'Text cannot be empty', data: null };
}
try {
const response = await fetch('/api/analyze', {
method: 'POST',
body: JSON.stringify({ text })
});
if (!response.ok) {
return { error: 'Analysis failed', data: null };
}
const data = await response.json();
return { error: null, data: data.analysis };
} catch (err) {
return {
error: err instanceof Error ? err.message : 'Unknown error',
data: null
};
}
},
{ error: null, data: null }
);
Enter fullscreen mode
Exit fullscreen mode
Notice what’s gone: no setLoading, no setError, no manual event handling. The form submission is implicit. isPending is automatic. Validation and async logic live in the same function.
Building an AI Feature with useActionState
Let me show you a real pattern from CitizenApp. We have a feature that analyzes user feedback using Claude. Here’s the component:
'use client';
import { useActionState } from 'react';
interface AnalysisState {
error: string | null;
analysis: string | null;
tokenUsage?: { input: number; output: number };
}
async function analyzeFeedback(
_prevState: AnalysisState,
formData: FormData
): Promise {
const feedback = formData.get('feedback') as string;
const sentiment = formData.get('sentiment') as string;
// Validation
if (!feedback.trim() || feedback.length
Your Feedback
Overall Sentiment
Select sentiment...
Positive
Negative
Neutral
{isPending ? 'Analyzing...' : 'Analyze with Claude'}
{state.error && (
{state.error}
)}
{state.analysis && (
{state.analysis}
{state.tokenUsage && (
Used {state.tokenUsage.input} input + {state.tokenUsage.output} output tokens
)}
)}
);
}
Enter fullscreen mode
Exit fullscreen mode
The backend (FastAPI) is straightforward:
from fastapi import FastAPI, HTTPException
import anthropic
app = FastAPI()
@app.post("/api/analyze-feedback")
async def analyze_feedback(request: dict):
feedback = request.get("feedback", "").strip()
sentiment = request.get("sentiment", "").strip()
if not feedback or len(feedback) < 10:
raise HTTPException(status_code=400, detail="Feedback too short")
if sentiment not in ["positive", "negative", "neutral"]:
raise HTTPException(status_code=400, detail="Invalid sentiment")
client = anthropic.Anthropic()
try:
message = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=500,
messages=[
{
"role": "user",
"content": f"Analyze this {sentiment} feedback and provide 2-3 actionable insights:\n\n{feedback}"
}
]
)
return {
"analysis": message.content[0].text,
"tokenUsage": {
"input": message.usage.input_tokens,
"output": message.usage.output_tokens
}
}
except anthropic.APIError as e:
raise HTTPException(status_code=500, detail=str(e))
Enter fullscreen mode
Exit fullscreen mode
The Real Win: Semantic HTML and Progressive Enhancement
Here’s why I prefer useActionState over Next.js Server Actions for this pattern: it works as regular form submission even without JavaScript. Your semantic HTML works. The disabled attributes prevent double-submission. If you’re using Astro with islands architecture or just React on its own, you’re not locked into a full-stack framework.
The form will submit, the server will process it, and if JavaScript fails to load, your users still get an error message or a page reload. That’s resilience.
What I Missed the First Time
The gotcha: useActionState doesn’t clear the form automatically. If you want to reset inputs after successful submission, you need to either:
-
Render a success message instead of the form
-
Use a ref to manually reset the form
-
Return a new form state that tracks submission ID and re-render conditionally
I initially expected it to be like form libraries, but it’s simpler—intentionally. It’s a state machine, not form magic.
Also: the `
