12/2 폼사용, 검증, 공통컴포넌트

김하은·2022년 12월 3일
0

어제수업내용을 정리해본다.
사실, 게시판 만드는데 배운걸 적용해보느라 시간이 훌쩍가... 오늘이 되었다.

늘 강조하시는 부분이지만, 코드를 유지보수 쉽게하기위해 리펙토링이라는 과정을 거쳐야한다.
코드를 간단하게 최대한 줄이는 것이다.
그러나 쉽지는 않았다.

..

폼을 자동으로 만들어주는 react-hooks-form
검증을할때 if문대신 사용이 가능해 훨씬 코드를 줄여주는 yup이라는 두가지 라이브러리를 사용해보았다.

그전에 과제로 내주셨던 비회원장바구니에 관해 리뷰를 간단히 해주셨다. 비회원의 경우는 로컬스토리지에 저장하게 만들어주기에 로컬스토리지를 배우고 적용해보다는 의미에서 내주신것같다. 나는.. 검색해보고 코드를 받아왔다. 대체 어떻게 하라는건지 너무막막해서였다.

import { useQuery, gql } from "@apollo/client";
import {
  IBoard,
  IQuery,
  IQueryFetchBoardsArgs,
} from "../../src/commons/types/generated/types";

const FETCH_BOARDS = gql`
  query fetchBoards($page: Int) {
    fetchBoards(page: $page) {
      _id
      writer
      title
      contents
    }
  }
`;

type IBaskets = Array<Pick<IBoard, "contents" | "title" | "_id" | "writer">>;

export default function StaticRoutedPage() {
  const { data } = useQuery<Pick<IQuery, "fetchBoards">, IQueryFetchBoardsArgs>(
    FETCH_BOARDS
  );
  // 인자 el이 basket에 들어감
  const onClickBasket = (basket: IBoard) => () => {
    console.log(basket);

    // 1. 기존 장바구니 가져오기// 누적담기
    const baskets: IBaskets = JSON.parse(
      // 로컬스토리지에서 가져온건 문자열이기에 배열로 바꾸기
      localStorage.getItem("baskets") ?? "[]" // 없으면 빈배열 따라서 직접 배열 안만들어줘도 됨
    );

    // 2. 이미 담겼는지 확인하기 // 각각의 기존에 담겨있던객체의 아이디를 뽑아오고,지금 클릭한 아이디를 비교
    const temp = baskets.filter((el) => el._id === basket._id);
    if (temp.length === 1) {
      // 이미 하나가 있음
      alert("이미 담으신 물품입니다!!!");
      return; // 더 못담게 return
    }

    // 3. 해당 장바구니에 담기
    baskets.push(basket);
    localStorage.setItem("baskets", JSON.stringify(baskets)); // 문자열로 들어가야하니 stringify붙여야함
  };

  return (
    <>
      {data?.fetchBoards.map((el) => (
        <div key={el._id}>
          <span style={{ margin: "10px" }}>{el.writer}</span>
          <span style={{ margin: "10px" }}>{el.title}</span>
          <button onClick={onClickBasket(el)}>장바구니담기</button>
        </div>
      ))}
    </>
  );
}

리뷰해주신 코드이다.
해당부분은 리뷰용 코드이기에 기존의fetchBoard를 이용한것 같다.

import { gql, useQuery } from "@apollo/client";
import { Modal } from "antd";

export const FETCH_USED_ITEMS = gql`
  query fetchUseditems($isSoldout: Boolean, $search: String, $page: Int) {
    fetchUseditems(isSoldout: $isSoldout, search: $search, page: $page) {
      _id
      name
      remarks
      contents
      price
      useditemAddress {
        address
        # addressDetail
        createdAt
      }
      createdAt
    }
  }
`;
export default function UnUserBasketPage() {
  const { data } = useQuery(FETCH_USED_ITEMS);

  const onClickBasket = (basket: any) => () => {
    const baskets = JSON.parse(localStorage.getItem("baskets") ?? "[]");

    const basketTemp = baskets.filter((el: any) => el._id === basket._id);
    if (basketTemp.length === 1) {
      Modal.info({ content: "이미 장바구니에 담긴 상품입니다." });
      return;
    }

    baskets.push(basket);
    localStorage.setItem("baskets", JSON.stringify(baskets));
  };
  
  return (
    <div>
      {data?.fetchUseditems.map((el: any) => (
        <div key={el._id}>
          <div>{el.name}</div>
          <div>{el.price}</div>
          <button onClick={onClickBasket(el)}>장바구니담기</button>
        </div>
      ))}
    </div>
  );
}

