{"slug": "react-19-useactionstate-for-form-driven-ai-features-building-progressive-without", "title": "React 19 useActionState for Form-Driven AI Features: Building Progressive Enhancement Without JavaScript Frameworks Complexity", "summary": "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.", "body_md": "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`\n\nhooks 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`\n\nin React 19, and it changed how I approach form-driven AI features.\n\nHere's the thing: `useActionState`\n\nisn'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.\n\nThe traditional pattern looks like this:\n\n```\nconst [input, setInput] = useState('');\nconst [loading, setLoading] = useState(false);\nconst [error, setError] = useState<string | null>(null);\nconst [result, setResult] = useState<string | null>(null);\n\nconst handleSubmit = async (e: React.FormEvent) => {\n  e.preventDefault();\n  setLoading(true);\n  setError(null);\n\n  try {\n    const response = await fetch('/api/analyze', {\n      method: 'POST',\n      body: JSON.stringify({ text: input })\n    });\n\n    if (!response.ok) throw new Error('API failed');\n    const data = await response.json();\n    setResult(data.analysis);\n  } catch (err) {\n    setError(err instanceof Error ? err.message : 'Unknown error');\n  } finally {\n    setLoading(false);\n  }\n};\n```\n\nThis 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.\n\nWith `useActionState`\n\n, you declare the action once and bind it directly to the form:\n\n```\nconst [state, formAction, isPending] = useActionState(\n  async (prevState, formData) => {\n    const text = formData.get('text') as string;\n\n    if (!text.trim()) {\n      return { error: 'Text cannot be empty', data: null };\n    }\n\n    try {\n      const response = await fetch('/api/analyze', {\n        method: 'POST',\n        body: JSON.stringify({ text })\n      });\n\n      if (!response.ok) {\n        return { error: 'Analysis failed', data: null };\n      }\n\n      const data = await response.json();\n      return { error: null, data: data.analysis };\n    } catch (err) {\n      return { \n        error: err instanceof Error ? err.message : 'Unknown error',\n        data: null \n      };\n    }\n  },\n  { error: null, data: null }\n);\n```\n\nNotice what's gone: no `setLoading`\n\n, no `setError`\n\n, no manual event handling. The form submission is implicit. `isPending`\n\nis automatic. Validation and async logic live in the same function.\n\nLet me show you a real pattern from CitizenApp. We have a feature that analyzes user feedback using Claude. Here's the component:\n\n``` js\n'use client';\n\nimport { useActionState } from 'react';\n\ninterface AnalysisState {\n  error: string | null;\n  analysis: string | null;\n  tokenUsage?: { input: number; output: number };\n}\n\nasync function analyzeFeedback(\n  _prevState: AnalysisState,\n  formData: FormData\n): Promise<AnalysisState> {\n  const feedback = formData.get('feedback') as string;\n  const sentiment = formData.get('sentiment') as string;\n\n  // Validation\n  if (!feedback.trim() || feedback.length < 10) {\n    return {\n      error: 'Feedback must be at least 10 characters',\n      analysis: null\n    };\n  }\n\n  if (!['positive', 'negative', 'neutral'].includes(sentiment)) {\n    return {\n      error: 'Invalid sentiment selection',\n      analysis: null\n    };\n  }\n\n  try {\n    // Call our backend which speaks to Claude\n    const response = await fetch('/api/analyze-feedback', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ feedback, sentiment })\n    });\n\n    if (!response.ok) {\n      const errData = await response.json();\n      return {\n        error: errData.message || 'Analysis failed',\n        analysis: null\n      };\n    }\n\n    const { analysis, tokenUsage } = await response.json();\n    return {\n      error: null,\n      analysis,\n      tokenUsage\n    };\n  } catch (err) {\n    return {\n      error: 'Network error. Please try again.',\n      analysis: null\n    };\n  }\n}\n\nexport function FeedbackAnalyzer() {\n  const [state, formAction, isPending] = useActionState(\n    analyzeFeedback,\n    { error: null, analysis: null }\n  );\n\n  return (\n    <div className=\"max-w-2xl mx-auto p-6\">\n      <form action={formAction} className=\"space-y-4\">\n        <div>\n          <label className=\"block text-sm font-medium mb-2\">\n            Your Feedback\n          </label>\n          <textarea\n            name=\"feedback\"\n            className=\"w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500\"\n            placeholder=\"Tell us what you think...\"\n            disabled={isPending}\n          />\n        </div>\n\n        <div>\n          <label className=\"block text-sm font-medium mb-2\">\n            Overall Sentiment\n          </label>\n          <select\n            name=\"sentiment\"\n            className=\"w-full p-3 border border-gray-300 rounded-lg\"\n            disabled={isPending}\n          >\n            <option value=\"\">Select sentiment...</option>\n            <option value=\"positive\">Positive</option>\n            <option value=\"negative\">Negative</option>\n            <option value=\"neutral\">Neutral</option>\n          </select>\n        </div>\n\n        <button\n          type=\"submit\"\n          disabled={isPending}\n          className=\"w-full bg-blue-600 text-white py-2 rounded-lg disabled:opacity-50\"\n        >\n          {isPending ? 'Analyzing...' : 'Analyze with Claude'}\n        </button>\n      </form>\n\n      {state.error && (\n        <div className=\"mt-4 p-4 bg-red-50 border border-red-200 rounded-lg\">\n          <p className=\"text-red-800\">{state.error}</p>\n        </div>\n      )}\n\n      {state.analysis && (\n        <div className=\"mt-4 p-4 bg-green-50 border border-green-200 rounded-lg\">\n          <p className=\"text-green-900\">{state.analysis}</p>\n          {state.tokenUsage && (\n            <p className=\"text-xs text-green-700 mt-2\">\n              Used {state.tokenUsage.input} input + {state.tokenUsage.output} output tokens\n            </p>\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n```\n\nThe backend (FastAPI) is straightforward:\n\n``` python\nfrom fastapi import FastAPI, HTTPException\nimport anthropic\n\napp = FastAPI()\n\n@app.post(\"/api/analyze-feedback\")\nasync def analyze_feedback(request: dict):\n    feedback = request.get(\"feedback\", \"\").strip()\n    sentiment = request.get(\"sentiment\", \"\").strip()\n\n    if not feedback or len(feedback) < 10:\n        raise HTTPException(status_code=400, detail=\"Feedback too short\")\n\n    if sentiment not in [\"positive\", \"negative\", \"neutral\"]:\n        raise HTTPException(status_code=400, detail=\"Invalid sentiment\")\n\n    client = anthropic.Anthropic()\n\n    try:\n        message = client.messages.create(\n            model=\"claude-3-5-sonnet-20241022\",\n            max_tokens=500,\n            messages=[\n                {\n                    \"role\": \"user\",\n                    \"content\": f\"Analyze this {sentiment} feedback and provide 2-3 actionable insights:\\n\\n{feedback}\"\n                }\n            ]\n        )\n\n        return {\n            \"analysis\": message.content[0].text,\n            \"tokenUsage\": {\n                \"input\": message.usage.input_tokens,\n                \"output\": message.usage.output_tokens\n            }\n        }\n    except anthropic.APIError as e:\n        raise HTTPException(status_code=500, detail=str(e))\n```\n\nHere's why I prefer `useActionState`\n\nover Next.js Server Actions for this pattern: it works as regular form submission even without JavaScript. Your semantic HTML works. The `disabled`\n\nattributes 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.\n\nThe 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.\n\n**The gotcha**: `useActionState`\n\ndoesn't clear the form automatically. If you want to reset inputs after successful submission, you need to either:\n\nI initially expected it to be like form libraries, but it's simpler—intentionally. It's a state machine, not form magic.\n\nAlso: the `", "url": "https://wpnews.pro/news/react-19-useactionstate-for-form-driven-ai-features-building-progressive-without", "canonical_source": "https://dev.to/uaslimcreate/react-19-useactionstate-for-form-driven-ai-features-building-progressive-enhancement-without-3jl4", "published_at": "2026-06-06 08:47:49+00:00", "updated_at": "2026-06-06 09:11:42.680299+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-tools", "ai-products", "generative-ai", "large-language-models"], "entities": ["React 19", "useActionState", "CitizenApp", "Claude"], "alternates": {"html": "https://wpnews.pro/news/react-19-useactionstate-for-form-driven-ai-features-building-progressive-without", "markdown": "https://wpnews.pro/news/react-19-useactionstate-for-form-driven-ai-features-building-progressive-without.md", "text": "https://wpnews.pro/news/react-19-useactionstate-for-form-driven-ai-features-building-progressive-without.txt", "jsonld": "https://wpnews.pro/news/react-19-useactionstate-for-form-driven-ai-features-building-progressive-without.jsonld"}}