한국어

React의 useActionState 훅을 마스터하세요. 실용적인 예제를 통해 폼 관리 단순화, 보류 상태 처리, 사용자 경험 향상 방법을 심도 있게 다룹니다.

React useActionState: 최신 폼 관리를 위한 종합 가이드

웹 개발의 세계는 끊임없이 진화하고 있으며, 리액트 생태계는 이러한 변화의 최전선에 있습니다. 최근 버전에서 리액트는 상호작용적이고 탄력적인 애플리케이션을 구축하는 방식을 근본적으로 개선하는 강력한 기능들을 도입했습니다. 그중 가장 영향력 있는 것 중 하나가 바로 폼과 비동기 작업을 처리하는 방식을 획기적으로 바꾼 useActionState 훅입니다. 이 훅은 실험적 릴리스에서 useFormState로 알려졌었지만, 이제는 모든 현대 리액트 개발자에게 안정적이고 필수적인 도구가 되었습니다.

이 종합 가이드는 useActionState에 대해 깊이 있게 탐구합니다. 이 훅이 해결하는 문제들, 핵심 메커니즘, 그리고 useFormStatus와 같은 보완적인 훅들과 함께 활용하여 우수한 사용자 경험을 만드는 방법을 알아볼 것입니다. 간단한 연락처 폼을 만들든, 복잡하고 데이터 집약적인 애플리케이션을 만들든, useActionState를 이해하면 코드가 더 깔끔하고, 선언적이며, 견고해질 것입니다.

문제점: 전통적인 폼 상태 관리의 복잡성

useActionState의 우아함을 이해하기 전에, 먼저 이 훅이 해결하는 문제들을 이해해야 합니다. 수년간 리액트에서 폼 상태를 관리하는 것은 useState 훅을 사용하는 예측 가능하지만 종종 번거로운 패턴을 포함했습니다.

간단한 예시로, 목록에 새 제품을 추가하는 폼을 생각해 봅시다. 우리는 여러 상태를 관리해야 합니다:

일반적인 구현은 다음과 같을 수 있습니다:

예시: 여러 useState 훅을 사용하는 '구식' 방법

// 가상의 API 함수
const addProductAPI = async (productName) => {
await new Promise(resolve => setTimeout(resolve, 1500));
if (!productName || productName.length < 3) {
throw new Error('제품명은 3글자 이상이어야 합니다.');
}
console.log(`"${productName}" 제품이 추가되었습니다.`);
return { success: true };
};

// 컴포넌트
import { useState } from 'react';

function OldProductForm() {
const [productName, setProductName] = useState('');
const [error, setError] = useState(null);
const [isPending, setIsPending] = useState(false);

const handleSubmit = async (event) => {
event.preventDefault();
setIsPending(true);
setError(null);

try {
await addProductAPI(productName);
setProductName(''); // 성공 시 입력 비우기
} catch (err) {
setError(err.message);
} finally {
setIsPending(false);
}
};

return (




id="productName"
name="productName"
value={productName}
onChange={(e) => setProductName(e.target.value)}
/>

{error &&

{error}

}


);
}

이 접근 방식은 작동하지만 몇 가지 단점이 있습니다:

  • 보일러플레이트: 개념적으로는 단일 폼 제출 과정인 것을 관리하기 위해 세 개의 개별 useState 호출이 필요합니다.
  • 수동 상태 관리: 개발자는 try...catch...finally 블록 내에서 올바른 순서로 로딩 및 오류 상태를 수동으로 설정하고 재설정해야 할 책임이 있습니다. 이는 반복적이고 오류가 발생하기 쉽습니다.
  • 결합도: 폼 제출 결과를 처리하는 로직이 컴포넌트의 렌더링 로직과 밀접하게 결합되어 있습니다.

useActionState 소개: 패러다임의 전환

useActionState는 폼 제출과 같은 비동기 액션의 상태를 관리하기 위해 특별히 설계된 리액트 훅입니다. 상태를 액션 함수의 결과에 직접 연결하여 전체 과정을 간소화합니다.

시그니처는 명확하고 간결합니다:

const [state, formAction] = useActionState(actionFn, initialState);