이 부분이 내가 복사해온 코드이다.
리뷰를 듣고 코드를 다시보니 그제서야 정리가되었다.

먼저, localStorage에서 키로 가져오는데 없으면 키가 생성되고 빈배열이라도 나오게 써준것 같다.

로컬스토리지에 들어가 있는것은 문자열이기에 JSON.parse로 배열로 바꾼다.(원본으로 다시 변경)

혹시 선택시 중복된경우를 생각해야하는데, 그럴경우 같은 아이디를 가지면 하나를 지운다거나 한다는 방법이 있다는데, 일단 본 코드 상으로나 리뷰코드에서는 담겨진 상품의 아이디와 클릭한 상품의 아이디가 같을 경우 그 경우를 필터처리하여 받아오고, 그 길이가 1개 이상 또는 하나일때(어차피 한개 이상일 수는 없다) 이미 담음 상품이라며 return해 중단시켜주게 처리하였다.

그게 아닐경우 키에 push하는데, 어떻게 가능한것이냐면, baskets라는 키의 아이템을 가져오는데 없으면 빈 배열을 가져오니, 결국 배열이 있는것이다. 따라서 push가 가능하다. 그렇게 해당 배열에 push하여 localStorage.setItem하여 해당키에 배열을 문자열로바꾸어 넣으면된다.

const baskets = JSON.parse(localStorage.getItem("baskets") ?? "[]");

이 부분.
baskets라는 키의 아이템을 가져옴, 없으면 빈 배열로 baskets라는 상수변수에 들어감.
키와 변수 이름이 같아서 조금 헷갈린다.


form

폼은 폼 라이브러리를 사용해 폼만드는 것을 도와주는 것이다.

react-form, 리덕스 유행하며 나오게됨 redux-form이 있는데 리덕스폼은 리덕스 사용시에만 사용이 가능한 라이브러리이다.

그렇게 나오게되다가 라이브러리에 종속되지 않고 사용이 가능하게된 폼이 나오게되었는데 formik이라는 아이이다.
이때는 한참 클래스형 컴포넌트를 사용할 시기라 이 formik도 클래스형에 사용이 적합한 컴포넌트였다.

그러다가 후에 함수현 컴포넌트가 나오개되며 여기에 적합한 react-hook-form이라는것이 나오게 되었다.
따라서 요즘 함수형에서는 react-hook-form을 사용한다.

해당 홈페이지에 들어가게되면 자기들이 비제어 컴포넌트 방식을 사용해 빠르다고 적혀있다.

비제어?

제어는 input을 컨트롤 하는것을 의미한다. input에 작성시 매번 state에 들어가 입력될때마다 저장되는 방식이다.
다만, 비제어는 state에 저장하지 않고 있다가 필요시에 useRef를 통해 input을 참조해 꺼내오거나 document.getElementById하고 가져오는 방법을 사용하게된다. 즉, 입력시에는 어떠한 작용도 일어나지 않는다. 따라서 성능이 빠르다.

isActive등을 사용하려면 제어컴포넌트 사용이 필요하다. 바뀐 스테이트에 내용을 채워야하기 때문이다. 그런데 비제어 컴포넌트에서는 성능을 위해 리랜더(state가 변경되면 리랜더가 일어남)가 일어나지 않는다. 따라서 속도가빠른것.

react-hook-form은 기본적으로(default)가 비제어방식이다.

yarn add react-hook-form 으로 설치

import { useForm } from "react-hook-form";

interface IFormData {
  writer: string;
  title: string;
  contents: string;
  boardAddress: {
    addressDetail: string;
  };
}

export default function ReactHookFormPage() {
  const { register, handleSubmit } = useForm<IFormData>();

  const onClickSubmit = (data: IFormData) => {
    console.log(
      //   data.writer,
      //   data.contents,
      //   data.title,
      data.boardAddress.addressDetail
    );
  };

  console.log("리랜더링되나요?");
  return (
    <form onSubmit={handleSubmit(onClickSubmit)}>
      작성자: <input type="text" {...register("writer")} />
      제목: <input type="text" {...register("title")} />
      내용: <input type="text" {...register("contents")} />
      주소 : <input type="text" {...register("boardAddress.addressDetail")} />
      <button>등록하기</button>
    </form>
  );
}
/* <button type="reset">지우기</button>
 <button type="submit">보내기</button> // 얘가 기본값
 <button type="button">나만의 버튼</button> */
