리액트 useFormState

렐루·2024년 5월 10일
0

넥스트js

목록 보기
7/16
post-thumbnail

1. 작성 동기

노마드 코더의 당근마켓 클론코딩을 진행하면서 6강에 input의 유효성 검사 부분이 인상깊었습니다.
그동안 내부적으로 유효성 검사를 하려고 봐도봐도 잘 모르겠는 정규식을 열심히 작성하느라 너무 힘들었는데 npm에서 zod라는 매우 편리한 라이브러리를 사용했습니다.
https://zod.dev/?id=installation

전에 팀플할때 회원가입같이 단계적인 절차가 필요한 작업을 구현했을 때 이전 값을 어떻게 처리해야할지 정말정말정말 난감했었는데 useFormState라는 매우 훌륭한 훅을 사용하면 아주 간편하게, 그리고 깔끔하게 구현할 수 있다는 점이 너무 매력적이라 좀더 자세하게 글을 쓰고자 합니다!

2. useFormState

https://es.react.dev/reference/react-dom/hooks/useFormState
useFormState양식 작업의 결과에 따라 상태를 업데이트할 수 있는 후크입니다.

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

2-1. 매개변수

  • fn

    양식을 제출하거나 버튼을 눌렀을 때 호출되는 함수
    함수가 호출되면 양식의 이전 상태(처음에는 initialState전달한 상태, 이후에는 이전 반환 값)를 초기 인수로 수신하고 그 뒤에는 양식 작업이 일반적으로 수신하는 인수가 이어집니다.
    initialState: 초기에 상태를 원하는 값입니다.
    직렬화 가능한 모든 값이 될 수 있습니다. 이 인수는 작업이 처음 호출된 후에는 무시됩니다.

  • initialState

    초기에 상태를 원하는 값입니다. 직렬화 가능한 모든 값이 될 수 있습니다. 이 인수는 작업이 처음 호출된 후에는 무시됩니다.

직렬화 가능한 모든 값
직렬화 가능 객체 (Serializable object) 는 모든 JavaScript 환경('영역')에서 직렬화되고 나중에 역직렬화될 수 있는 객체입니다.
https://developer.mozilla.org/ko/docs/Glossary/Serializable_object
아래 링크에 가능한 객체가 명시되어 있습니다.
https://developer.mozilla.org/ko/docs/Web/API/Web_Workers_API/Structured_clone_algorithm#supported_types

2-2. 리턴

useFormState 는 두 개의 값이 있는 배열을 반환합니다.

  • state
    현재 상태. 첫 번째 렌더링 중에는 initialState.
    작업이 호출된 후에는 작업에서 반환된 값과 일치합니다.

  • formAction
    form 컴포넌트의 action prop 또는 button 컴포넌트의 formAction prop으로 전달할 수 있는 새로운 action입니다.
    해당 값을 전달하게 되면 action에 등록한 함수가 들어가게 됩니다!

3. 전체 코드

// page.tsx
"use client";

import Link from "next/link";
import { useFormState } from "react-dom";

import Input from "../components/input";
import Button from "../components/button";
import { smsLogin } from "./actions";
import { error } from "console";

const initialState = {
  token: false,
  error: undefined,
};

export default function SMSLogin() {
  const [state, dispatch] = useFormState(smsLogin, initialState);
  return (
    <div className="flex flex-col gap-10 py-6">
      <div
        className="flex flex-col gap-2
      *:font-medium"
      >
        <h1 className="text-2xl">SMS Login</h1>
        <h2 className="text-xl">Verify ypur phone number</h2>
      </div>
      <form action={dispatch} className="flex flex-col gap-3">
        {state?.token ? (
          <Input
            name="token"
            type="number"
            placeholder="Verification code"
            min={100000}
            max={999999}
            errors={state.error?.formErrors}
            required
          />
        ) : (
          <Input
            name="phone"
            type="text"
            placeholder="Phone number"
            errors={state.error?.formErrors}
            required
          />
        )}
        <Button text={state.token ? "Verify Token" : "Send Verification SMS"} />
      </form>
    </div>
  );
}
// action.ts
"use server";

import { z } from "zod";
import validator from "validator";
import { redirect } from "next/navigation";
import { error } from "console";