구성 요소를 분석해 보겠습니다:

  • actionFn(previousState, formData): 작업을 수행하는 비동기 함수입니다(예: API 호출). 이전 상태와 폼 데이터를 인수로 받습니다. 결정적으로, 이 함수가 반환하는 것이 새로운 상태가 됩니다.
  • initialState: 액션이 처음 실행되기 전의 상태 값입니다.
  • state: 현재 상태입니다. 처음에는 initialState를 가지며, 각 실행 후 actionFn의 반환 값으로 업데이트됩니다.
  • formAction: 액션 함수를 감싼 새로운 버전의 함수입니다. 이 함수를 <form> 요소의 action prop에 전달해야 합니다. 리액트는 이 감싸진 함수를 사용하여 액션의 보류 상태를 추적합니다.

실용 예제: useActionState로 리팩토링하기

이제 useActionState를 사용하여 제품 폼을 리팩토링해 봅시다. 개선점이 즉시 드러납니다.

먼저, 액션 로직을 조정해야 합니다. 오류를 던지는 대신, 액션은 결과를 설명하는 상태 객체를 반환해야 합니다.

예시: useActionState를 사용한 '새로운' 방법

// useActionState와 함께 작동하도록 설계된 액션 함수
const addProductAction = async (previousState, formData) => {
const productName = formData.get('productName');
await new Promise(resolve => setTimeout(resolve, 1500)); // 네트워크 지연 시뮬레이션

if (!productName || productName.length < 3) {
return { message: '제품명은 3글자 이상이어야 합니다.', success: false };
}

console.log(`"${productName}" 제품이 추가되었습니다.`);
// 성공 시, 성공 메시지를 반환하고 폼을 비웁니다.
return { message: `"${productName}"을(를) 성공적으로 추가했습니다.`, success: true };
};

// 리팩토링된 컴포넌트
import { useActionState } from 'react';
// 참고: 다음 섹션에서 보류 상태를 처리하기 위해 useFormStatus를 추가할 것입니다.

function NewProductForm() {
const initialState = { message: null, success: false };
const [state, formAction] = useActionState(addProductAction, initialState);

return (





{!state.success && state.message && (

{state.message}


)}
{state.success && state.message && (

{state.message}


)}

);
}

얼마나 깔끔해졌는지 보세요! 세 개의 useState 훅을 단일 useActionState 훅으로 대체했습니다. 이제 컴포넌트의 책임은 순전히 `state` 객체를 기반으로 UI를 렌더링하는 것입니다. 모든 비즈니스 로직은 `addProductAction` 함수 내에 깔끔하게 캡슐화되어 있습니다. 상태는 액션이 반환하는 것에 따라 자동으로 업데이트됩니다.

하지만 잠깐, 보류 상태는 어떻게 하죠? 폼이 제출되는 동안 버튼을 어떻게 비활성화할까요?

useFormStatus로 보류 상태 처리하기

리액트는 이 문제를 해결하기 위해 설계된 동반 훅인 useFormStatus를 제공합니다. 이 훅은 마지막 폼 제출에 대한 상태 정보를 제공하지만, 중요한 규칙이 있습니다: 상태를 추적하려는 <form> 내부에 렌더링되는 컴포넌트에서 호출해야 합니다.

이는 관심사의 분리를 장려합니다. 제출 버튼과 같이 폼의 제출 상태를 알아야 하는 UI 요소를 위한 컴포넌트를 특별히 만듭니다.

useFormStatus 훅은 여러 속성을 가진 객체를 반환하며, 그중 가장 중요한 것은 `pending`입니다.

const { pending, data, method, action } = useFormStatus();

  • pending: 부모 폼이 현재 제출 중이면 `true`, 그렇지 않으면 `false`인 불리언 값입니다.
  • data: 제출 중인 데이터를 포함하는 `FormData` 객체입니다.
  • method: HTTP 메서드를 나타내는 문자열입니다 (`'get'` 또는 `'post'`).
  • action: 폼의 `action` prop에 전달된 함수에 대한 참조입니다.

상태를 인지하는 제출 버튼 만들기

전용 `SubmitButton` 컴포넌트를 만들어 폼에 통합해 봅시다.

예시: SubmitButton 컴포넌트

import { useFormStatus } from 'react-dom';
// 참고: useFormStatus는 'react'가 아닌 'react-dom'에서 가져옵니다.

function SubmitButton() {
const { pending } = useFormStatus();

return (

);
}

이제 메인 폼 컴포넌트를 업데이트하여 사용해 봅시다.

예시: useActionState와 useFormStatus를 사용한 완전한 폼

import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';

// ... (addProductAction 함수는 동일하게 유지)

function SubmitButton() { /* ... 위에서 정의한 대로 ... */ }

