Skip to main content
All posts

React 19 useActionState for Form-Driven AI Features: Building Progressive Enhancement Without JavaScript Frameworks Complexity

6 June 2026

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<string | null>(null);
const [result, setResult] = useState<string | null>(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);
  }
};

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

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<AnalysisState> {
  const feedback = formData.get('feedback') as string;
  const sentiment = formData.get('sentiment') as string;
  
  // Validation
  if (!feedback.trim() || feedback.length < 10) {
    return {
      error: 'Feedback must be at least 10 characters',
      analysis: null
    };
  }
  
  if (!['positive', 'negative', 'neutral'].includes(sentiment)) {
    return {
      error: 'Invalid sentiment selection',
      analysis: null
    };
  }
  
  try {
    // Call our backend which speaks to Claude
    const response = await fetch('/api/analyze-feedback', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ feedback, sentiment })
    });
    
    if (!response.ok) {
      const errData = await response.json();
      return {
        error: errData.message || 'Analysis failed',
        analysis: null
      };
    }
    
    const { analysis, tokenUsage } = await response.json();
    return {
      error: null,
      analysis,
      tokenUsage
    };
  } catch (err) {
    return {
      error: 'Network error. Please try again.',
      analysis: null
    };
  }
}

export function FeedbackAnalyzer() {
  const [state, formAction, isPending] = useActionState(
    analyzeFeedback,
    { error: null, analysis: null }
  );
  
  return (
    <div className="max-w-2xl mx-auto p-6">
      <form action={formAction} className="space-y-4">
        <div>
          <label className="block text-sm font-medium mb-2">
            Your Feedback
          </label>
          <textarea
            name="feedback"
            className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
            placeholder="Tell us what you think..."
            disabled={isPending}
          />
        </div>
        
        <div>
          <label className="block text-sm font-medium mb-2">
            Overall Sentiment
          </label>
          <select
            name="sentiment"
            className="w-full p-3 border border-gray-300 rounded-lg"
            disabled={isPending}
          >
            <option value="">Select sentiment...</option>
            <option value="positive">Positive</option>
            <option value="negative">Negative</option>
            <option value="neutral">Neutral</option>
          </select>
        </div>
        
        <button
          type="submit"
          disabled={isPending}
          className="w-full bg-blue-600 text-white py-2 rounded-lg disabled:opacity-50"
        >
          {isPending ? 'Analyzing...' : 'Analyze with Claude'}
        </button>
      </form>
      
      {state.error && (
        <div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
          <p className="text-red-800">{state.error}</p>
        </div>
      )}
      
      {state.analysis && (
        <div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
          <p className="text-green-900">{state.analysis}</p>
          {state.tokenUsage && (
            <p className="text-xs text-green-700 mt-2">
              Used {state.tokenUsage.input} input + {state.tokenUsage.output} output tokens
            </p>
          )}
        </div>
      )}
    </div>
  );
}

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

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:

  1. Render a success message instead of the form
  2. Use a ref to manually reset the form
  3. 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 `

You might also like

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch