본문 바로가기

카테고리 없음

React) useActionState에 대한 고민

리액트 19버전에 useActionState훅이 정식 출시되었습니다.

제가 운영중인 코드 갤러리에도 Next 14에서 15로, React 18에서 19로 마이그레이션을 진행했고 form에 대한 것들을 모두 useActionState로 바꾸었습니다.

 

useActionState에 대해 간단히 설명드리면, 

const [state, formAction, isPending] = useActionState(fn, initialState, permalink?);

으로 구성되어있고,

각각

  • fn:폼이 제출되거나 submit버튼을 눌렀을 때 실행되는 함수입니다.
  • initialState: state의 초기값입니다.
  • permalink(optional):이 폼이 수정하는 고유한 페이지 URL을 포함하는 문자열입니다.

사용법은 간단합니다. 

import { useActionState } from "react";

async function increment(previousState, formData) {
  return previousState + 1;
}

function StatefulForm({}) {
  const [state, formAction] = useActionState(increment, 0);
  return (
    <form>
      {state}
      <button formAction={formAction}>Increment</button>
    </form>
  );
}

 

하지만 이를 적용하다 이상한 점을 느꼈습니다.

 

useActionState를 적용하기 전의 코드 상태는 다음과 같습니다.

"use client"

export default function SignIn() {
  const { email, password, clientError, serverError, action } = useSignInForm();

  return (
    <form action={action}>
      <div>
        <label htmlFor="userId">이메일</label>
        <input type="text" name="email" id="email" value={email} />
        {clientError && <p> {clientError}</p>}
      </div>
      <div>
        <label htmlFor="pw">패스워드</label>
        <input type="password" name="pw" id="pw" value={password} />
      </div>
      <button type="submit">로그인하기</button>
      {serverError && <p>{serverError}</p>}
    </form>
  );
}

 

export default function useSignInForm() {
  const [email, setUserId] = useState("");
  const [password, setPassword] = useState("");
  const [clientError, setClientError] = useState<null | string>(null);
  const [serverError, setServerError] = useState<null | string>(null);

  function handleEmail(e: ChangeEvent<HTMLInputElement>) {
    setUserId(e.target.value);
  }

  function handlePw(e: ChangeEvent<HTMLInputElement>) {
    setPassword(e.target.value);
  }

  async function action() {
    try {
      const response = await login(email, password);
      redirect("/");
    } catch (e) {
      const msg  = e instanceof Error ? e.message : "오류가 발생했습니다."
      console.error(msg);
      setServerError(msg)
    }
  }

  return { email, password, clientError, serverError, action };
}
"use server";

async function login(email: string, pw: string) {
  try {
    // 로그인 로직
  } catch (e) {
    console.error(e);
    throw e;
  }
}

 

여기서 useActionState로 바꾸는 것은 간단합니다. 

export default function SignIn() {
  const { email, password, clientError, serverError, action, isPending } =
    useSignInForm();

  return (
    <form action={action}>
      <div>
        <label htmlFor="userId">이메일</label>
        <input type="text" name="email" id="email" value={email} />
        {clientError && <p> {clientError}</p>}
      </div>
      <div>
        <label htmlFor="pw">패스워드</label>
        <input type="password" name="pw" id="pw" value={password} />
      </div>
      <button type="submit" disabled={isPending}>
        로그인하기
      </button>
      {serverError && <p>{serverError}</p>}
    </form>
  );
}


export default function useSignInForm() {
  const [email, setUserId] = useState("");
  const [password, setPassword] = useState("");
  const [clientError, setClientError] = useState<null | string>(null);
  const [serverError, setServerError] = useState<null | string>(null);
  const [loginState, loginForm,isPending] = useActionState(action, null)

  function handleEmail(e: ChangeEvent<HTMLInputElement>) {
    setUserId(e.target.value);
  }

  function handlePw(e: ChangeEvent<HTMLInputElement>) {
    setPassword(e.target.value);
  }

  async function action(prev:any, formData:formData) {
    try {
      const response = await login(email, password);
      redirect("/");
    } catch (e) {
      const msg  = e instanceof Error ? e.message : "오류가 발생했습니다."
      console.error(msg);
      setServerError(msg)
    }
  }

  return { email, password, clientError, serverError, action, isPending };
}

그러나 한가지 고민이 있었습니다. 

clientError는 이메일 입력 시, 이메일을 검증하고 serverError는 서버의 로그인 진행 중 에러를 담당합니다. 그렇다면 "loginState는 무엇을 담당하는 상태일까"가 고민이었습니다.  물론 사용하지 않아도 무방할 것 같지만 "상태"를 담당하는 변수인만큼 어딘가에 꼭 필요할 것이라 생각했습니다. 우선 공식문서를 다시 읽어보았습니다.

 

React 공식문서는 다음처럼 사용 예제를 제공했습니다.

import { useActionState } from "react";
import { addToCart } from "./actions.js";

function AddToCartForm({itemID, itemTitle}) {
  const [message, formAction, isPending] = useActionState(addToCart, null);
  return (
    <form action={formAction}>
      <h2>{itemTitle}</h2>
      <input type="hidden" name="itemID" value={itemID} />
      <button type="submit">Add to Cart</button>
      {isPending ? "Loading..." : message}
    </form>
  );
}

export default function App() {
  return (
    <>
      <AddToCartForm itemID="1" itemTitle="JavaScript: The Definitive Guide" />
      <AddToCartForm itemID="2" itemTitle="JavaScript: The Good Parts" />
    </>
  );
}

 

"use server";

export async function addToCart(prevState, queryData) {
  const itemID = queryData.get('itemID');
  if (itemID === "1") {
    return "Added to cart";
  } else {
    // Add a fake delay to make waiting noticeable.
    await new Promise(resolve => {
      setTimeout(resolve, 2000);
    });
    return "Couldn't add to cart: the item is sold out.";
  }
}

유심히 봐야할 것은 addToCart입니다. 해당 함수를 보면 비동기 요청을 실행하는 로직이지만 에러를 던지는 부분이 없습니다. 또한 리액트의 안내대로 "오류 메세지나 토스트와 같은 메세지를 표시하려면"으로 서버에서 반환한 단순한 스트링 값이 비동기 요청 결과의 "상태"를 담고 있습니다.

즉, 서버에서 에러를 던질 필요가 없습니다. (사견입니다.)

이에 대해 좀 더 상세히 말씀드리면 클라이언트의 try, catch구문이 필요가 없고 그만큼 코드 양도 줄어들 수 있습니다.

 

위처럼 단순한 "상태"를 리턴한다 하면 제 코드는 다음과 같이 수정될 수 있습니다.

export default function SignIn() {
  const { email, password, clientError, action, isPending ,loginState} =
    useSignInForm();

  return (
    <form action={action}>
      <div>
        <label htmlFor="userId">이메일</label>
        <input type="text" name="email" id="email" value={email} />
        {clientError && <p> {clientError}</p>}
      </div>
      <div>
        <label htmlFor="pw">패스워드</label>
        <input type="password" name="pw" id="pw" value={password} />
      </div>
      <button type="submit" disabled={isPending}>
        로그인하기
      </button>
     <p>{loginState?.message}</p>
    </form>
  );
}

export default function useSignInForm() {
  const [email, setUserId] = useState("");
  const [password, setPassword] = useState("");
  const [clientError, setClientError] = useState<null | string>(null);
  const [loginState, loginForm,isPending] = useActionState(action, null)

  function handleEmail(e: ChangeEvent<HTMLInputElement>) {
    setUserId(e.target.value);
  }

  function handlePw(e: ChangeEvent<HTMLInputElement>) {
    setPassword(e.target.value);
  }

  async function action(prev:any, formData:formData) {
    const response = await login(email, password);
    if (!response.success) {
      return response
    }
      redirect("/");
    
  }

  return { email, password, clientError, action, isPending ,loginState};
}

"use server";

async function login(email: string, pw: string) {
  try {
    // 로그인 로직
    const login = await prisma.... // 로그인 로직

    return {success:true, message:'로그인에 성공했습니다.'}
  } catch (e) {
    console.error(e);
    return {success:false, message:e instanceof Error ? e.message : "오류가 발생했습니다."}
  }
}