function CompleteProductForm() {
const initialState = { message: null, success: false };
const [state, formAction] = useActionState(addProductAction, initialState);

return (



{/* 성공 시 입력을 초기화하기 위해 key를 추가할 수 있습니다 */}


{!state.success && state.message && (

{state.message}


)}
{state.success && state.message && (

{state.message}


)}

);
}

이 구조를 사용하면 `CompleteProductForm` 컴포넌트는 보류 상태에 대해 아무것도 알 필요가 없습니다. `SubmitButton`은 완전히 독립적입니다. 이 구성 패턴은 복잡하고 유지보수 가능한 UI를 구축하는 데 매우 강력합니다.

점진적 향상의 힘

이 새로운 액션 기반 접근 방식의 가장 심오한 이점 중 하나는, 특히 서버 액션과 함께 사용할 때, 자동적인 점진적 향상(progressive enhancement)입니다. 이는 네트워크 조건이 불안정하고 사용자가 구형 기기를 사용하거나 자바스크립트를 비활성화했을 수 있는 글로벌 고객을 위한 애플리케이션을 구축하는 데 필수적인 개념입니다.

작동 방식은 다음과 같습니다:

  1. 자바스크립트가 없을 때: 사용자의 브라우저가 클라이언트 측 자바스크립트를 실행하지 않으면, `<form action={...}>`은 표준 HTML 폼으로 작동합니다. 서버에 전체 페이지 요청을 보냅니다. Next.js와 같은 프레임워크를 사용하는 경우, 서버 측 액션이 실행되고 프레임워크는 새 상태(예: 유효성 검사 오류 표시)로 전체 페이지를 다시 렌더링합니다. 애플리케이션은 SPA와 같은 부드러움 없이도 완벽하게 작동합니다.
  2. 자바스크립트가 있을 때: 자바스크립트 번들이 로드되고 리액트가 페이지를 하이드레이션하면, 동일한 `formAction`이 클라이언트 측에서 실행됩니다. 전체 페이지 리로드 대신, 일반적인 fetch 요청처럼 동작합니다. 액션이 호출되고, 상태가 업데이트되며, 컴포넌트의 필요한 부분만 다시 렌더링됩니다.

이는 폼 로직을 한 번만 작성하면 두 시나리오 모두에서 원활하게 작동한다는 것을 의미합니다. 기본적으로 견고하고 접근성 높은 애플리케이션을 구축하게 되며, 이는 전 세계 사용자 경험에 큰 이점입니다.

고급 패턴 및 사용 사례

1. 서버 액션 vs. 클라이언트 액션

useActionState에 전달하는 `actionFn`은 예제에서처럼 표준 클라이언트 측 비동기 함수일 수도 있고, 서버 액션일 수도 있습니다. 서버 액션은 서버에 정의되어 클라이언트 컴포넌트에서 직접 호출할 수 있는 함수입니다. Next.js와 같은 프레임워크에서는 함수 본문 상단에 "use server"; 지시어를 추가하여 정의합니다.

  • 클라이언트 액션: 클라이언트 측 상태에만 영향을 미치거나 클라이언트에서 직접 타사 API를 호출하는 변경 작업에 이상적입니다.
  • 서버 액션: 데이터베이스나 다른 서버 측 리소스를 포함하는 변경 작업에 완벽합니다. 모든 변경 작업에 대해 수동으로 API 엔드포인트를 만들 필요가 없어 아키텍처를 단순화합니다.

가장 큰 장점은 useActionState가 두 가지 모두와 동일하게 작동한다는 것입니다. 컴포넌트 코드를 변경하지 않고도 클라이언트 액션을 서버 액션으로 교체할 수 있습니다.

2. `useOptimistic`을 이용한 낙관적 업데이트

더욱 반응적인 느낌을 주기 위해 useActionStateuseOptimistic 훅과 결합할 수 있습니다. 낙관적 업데이트는 비동기 액션이 성공할 것이라고 *가정*하고 UI를 즉시 업데이트하는 것입니다. 만약 실패하면, UI를 이전 상태로 되돌립니다.

댓글을 추가하는 소셜 미디어 앱을 상상해 보세요. 낙관적으로, 서버에 요청을 보내는 동안 새 댓글을 목록에 즉시 표시할 것입니다. useOptimistic은 이 패턴을 쉽게 구현할 수 있도록 액션과 함께 작동하도록 설계되었습니다.

3. 성공 시 폼 초기화

