React 19, useActionState로 폼 기반 AI 기능 구현: 프레임워크 복잡성 없이 점진적 향상 구축
Source: Dev.to
React 19 useActionState 로 폼 기반 AI 기능 구현: 자바스크립트 프레임워크 복잡성 없이 프로그레시브 향상 구축
CitizenApp에서 AI 기능을 아홉 개나 만들었는데, 확실히 말씀드릴 수 있습니다. 대부분은 useState 훅을 얽어매어 로딩 상태, 오류 상태, 비동기 작업을 동시에 관리하던 엉망진창이었습니다. 같은 폼 상태 머신—로딩 스피너, 검증 오류, 성공 메시지, 그리고 다시 대기 상태—을 반복해서 작성하고 있다는 사실을 깨달았을 때 한계에 다다랐습니다. 그때 React 19의 useActionState를 발견했고, 폼 기반 AI 기능을 접근하는 방식이 완전히 바뀌었습니다.
핵심은 이렇습니다. useActionState는 단순한 문법 설탕이 아닙니다. “컴포넌트 상태를 관리하고 부수 효과를 처리한다”는 사고방식에서 “폼이 제출될 때 일어날 일을 선언한다”는 사고방식으로 근본적으로 전환시켜 줍니다. 특히 Claude API 응답을 기다리면서 진행 상황을 단계적으로 보여줘야 하는 AI 기능에서는 딱 맞는 솔루션입니다.
왜 useActionState인가? useState + useEffect 대신
전통적인 패턴은 다음과 같습니다.
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);
}
};
전체 화면 모드 진입
전체 화면 모드 종료
이 코드는 동작하지만 네 개의 별도 상태 업데이트를 수동으로 조정해야 합니다. 레이스 컨디션이 숨어들고, 폼은 서버에 의해 제어되지 않으며, Claude의 검증 오류와 네트워크 오류가 뒤섞입니다. 낙관적 업데이트까지 추가하면 코드가 스파게티처럼 얽히게 됩니다.
useActionState를 사용하면 액션을 한 번 선언하고 폼에 바로 바인딩합니다.
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 }
);
전체 화면 모드 진입
전체 화면 모드 종료
사라진 부분을 보세요: setLoading, setError 같은 수동 업데이트가 없습니다. 폼 제출 자체가 암묵적으로 처리됩니다. isPending은 자동으로 관리됩니다. 검증 로직과 비동기 로직이 같은 함수 안에 존재합니다.
useActionState 로 AI 기능 만들기
CitizenApp에서 실제로 사용되는 패턴을 보여드리겠습니다. Claude를 이용해 사용자 피드백을 분석하는 기능입니다. 아래가 해당 컴포넌트 코드입니다.
'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
)}
)}
);
}
전체 화면 모드 진입
전체 화면 모드 종료
백엔드(FastAPI) 코드는 매우 간단합니다.
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))
전체 화면 모드 진입
전체 화면 모드 종료
실제 승리 포인트: 시맨틱 HTML과 프로그레시브 향상
왜 나는 이 패턴에서 Next.js Server Actions 보다 useActionState를 선호하는지 설명하겠습니다. 바로 자바스크립트가 없어도 일반 폼 제출처럼 동작한다는 점입니다. 시맨틱 HTML이 그대로 작동하고, disabled 속성 덕분에 중복 제출을 방지합니다. Astro의 아이랜드 아키텍처든, 순수 React든, 전체 스택 프레임워크에 얽매이지 않습니다.
폼이 제출되면 서버가 처리하고, 자바스크립트 로드에 실패하더라도 사용자는 오류 메시지나 페이지 새로고침을 통해 결과를 확인할 수 있습니다. 이것이 바로 회복력(resilience)입니다.
처음에 놓쳤던 점
함정: useActionState는 폼을 자동으로 초기화하지 않습니다. 성공적으로 제출된 뒤 입력값을 리셋하려면 다음 중 하나를 선택해야 합니다.
- 폼 대신 성공 메시지를 렌더링하기
ref를 사용해 폼을 수동으로 리셋하기- 제출 ID 등을 포함한 새로운 폼 상태를 반환하고 조건부로 다시 렌더링하기
처음엔 폼 라이브러리처럼 동작할 거라 기대했지만, 실제로는 더 단순합니다—의도된 설계대로 상태 머신이며, 마법 같은 폼 처리 기능은 없습니다.
또한 `