<form>

태그를 이용해 감싸주면되는데, 버튼태그에 적용되는 주의 사항이 있다.

이 경우에 버튼태그에도 type이라는 것이 존재하게되는데,
submit,
reset,
button 이 있다.

submit의 경우 default 값인데, 버튼 클릭시 form에 적어준 주소로 보내지게 되며, reset타입의 경우 form 안의 input의 값을 전부 지워주고, 마지막 button타입의 경우가 나만의 버튼을 만들시 사용(이제껏 한 방식)되는 것이다. 즉, form으로 감싸지만 않으면 onClick을 줄 수 있으나, form으로 감싸주고 type을 지정하지 않으면 submit이 작동되어 form의 onSubmit={여기가 작동됨}

저 안에는 함수를 넣기도 한다.

register에는 onChangewriter등의 기능들이 들어있다. 따라서 적용시 태그에 {...register}라고 적어준다.

그러면 자동으로 onChange가 바인딩된다.

{...register("writer")} ==> 이런식으로 작성한다면 자동으로 writer state가 만들어지고 change시 자동으로 들어간다.

단, 비제어 컴포넌트이기에 writer state가 저장되지는 않는다. 그럼 언제 저장되어 사용할 수 있나?

이때 hanleSubmit 이라는 도구를 사용한다.
onSubmit = {handleSubmit(연결한 함수 명)}} 이때 handelSubmit이 연결된 함수의 매개변수로 들어가는 인자가되고, 그 매개변수를 이용해 mutation을 보내주면 된다.

이미 state나, onChange등이 register에 있기에 기존의 onChange함수는 필요없게된다.

const [aaa,setAaa] = useState("") 이 경우에는 useState뒤의 소괄호를 통해 타입이 자동으로 추론된다.
그러나 useForm을 사용하게되면 자동추론이 되지 않아 명시해주어야한다.
기존에 useState<여기에 타입적기>(), 이나 useMutation<타입>() 이런식으로 저 소괄호 왼편에 <> 얘를 만들어 여기 안에 타입을 적어주는 형식으로 타입명시를 한다.

이렇게 타입명시하는것을 Generic(제내릭)이라고 한다는데 후에 다시 언급하며 수업을 진행하신다고 하고 넘어가셨다.

useForm도 뒤에 타입을 적어주자.

일단 type 이나 interface를 사용해 객체를 만든 뒤

interface IFormData {
  writer: string;
  title: string;
  contents: string;
  boardAddress: {
    addressDetail: string;
  };
}

각각의 타입을 넣어준다.

그리고 useForm에 바인딩? 시켜준다.

const { register, handleSubmit } = useForm<IFormData>()

이 타입이 handelSubmit을 인자로 받게되는 클릭함수의 매개변수의 타입도 된다.


검증하기

검증라이브러리라는것도 따로 존재한다.
찾아보니 react-hook-form만으로도 검증이 가능한것 같지만..
검증라이브러리로는 yup이라는 것을 사용했다.
react-hook-form에 연결하는 yup을 설치했다.

yarn add @hookform/resolvers yup

객체 안에 등록된다. 해당 조건이 맞아야 등록된다.

