cd /news/artificial-intelligence/react-19-useactionstate-for-form-dri… · home topics artificial-intelligence article
[ARTICLE · art-23254] src=dev.to pub= topic=artificial-intelligence verified=true sentiment=↑ positive

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

A developer at CitizenApp built nine AI features using React 19's `useActionState` hook, which eliminates the need for multiple `useState` calls to manage loading, error, and result states in form-driven features. The hook allows developers to declare form submission actions directly, automatically handling pending states and validation without manual state orchestration. This approach reduces complexity and race conditions, particularly for AI features that require progressive feedback during API calls.

read5 min publishedJun 6, 2026

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 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— 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.

The traditional pattern looks like this:

const [input, setInput] = useState('');
const [, set] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  set(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 {
    set(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 set

, no setError

, no manual event handling. The form submission is implicit. isPending

is automatic. Validation and async logic live in the same function.

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

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.

The gotcha: useActionState

doesn't clear the form automatically. If you want to reset inputs after successful submission, you need to either:

I initially expected it to be like form libraries, but it's simpler—intentionally. It's a state machine, not form magic.

Also: the `

── more in #artificial-intelligence 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/react-19-useactionst…] indexed:0 read:5min 2026-06-06 ·