성공적인 제출 후 폼 입력을 지우는 것은 일반적인 요구 사항입니다. useActionState를 사용하여 이를 달성하는 몇 가지 방법이 있습니다.

  • Key Prop 트릭: `CompleteProductForm` 예제에서 보여준 것처럼, 입력이나 전체 폼에 고유한 `key`를 할당할 수 있습니다. 키가 변경되면 리액트는 이전 컴포넌트를 마운트 해제하고 새 컴포넌트를 마운트하여 사실상 상태를 초기화합니다. 키를 성공 플래그(`key={state.success ? 'success' : 'initial'}`)에 연결하는 것은 간단하고 효과적인 방법입니다.
  • 제어 컴포넌트: 필요한 경우 여전히 제어 컴포넌트를 사용할 수 있습니다. useState로 입력 값을 관리함으로써, useActionState의 성공 상태를 감지하는 useEffect 내에서 세터 함수를 호출하여 값을 지울 수 있습니다.

흔한 함정과 모범 사례

  • useFormStatus의 위치: useFormStatus를 호출하는 컴포넌트는 반드시 `<form>`의 자식으로 렌더링되어야 합니다. 형제나 부모 컴포넌트에서는 작동하지 않습니다.
  • 직렬화 가능한 상태: 서버 액션을 사용할 때, 액션에서 반환되는 상태 객체는 직렬화 가능해야 합니다. 즉, 함수, 심볼 또는 기타 직렬화 불가능한 값을 포함할 수 없습니다. 일반 객체, 배열, 문자열, 숫자, 불리언 값을 사용하세요.
  • 액션에서 오류 던지지 않기: `throw new Error()` 대신, 액션 함수는 오류를 우아하게 처리하고 오류를 설명하는 상태 객체를 반환해야 합니다(예: `{ success: false, message: '오류가 발생했습니다.' }`). 이렇게 하면 상태가 항상 예측 가능하게 업데이트됩니다.
  • 명확한 상태 형태 정의: 처음부터 상태 객체에 대한 일관된 구조를 확립하세요. `{ data: T | null, message: string | null, success: boolean, errors: Record | null }`와 같은 형태는 많은 사용 사례를 포괄할 수 있습니다.

useActionState vs. useReducer: 간단 비교

언뜻 보기에 useActionStateuseReducer와 비슷해 보일 수 있습니다. 둘 다 이전 상태를 기반으로 상태를 업데이트하기 때문입니다. 그러나 그들은 다른 목적을 가집니다.

  • useReducer클라이언트 측에서 복잡한 상태 전환을 관리하기 위한 범용 훅입니다. 액션을 디스패치하여 트리거되며, 가능성 있는 동기적 상태 변경이 많은 상태 로직(예: 복잡한 다단계 마법사)에 이상적입니다.
  • useActionState는 단일, 일반적으로 비동기 액션에 응답하여 변경되는 상태를 위해 설계된 전문화된 훅입니다. 주요 역할은 HTML 폼, 서버 액션, 그리고 보류 상태 전환과 같은 리액트의 동시성 렌더링 기능과 통합하는 것입니다.

핵심: 폼 제출 및 폼에 연결된 비동기 작업에는 useActionState가 현대적이고 목적에 맞게 제작된 도구입니다. 다른 복잡한 클라이언트 측 상태 머신에는 useReducer가 여전히 훌륭한 선택입니다.

결론: 리액트 폼의 미래를 맞이하며

useActionState 훅은 새로운 API 그 이상입니다. 이는 리액트에서 폼과 데이터 변경을 처리하는 더 견고하고, 선언적이며, 사용자 중심적인 방식으로의 근본적인 전환을 나타냅니다. 이를 채택함으로써 다음과 같은 이점을 얻을 수 있습니다:

  • 보일러플레이트 감소: 단일 훅이 여러 useState 호출과 수동 상태 조정을 대체합니다.
  • 통합된 보류 상태: 동반 훅인 useFormStatus를 사용하여 로딩 UI를 원활하게 처리합니다.
  • 내장된 점진적 향상: 자바스크립트 유무에 관계없이 작동하는 코드를 작성하여 모든 사용자에 대한 접근성과 복원력을 보장합니다.
  • 단순화된 서버 통신: 서버 액션과 자연스럽게 맞아떨어져 풀스택 개발 경험을 간소화합니다.

새로운 프로젝트를 시작하거나 기존 프로젝트를 리팩토링할 때 useActionState를 사용해 보세요. 코드를 더 깔끔하고 예측 가능하게 만들어 개발자 경험을 향상시킬 뿐만 아니라, 더 빠르고, 더 탄력적이며, 다양한 전 세계 사용자에게 접근 가능한 고품질 애플리케이션을 구축할 수 있도록 힘을 실어줄 것입니다.