개발

react-hook-form을 이용한 복잡한 폼 구현 feat checkbox

배우겠습니다 2024. 1. 11. 00:59

요구사항 

마이 타입을 설정하고 갱신해야한다.

체크된건 주황색/안된건 하얀색이다.

1. 서버로 부터 전체 마이타입을 가져온다. [{id:number,name:string,thumbnail:string(imgURL)},...]형식이다.

2. 만약에 유저가 이미 설정한 마이타입이 있다면 체크돼있어야한다.

3. 사진을 누르면 체크 상태가 바뀐다. 

4. 3개보다 더 많이 체크되면 안된다.

5. 초기화 누르면 초기상태가 된다. 

6. 확인하기를 누르면 서버로 요청을 보내고 갱신한다. [id1,id2,id3,...]형식이다.

 

문제 해결 전략

모달에서 상태관리는 복잡하다. useContext로 서버의 전체 마이타입과 모달을 닫는 setIsOpen을 뿌려준다.

useFieldArray을 고려해볼 수 있다. 하지만 문서를 읽어봤고 들어오는 데이터를 생각해봤다.

해당 훅의 사용은 오히려 개발경험과 코드구조에 악영향을 줄 것 같았다.

전체 마이 타입은 거의 바뀔 데이터도 아니고(사실 고정된 데이터라 생각한다.) 폼을 입력하는 중엔 큰 동적인 변화가 없는 것도 이유이다. 따라서 useForm의 기능을 활용해보고자 했다.

그래서 폼을 다음과 같이 설정한다. {[key:number]:boolean} (key는 마이타입의 아이디고 value는 체크됐는지 여부이다.)

각각의 마이 타입별로 필드를 관리하자고 생각했다.

타입 에러는 타입 캐스팅을 활용하면 해결할 수 있으나, 추후엔 유지보수성에 악영향이 갈 수도 있다.

따라서 상황을 보고 useFieldArray를 고려해 볼 것이다.

선택인지 아닌지가 중요하므로 input의 타입을 checkbox로 활용했다. 이를 숨기고 내 ui와 연결할 것이다.

 

 

1.  useQuery를 이용해 전체 마이타입과 유저가 설정한 마이타입을 가져온다.

2. useForm의 defaultValues를 설정해준다.

export const getDefaultValues = (
  all: IfCategory[],
  my: IfUserType[],
): FormCategoryDataType => {
  const result:FormCategoryDataType = {} //결과
  const myIdArr = my.map(e => e.id) //현재 내 타입의 id들을 뽑아준다.
  all.forEach(e => {
    result[e.id] = myIdArr.includes(e.id) // 전체타입과 내타입을 비교한다.
  })
  return result
}


const {data: curType} = useContext(TypeContext)

  const methods = useForm<FormCategoryDataType>({
    defaultValues: getDefaultValues(allType, curType),
  })

  const {onSubmit} = usePostCategory()

 

  return (
    <>
      <Bar>마이 타입</Bar>
      <FormProvider {...methods}>
        <form onSubmit={methods.handleSubmit(onSubmit)}>
          <CardContainer data={allType}/>
          <ButtonContainer/>
        </form>
      </FormProvider>
    </>
  )

 

getDefaultValues()로 초기값을 설정해준다.

 

3.필드 만들기

 return (
    <Container>
      {data.map(e => (
        <TypeCard key={e.id} data={e} />
      ))}
    </Container>
  )

 

전체 타입을 기준으로 카드를 뿌려준다. 각 카드가 필드가 될 것이다.

const TypeCard = ({data}: proptype) => {
  const id = data.id
  const {category, control, getValues} = useCategoryForms(id)

  const flag = useWatch({
    control: control,
    name: String(id) as never,
  }) as boolean

  const checkRef = useRef<HTMLInputElement | null>(null)

  const blockClick = (): boolean => {
    const allData = getValues()
    const cnt = Object.values(allData).filter(e => e === true).length
    if (cnt >= 3) {
      return false
    }
    return true
  }

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    category.field.onChange(!e.target.checked)
  }

  return (
    <Container
      onClick={() => {
        if (checkRef.current) {
          if (!flag && !blockClick()) {
            alert('타입은 최대 3개까지 설정 가능합니다.')
          } else {
            checkRef.current.click()
            console.log(id, category.field, flag)
          }
        }
      }}
    >
      <input
        type="checkbox"
        hidden
        id={String(id)}
        name={category.field.name}
        ref={e => {
          category.field.ref(e)
          checkRef.current = e
        }}
        onChange={handleChange}
      />
      <TypeImage src={data.thumbnail} alt={data.name} />
      {<CustomCheck flag={flag} />}
      <Title>{data.name}</Title>
    </Container>
  )
}