우선 서버 액션 login함수에서 에러를 던지는 것을 수정했습니다. 에러 대신 {success:boolean, message:string}으로 로그인의 성공 여부와 에러를 표시합니다. useSignInForm에서 serverError와  SignIn컴포넌트에서 해당 상태를 렌더링하는 부분을 삭제했습니다.

즉, 저는 useActionState의 loginState를 서버 에러로 사용했습니다. 이렇게 되면 폼 관리와 서버에러 상태를 한 번에 관리할 수 있습니다. 이에 따라 단순히 useSignInForm에서 모든 상태를 관리하는 것 보다 상태를 좀 더 쪼개기로 했습니다.

export default function SignIn() {
  const { action, isPending ,loginState} = useSignInForm();
  const { handleEmail, email, error } = useEmail();
  const {handlePw, pw} = usePassword()


  return (
    <form action={action}>
      <div>
        <label htmlFor="userId" onChange={handleEmail}>이메일</label>
        <input type="text" name="email" id="email" value={email} />
        {error && <p> {error}</p>}
      </div>
      <div>
        <label htmlFor="pw">패스워드</label>
        <input type="password" name="pw" id="pw" value={pw} onChange={handlePw} />
      </div>
      <button type="submit" disabled={isPending}>
        로그인하기
      </button>
     <p>{loginState?.message}</p>
    </form>
  );
}

export default function useSignInForm() {
  const [loginState, loginForm,isPending] = useActionState(action, null)


  async function action(prev:any, formData:formData) {
    const response = await login(email, password);
    if (!response.success) {
      return response
    }
      redirect("/");
    
  }

  return { action, isPending ,loginState};
}

export default function useEmail() {
  const [email, setEmail] = useState('');
  const [error, setError] =useState<null | string>(null)

  const handleEmail = (e: ChangeEvent<HTMLInputElement>)=>{
    
    // 이메일 검증 로직
    // if (true) {
    //   setError('이메일 형식이 아닙니다.')
    //   return
    // }
    setEmail(e.target.value)
  }

  return {handleEmail, email, error}
}

export default function usePassword() {
  const [pw, setPw] = useState('')

  function handlePw(e: ChangeEvent<HTMLInputElement>) {
    setPw(e.target.value)
  }

  return {handlePw, pw}
}

 

useSignInForm은 form상태만을 관리합니다. 이에따라 email과 password의 상태도 따른 훅으로 분리하여 관리했습니다. 어떻게 보면 "하나의 컴포넌트에는 하나의 기능만 담당한다"라는 단일원칙에 잘 맞을지도 모릅니다.

 

저는 서버 액션에서 prisma로 mysql을 연동하여 사용했기 때문에 prisma를 사용하는 중 에러를 캐치하기 위해 어쩔 수 없이 try-catch를 사용했습니다. 제가 알아본 바로, supabase와 같은 것은 리턴 값에 error를 던지기 때문에 좀 더 쉽게 서버 액션을 관리할 수 있을 것같습니다. 

 

// 예시코드

async function SignInWithSupabase(email, pw) {

  const { error } = await supabase.auth.signIn( 
    //....
  )

  if (error) {
    return {success:false, message:error}
  }

  return {success:true, message:'로그인 성공!'}

}

훨씬 간단합니다. 물론 prisma도 커스텀하여 바로 error를 가져올 수 있습니다. 하지만 아직 프리즈마의 모든 인자들을 커스텀하기엔 어려울 것 같아 try-catch를 사용하고 있습니다. 

 

 

정리

useActionState를 사용하면 클라이언트에서 try-catch를 사용할 필요가 없습니다. 즉, 서버 에러를 try-catch로 잡아내어 해당 에러를 나타내는 또 다른 "상태"를 선언할 필요가 없습니다. 그에 따라 로직이 좀 더 간단해지며 서버 액션에서도 커스텀, 혹은 비구조화 할당으로 에러 반환을 지원하는 비동기 로직이라면 더더욱 간단해질 수 있습니다.