const yup이름 = yup.object({
키:yup.string().....~
}

이런식으로적게된다.

앞쪽에 number(), string() 여기 소괄호에는 에러메세지를 적을 수 없으나 뒤에 required() 여기 소괄호에는 에러메세지를 적을 수 있다. 만일 required즉 필수 요소 처리를 했는데 적지 않은경우 에러메세지가 나오게된다.

보통 yup의 이름으로는 schema라고 사용한다.

const schema = yup.object({
  // 검증하기
  writer: yup.string().required("작성자를 입력해주세요"),
  title: yup.string().required("제목을 입력해주세요"),
  contents: yup.string().required("내용을 입력해주세요"),
  // email:yup    // 회원가입에 적용
  // .string()
  // .email("이메일 형식에 적합하지 않습니다")
  // .required("이메일은 필수 입력입니다")

  // password:yup
  // .string()
  // .min(4,"비밀번호는 최소 4자리이상 입력")
  // .max(15,"최대 15자리까지만 입력 가능합니다")

  // phone: yup
  //   .string()
  //   .matches(/^\d{3}-\d{3,4}-\d{4}$/, "전화번호 형식에 맞지 않습니다.")
  //   .required("필수 형식입니다"),
});

email에는 .email()이라는 속성이 있다.
matches는 형식을 검증하는 정규표현식이다.

정규표현식 각 의미:
^ ==> 시작을 의미한다
$ ==> 끝을 의미한다.
\d ==> 숫자를 의미
\w ===> 문자를 의미
\d{3} ==> 숫자 3개를 의미
\d{3,4} ==> 숫자 3개 또는 4개를 의미

회원가입검증시 이용하기


조건에 맞지 않는 경우 에러메세지 아래에 보이게

비제어 컴포넌트로는 안된다.

클릭시 검증하는것은 비제어로도 가능하다.검증클릭시 한번만 하면되기 때문.

resolver: yupResolver(schema),
    mode: "onChange",

저 resolver아래 mode : "onChange" 를 추가해준다.

==> 의미는 변경할때마다 검증하겠다는 말.

검증할때마다 리랜더링이 일어나게된다.
이 경우를 트리거 라고한다.

트리거: 어떤일을 원인으로 2차적 사건이 일어나는... 그러것을 의미하는데, 여기에서는 특정작업시 2차 작업이 일어나는것을 의미한다.

여기에서는 onChange를 트리거한다고 할 수 있다.

각 input 아래에 에러메세지 띄우기.

const { register, handleSubmit, formState } = useForm({

useForm에 formState를 추가해준다.

{formState.errors.writer?.message} 

이런식으로 input태그 아래 넣어주면, 에러가 존재하는 경우,메세지가 나오게 된다.

==> 편리한 기능

버튼이 다 입력되었을 경우의 색상변경하기
style = {{background:formState.isValid ? "yellow" : "" }}

formState에 isValid라는 기능이 들어있어 사용이 가능하게된다.


공통컴포넌트 나누기.

input과 button을 공통컴포넌트로 나누고import하여 사용하는 부분을 하였다.

import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import InputsDefault from "../../src/components/commons/inputs/default";
import ButtonsDefault from "../../src/components/commons/buttons/default";

interface IFormData {
  writer: string;
  title: string;
  contents: string;
  password: string;
  boardAddress: {
    addressDetail: string;
  };
}

const schema = yup.object({
  // 검증하기
  writer: yup.string().required("작성자를 입력해주세요"),
  title: yup.string().required("제목을 입력해주세요"),
  contents: yup.string().required("내용을 입력해주세요"),
  password: yup
    .string()
    .min(4, "비밀번호는 최소 4자리이상 입력")
    .max(15, "최대 15자리까지만 입력 가능합니다")
   .required("비밀번호를 입력해주세요"),
  // email:yup    // 회원가입에 적용
  // .string()
  // .email("이메일 형식에 적합하지 않습니다")
  // .required("이메일은 필수 입력입니다")

  // phone: yup
  //   .string()
  //   .matches(/^\d{3}-\d{3,4}-\d{4}$/, "전화번호 형식에 맞지 않습니다.")
  //   .required("필수 형식입니다"),
});

export default function ReactHookFormPage() {
  const { register, handleSubmit, formState } = useForm<IFormData>({
    resolver: yupResolver(schema),
    mode: "onChange",
  });

  const onClickSubmit = (data: IFormData) => {
    console.log(
      //   data.writer,
      //   data.contents,
      //   data.title,
      data.boardAddress.addressDetail
    );
  };

  console.log("리랜더링되나요?");
  return (
    <form onSubmit={handleSubmit(onClickSubmit)}>
      작성자: <InputsDefault type="text" register={register("writer")} />
      <div>{formState.errors.writer?.message}</div>
      {/* 에러가 있으면(?) 보여주기 */}
      제목: <InputsDefault type="text" register={register("title")} />
      <div>{formState.errors.title?.message}</div>
      내용: <InputsDefault type="text" register={register("contents")} />
      <div>{formState.errors.contents?.message}</div>
      비밀번호 :{" "}
      <InputsDefault type="password" register={register("password")} />
      <div>{formState.errors.password?.message}</div>
      <ButtonsDefault title="등록하기" isActive={formState.isValid} />
    </form>
  );
}

input부분

import { UseFormRegisterReturn } from "react-hook-form";

interface IInputsDefaultProps {
type: "text" | "password";
register: UseFormRegisterReturn;
}

export default function InputsDefault(props: IInputsDefaultProps) {
return <input type={props.type} {...props.register} />;
}

button부분

interface IButtonsProps {
  isActive: boolean;
  title: string;
}

export default function ButtonsDefault(props: IButtonsProps) {
  return (
    <button style={{ background: props.isActive ? "yellow" : "" }}>
      {props.title}
    </button>
  );
}

input의 경우에는 register라는 이름으로 보내주고, type도 text와 password가 있으니 type으로 넘겨준다. 이것들의 타입도 정해주라고 타입스크립트 에러가 뜨는데, string으로 하면 되는줄 알았는데 하나는 text고 하나는 password이니 type의 타입도

type: "text" | "password";

이렇게 적어준다.

register은 {register("password")}이렇게 되있는것을 넘겨주었으니 스프레드 연산자를 사용해 input 컴포넌트에서 받는다

{register("password")}
==> {...props.register}

type="text" 나 type="password"는

type={props.type} 으로 받는다

버튼의 경우

isActive={formState.isValid}  

스타일부분이 있었는데, 기존에 formState.isValid ? 다 입력되었을시: 입력 안되었을시

이렇게 사용했던 부분을 isActive라는 이름으로 넘겨주고,

style={{ background: props.isActive ? "yellow" : "" }}

라고 버튼 컴포넌트에서 props로 받아 사용한다.

그런데, 버튼에는 등록하기 등의 이름도 있다. 이 부분도 import하는곳마다 다르게 사용할것이기에 titile이라는 부분에 담아주고 그 title을 props로 받아 사용한다.

title="등록하기"
==> 버튼 컴포넌트에 넘기면

<button style={{ background: props.isActive ? "yellow" : "" }}>
    {props.title}
  </button>

나는 버튼에 handleSubmit을 연결해 분리해보았다.

import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import InputsPage from "../../../src/components/commons/inputs/Input";
import ButtonPage from "../../../src/components/commons/buttons/button";
import { IFormDatas } from "../../../src/components/commons/buttons/button.types";

const schema = yup.object({
  writer: yup
    .string()
    .required("작성자를 입력해주세요")
    .max(5, "5글자 이내로 적어주세요"),
  password: yup
    .string()
    .required("비밀번호를 입력해주세요")
    .matches(
      /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/,
      "비밀번호는 영문, 숫자, 특수문자를 포함한 8자리 이내로 적어주세요"
    ),
  title: yup
    .string()
    .max(100, "100자 이내로 적어주세요")
    .required("제목을 입력해주세요"),
  contents: yup
    .string()
    .max(1000, "1000자 이내로 적어주세요")
    .required("내용을 입력해주세요"),
});

export default function ReactHookForm() {
  const { register, handleSubmit, formState } = useForm<IFormDatas>({
    resolver: yupResolver(schema),
    mode: "onChange",
  });

  const onClickSubmit = (data: IFormDatas) => {
    console.log(data.writer, data.title, data.contents);
  };

  return (
    <form>
      작성자: <InputsPage type="text" register={register("writer")} />
      <div>{formState.errors.writer?.message}</div>
      비밀번호: <InputsPage type="password" register={register("password")} />
      <div>{formState.errors.password?.message}</div>
      제목: <InputsPage type="text" register={register("title")} />
      <div>{formState.errors.title?.message}</div>
      내용: <InputsPage type="text" register={register("contents")} />
      <div>{formState.errors.contents?.message}</div>
      <ButtonPage
        title={"등록하기"}
        handleSubmit={handleSubmit}
        onClickSubmit={onClickSubmit}
      />
    </form>
  );
}

input 컴포넌트

import { IInputsProps } from "./input.types";

export default function InputsPage(props: IInputsProps) {
 return <input type={props.type} {...props.register} />;
}

button 컴포넌트

import { IButtonPageProps } from "./button.types";

export default function ButtonPage(props: IButtonPageProps) {
  return (
    <button type="button" onClick={props.handleSubmit(props.onClickSubmit)}>
      {props.title}
    </button>
  );
}

input타입

import { UseFormRegisterReturn } from "react-hook-form";

export interface IInputsProps {
type: "text" | "password";
register: UseFormRegisterReturn;
}

button 타입

import { UseFormHandleSubmit } from "react-hook-form";

export interface IFormDatas {
writer: string;
password: string;
title: string;
contents: string;
}
export interface IButtonPageProps {
handleSubmit: UseFormHandleSubmit<IFormDatas>;
onClickSubmit: (data: IFormDatas) => void;
title: string;
}

각각 컴포넌트를 분리하고 코드가 줄어드는 과정을 확인도 해보았다. 확실히 줄어드니 보기도 편하고 유지보수도 쉬울것 같다.

다만, 아직도 응용은 쉽지가 않다..

0개의 댓글