3번을 구현하기 위해 필요한 것은 다음과 같다.

3-1. 현재 체크 상태를 가져와야한다. 

3-2. 체크가 바뀌면 리랜더링이 돼야한다.

따라서 getValues()가 아닌 useWatch()로 상태를 가져와 리랜더링을 발생시켜 ui를 바꿔준다.

 const flag = useWatch({
    control: control,
    name: String(id) as never,
  }) as boolean

3-3. 사진을 누르면 체크 상태가 바뀌어야한다.

따라서 input field와 전체 카드 영역을 링크시켜줘야한다.

const TypeCard = ({data}: proptype) => {
  
  const checkRef = useRef<HTMLInputElement | null>(null) //  input의 ref를 활용할 수 있다.



  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    category.field.onChange(!e.target.checked)
  } // checkbox인풋의 값을 제어할 수 있다.

  return (
    <Container
      onClick={() => {
        if (checkRef.current) {
            checkRef.current.click() // hidden인 input을 클릭한다.
          }
        }
      }}
    >
      <input
        type="checkbox"
        hidden // 가려져 있다.
        id={String(id)}
        name={category.field.name}
        ref={e => {
          category.field.ref(e)
          checkRef.current = e // field의 ref를 checkRef에 할당한다.
        }}
        onChange={handleChange} // checkRef.current.click()
      />
    </Container>
  )
}

 

field.ref로 input에 연결 가능하다. 이걸 checkRef에 연결시켜준다.input이 hidden이여도, checkRef.click()을한다면, checkbox input이 click되는 효과를 얻을 수 있다.

 

4. 3개이상이면 더 체크 못하게 하기

const blockClick = (): boolean => {
    const allData = getValues()
    const cnt = Object.values(allData).filter(e => e === true).length
    if (cnt >= 3) {
      return false
    }
    return true
  }

전체 폼 데이터를 가져와야하므로 getValues()를 활용한다.

막는건 굳이 리랜더링을 발생시킬 필요가 없다. alert를 띄울 것이기 때문이다.

<Container
      onClick={() => {
        if (checkRef.current) {
          if (!flag && !blockClick()) {
            alert('타입은 최대 3개까지 설정 가능합니다.')
          } else {
            checkRef.current.click()
          }
        }
      }}
    >

 

만약에 3개가 체크된 상태인데 체크가 안된걸 체크하려고 한다면(새로운걸 선택하려고 한다면) 막아버린다.

5. reset()

const ButtonContainer = () => {
  
  const {reset} = useFormContext<FormCategoryDataType>()

  return (
    <Container>
      <ModalButton mark="sub" variant="contained" onClick={()=>{reset()}}>
        초기화
      </ModalButton>
      <ModalButton variant="contained" type="submit">
        확인하기
      </ModalButton>
    </Container>
  )
}

reset은 폼 상태를 defaultValue로 만들어준다.

만약에 아무것도 선택 안한 상태로 만들고 싶다면 전체 폼 데이터를 가져오고 그걸 모두 false시킨 결과를 넣어줄 것이다.

6. 서버로 보내기

const {setIsOpen} = useContext(TypeContext)

  const myTypeMutation = useMutation((d: IfUserTypePost) => postMyType(d), {
    onSuccess: () => {
      queryClient.invalidateQueries() // 상태를 바꾸면 강제로 내 타입을 refetch해야한다.
      alert('타입 추가 및 변경 성공')
      setIsOpen(false) //모달을 닫아준다.
    },
    onError: () => {
      alert('타입 추가 및 변경 실패')
      setIsOpen(false)
    },
    useErrorBoundary: false,
  })

  const onSubmit = (data: FormCategoryDataType) => {
    const result = Object.keys(data)
      .filter(e => data[e] === true)
      .map(e => Number(e))
    // 서버로 보내는 양식에 맞게 폼 데이터를 가공한다.
    myTypeMutation.mutate({categoryIds: result})
  }

 

서버로 보내는 양식을 보면 useFieldArray가 맞나 싶긴한데 저 데이터 가공이 딱히 어려운게 아니긴 하다