const phoneSchema = z
  .string()
  .refine(
    (phone) => validator.isMobilePhone(phone, "ko-KR"),
    "Wrong phone format"
  );
const tokenSchema = z.coerce.number().min(100000).max(999999);

interface ActionState {
  token: boolean;
}

export async function smsLogin(prevState: ActionState, formData: FormData) {
  const phone = formData.get("phone");
  const token = formData.get("token");
  if (!prevState.token) {
    const result = phoneSchema.safeParse(phone);
    if (!result.success) {
      return {
        token: false,
        error: result.error.flatten(),
      };
    } else {
      return {
        token: true,
      };
    }
  } else {
    const result = tokenSchema.safeParse(token);
    if (!result.success) {
      return {
        token: true,
        error: result.error.flatten(),
      };
    } else {
      redirect("/");
    }
  }
}

4. 구현 내용



위의 사진은 핸드폰 인증번호로 로그인을 진행하는 단계 과정샷입니다.

먼저 핸드폰 번호를 받고, 유효성 검사를 진행한 후 인증번호를 다시 받는 작업입니다.

const initialState = {
  token: false,
  error: undefined,
};

export default function SMSLogin() {
  const [state, dispatch] = useFormState(smsLogin, initialState);
  return (

위의 코드는 page.tsx 파일 코드 일부입니다.
page.tsx 파일에서는 prev, curr 값을 구분하지 않아도 됩니다.
최초에만 설정해주고 그 다음부터는 action 함수로부터 넘어온 state 값만을 사용하면 됩니다.
어떤 연산의 결과만 화면에 보여주면 되니까요!

최초 값 initialState은 token이 false 입니다.
이 값은 state로 들어가고 그 다음부터는 action.ts 파일에서 넘어온 값들이 들어가게 됩니다.

// action.ts
"use server";

import { redirect } from "next/navigation";
import validator from "validator";
import { z } from "zod";

const phoneSchema = z
  .string()
  .refine(
    (phone) => validator.isMobilePhone(phone, "ko-KR"),
    "Wrong phone format"
  );
const tokenSchema = z.coerce.number().min(100000).max(999999);

interface ActionState {
  token: boolean;
}

export async function smsLogin(prevState: ActionState, formData: FormData) {
  const phone = formData.get("phone");
  const token = formData.get("token");
  if (!prevState.token) {
    const result = phoneSchema.safeParse(phone);
    if (!result.success) {
      return {
        token: false,
        error: result.error.flatten(),
      };
    } else {
      return {
        token: true,
      };
    }
  } else {
    const result = tokenSchema.safeParse(token);
    if (!result.success) {
      return {
        token: true,
        error: result.error.flatten(),
      };
    } else {
      redirect("/");
    }
  }
}

위 파일은 action 파일입니다.
폼의 action에 넣어준 함수가 되겠습니다.
useFormState의 인자로 들어가겠지만 리턴 값의 dispatch가 가르키게 되어 폼이 트리거 될 때 action에서 가리키고 있는 함수 action.ts가 실행되게 되는 것입니다.

최초로 실행되면 prev는 제가 처음에 하드코딩한 initialState 값이 됩니다.
그 다음부터는 action 파일 내부의 리턴값이 프리브가 됩니다.
한칸씩 밀리는거죠.

로그인 절차는 1. 핸드폰 번호 => 2. 인증번호 순으로 진행됩니다.
1. 처음에는 token은 false 입니다.
그럼 if문이 실행되고 만약에 번호가 유효성에서 통과하면? token은 true가 되고 다음 제출 2번에는 else 문이 실행됩니다.
만약에 핸드폰 번호 유효에서 탈락하면? phoneSchema.safeParse(phone).success 값이 되겠습니다.
그러면 계속 if문에 갖히게 되는거죠!

else 문에서는 만약에 token 번호가 올바르면?

const token = formData.get("token");
prevState.token

위의 두 개는 다른겁니다!!!? 아시죠?

먼저거는 둘째 인자로 들어오느 current 값, 즉 form 에서 바로 날라오는 값입니다.
두번째꺼는 prev 값으로 리턴값이라고 했죠?
처음 이니셜 값도 불린 들어있는 오브젝트, 리턴도 불린 들어있는 오브젝트이기에 위 파일 함수의 prev 값은 계속 같은 형식을 유지하는 겁니다.
이에 반면에 curr 값은 form에서 넘어오니까 계속 formData가 되는 거고요!

폼에서 token으로 가져온 값을 유효성 검사해서
만약에 통과면 다름 로직을 진행하면 되겠죠?
제가 올린 코드에선 그냥 redirect로 넘어갔는데 이제 추가로 보낸 코드랑 일치하는지 확인하는 작업을 추가해주시면 됩니다!

5. 가장 고민한 점

가장 고민했던 부분은 form의 action 부분이었습니다.
로직적인 부분은 사실 인강 코드이기 때문에 제가 고민할 여지는 없습니다;;

제가 궁금했던 부분인 타입을 지정하는 부분과 js와 jsx의 차이였습니다.

첫째 jsx에서 form의 action 부분이 html의 action과 다르게 동작하는 부분이 궁금했습니다. onSubmit의 경우 제출시 기본동작인 리로딩을 자바스크립트와 같이 막아줘야 했었는데 action에 콜백 함수를 넣을 수 있다는 부분에서 html의 form 태그와는 별개의 것으로 인지해야 하는가? 였습니다.
넥스트js는 결국 리액트고 리액트 코드는 자바스크립트 파일로 변환될텐데 결국 form도 html 태그와 같은 것 아닌가? 하는 생각이 들었습니다.

둘째 타입스크립트의 인터페이스?는 뭘까? 자바스크립트의 기본적인 타입과 dom의 기본적인 태그타입들이랑은 별개의 것인가 아니면 extends를 통해 이 또한 자바스크립트로 녹아들어가는가 라는 궁금증이었습니다.

별개의 것이라고 생각이 들었습니다.
첫번째 질문은 튜터님을 통해 전혀 별개의 것이라는 것을 배웠습니다.
과거에는 js와 jsx가 혼용이 불가했지만 지금은 가능해서 제가 착각했던 것 같습니다.
https://stackoverflow.com/questions/46169472/reactjs-js-vs-jsx

jsx. JSX는 표준 JavaScript가 아니기 때문에 "일반" JavaScript가 아닌 모든 것은 자체 확장, 즉 .jsxJSX 및 .tsTypeScript에 들어가야 한다고 주장할 수 있습니다 .

위의 글에서 중요한 부분만 발췌했습니다. 전혀 다른 부분이기에 혼용하면 안될 것같습니다. 다만 이렇게 너무 별도의 것을 만들면 뭔가 정말 자바스크립트에 대한 바른 지식이 서있지 않으면 종속될 수 있겠다라는 생각이 들었습니다.

두번째 타입에 관한 내용입니다.

export async function smsLogin(prevState: ActionState, formData: FormData) {
// ...이해생략  

export default function Input({
  errors = [],
  name,
  ...rest
}: InputProps & InputHTMLAttributes<HTMLInputElement>) {
// .. 이하 생략

InputHTMLAttributes는 리액트에서 제공하는 타입스크립트의 interface입니다.
vscode에서 InputHTMLAttributes를 타고 들어가면 다음과 같은 인터페이스가 나옵니다

interface HTMLInputElement extends HTMLElement, PopoverInvokerElement 


HTMLElement을 상속했다고 나옵니다.
계속 타고 들어가보겠습니다.

또 타입스크립트네요
AriaAttributes, DOMAttributes 또한 마지막이며 이 또한 인터페이스입니다.

타입스크립트 인터페이스
https://joshua1988.github.io/ts/guide/interfaces.html#%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4
인터페이스는 상호 간에 정의한 약속 혹은 규칙을 의미합니다. 타입스크립트에서의 인터페이스는 보통 다음과 같은 범주에 대해 약속을 정의할 수 있습니다.

  • 객체의 스펙(속성과 속성의 타입)
  • 함수의 파라미터
  • 함수의 스펙(파라미터, 반환 타입 등)
  • 배열과 객체를 접근하는 방식
  • 클래스

별도로 정의한 약속입니다. 이 또한 자바스크립트와는 별개의 것임을 배웠습니다.

긴글 읽어주셔서 감사합니다!

profile
프론트 공부중입니다!

0개의 댓글