React 컴포넌트를 커스텀 훅으로 제공하기

아데스티·2023년 2월 20일

React

목록 보기
1/7

참고자료:

React 컴포넌트를 커스텀 훅으로 제공하기 - LINE Engineering
React.FC를 사용하지 않는 이유

🔑 스타일의 재사용, 컴포넌트의 분할
npx create-react-app "프로젝트이름" --template typescript

완성 코드

  • App.tsx
        import useChecks from "./useChecks"
        
        const labels = ['check 1', 'check 2', 'check 3']
        
        const App = () => {
          const [isAllChecked, renderChecks] = useChecks(labels)
         
          return (
            <div>
              {renderChecks()}
              <p>
                <button disabled={!isAllChecked}>다음</button>
              </p>
            </div>
          )
        }
        
        export default App;
  • useChecked.tsx
        import { useState } from "react";
        import { Checks } from './Checks';
        
        type UseChecksResult = [boolean, () => JSX.Element]
         
        export const useChecks = (labels: readonly string[]): UseChecksResult => {
          const [checkList, setCheckList] = useState(() => labels.map(() => false))
         
          const handleCheckClick = (index: number) => {
            setCheckList((checks) => checks.map((c, i) => (i === index ? !c : c)))
          }
         
          const isAllChecked = checkList.every((x) => x)
         
          const renderChecks = () => (
            <Checks checkList={checkList} labels={labels} onCheck={handleCheckClick} />
          )
         
          return [isAllChecked, renderChecks]
        } 
        
        export default useChecks;
  • Checkes.tsx
        type Props = {
            checkList: readonly boolean[]
            labels: readonly string[]
            onCheck: (index: number) => void
          }
           
          export const Checks: React.FunctionComponent<Props> = ({ checkList, labels, onCheck }) => {
            return (
              <ul>
                {labels.map((label, idx) => (
                  <li key={idx}>
                    <label>
                      <input
                        type='checkbox'
                        checked={checkList[idx]}
                        onClick={() => onCheck(idx)}
                      />
                      {label}
                    </label>
                  </li>
                ))}
              </ul>
            )
          }

세줄 요약

  • App에 계산 로직이랑 렌더랑 데이터랑 다 들어 있어서 보기 싫다.
  • 컴포넌트를 분리해서 props로 보낼 수 도 없는 상황이다.
  • 컴포넌트로 못 빼면 Hook으로 빼라

문제 정의

'몇 가지 체크박스가 있고, 전부 체크하면 다음으로 넘어간다'는 전형적인 패턴

현재 2개의 체크박스만 체크되었기 때문에 '다음' 버튼은 비활성화되어 있습니다.

이 버튼은 체크박스를 모두 체크하면 누를 수 있게 활성화됩니다.

이를 React를 이용해서 최대한 있는 그대로 구현하면 다음과 같은 코드가 됩니다.

App.ts

  • 원본
    const labels = ['check 1', 'check 2', 'check 3']
     
    const App: React.FunctionComponent = () => {
      const [checkList, setCheckList] = useState([false, false, false])
     
      // index 번째 체크 상태를 반전시킨다
      const handleCheckClick = (index: number) => {
        setCheckList((checks) => checks.map((c, i) => (i === index ? !c : c)))
      }
     
      const isAllChecked = checkList.every((x) => x)
     
      return (
        <div>
          <ul>
            {labels.map((label, idx) => (
              <li key={idx}>
                <label>
                  <input
                    type='checkbox'
                    checked={checkList[idx]}
                    onClick={() => handleCheckClick(idx)}
                  />
                  {label}
                </label>
              </li>
            ))}
          </ul>
          <p>
            <button disabled={!isAllChecked}>다음</button>
          </p>
        </div>
      )
    }
const labels = ['check 1', 'check 2', 'check 3']
 
const App = () => {
  const [checkList, setCheckList] = useState([false, false, false])
 
  // index 번째 체크 상태를 반전시킨다
  const handleCheckClick = (index: number) => {
    setCheckList((checks) => checks.map((c, i) => (i === index ? !c : c)))
  }
 
  const isAllChecked = checkList.every((x) => x)
 
  return (
    <div>
      <ul>
        {labels.map((label, idx) => (
          <li key={idx}>
            <label>
              <input
                type='checkbox'
                checked={checkList[idx]}
                onClick={() => handleCheckClick(idx)}
              />
              {label}
            </label>
          </li>
        ))}
      </ul>
      <p>
        <button disabled={!isAllChecked}>다음</button>
      </p>
    </div>
  )
}
const App = () => {
  const [checkList, setCheckList] = useState([false, false, false])
	...
  return (
    <div>
      <ul>
        {labels.map((label, idx) => (
          <li key={idx}>
            <label>
              <input
                type='checkbox'
                checked={checkList[idx]}
                onClick={() => handleCheckClick(idx)}
              />
              {label}
            </label>
          </li>
        ))}
      </ul>
      ...
    </div>
  )
}
const labels = ['check 1', 'check 2', 'check 3']

labels의 각 요소를 돌며 li로 추가

state인 checkList 를 반영하며

onClick 시 () => handleCheckClick(idx) 을 실행하는 요소

const App = () => {
  const [checkList, setCheckList] = useState([false, false, false])
 
  // index 번째 체크 상태를 반전시킨다
  const handleCheckClick = (index: number) => {
    setCheckList((checks) => checks.map((c, i) => (i === index ? !c : c)))
  }
	...
}
labels.map((label, idx) => (
  <li key={idx}>
    <label>
      <input type="checkbox" checked={checkList[idx]} onClick={() => handleCheckClick(idx)} />
      {label}
    </label>
  </li>
));

map으로 받아온 idx와 state 배열 요소의 idx를 비교
map의 idx와 동일한 idx를 반전 (false → true)

check 1check 2check 3
falsefalsefalse
...

const App = () => {
  const [checkList, setCheckList] = useState([false, false, false])
  const isAllChecked = checkList.every((x) => x)
 
  return (
    <div>
      ...
      <p>
        <button disabled={!isAllChecked}>다음</button>
      </p>
    </div>
  )
}

every 는 모든 return 값이 true로 나와야 true값을 주는 함수
state중 하나라도 false 라면 false를 반환
<button disabled={!isAllChecked}>다음</button>
isAllChecked 가 false를 반환한다면 button은 disable 상태로 존재합니다.


useState를 이용해서 checkList라는 상태 변수를 선언했습니다. 이것은 boolean 배열입니다.

이 배열의 요소 하나는 체크박스 하나에 대응합니다. 

여기서 스타일을 재사용하는 것과 같은 목적으로, 체크박스가 나열된 부분을 컴포넌트로 분할해서 재사용할 수 있게 만들고 싶다면 어떻게 해야 할까요?

이 문제가 이번 글에서 해결할 문제입니다.

일반적인 컴포넌트 분할 방법

보통 컴포넌트를 분할한다면 아래와 같이 <Checks /> 컴포넌트를 만듭니다.

레이블 목록labels
현재 체크 상태 목록checkList
체크 상태가 변경된 경우의 핸들러onCheck

Checks.ts

type Props = {
  checkList: readonly boolean[]
  labels: readonly string[]
  onCheck: (index: number) => void
}
 
export const Checks: React.FunctionComponent<Props> = ({ checkList, labels, onCheck }) => 
{
  return (
    <ul>
      {labels.map((label, idx) => (
        <li key={idx}>
          <label>
            <input
              type='checkbox'
              checked={checkList[idx]}
              onClick={() => onCheck(idx)}
            />
            {label}
          </label>
        </li>
      ))}
    </ul>
  )
}
export const Checks: React.FunctionComponent<Props> = ({ checkList, labels, onCheck })

Checks 라는 컴포넌트를 내보냄
props로 { checkList, labels, onCheck }를 받음

check 1check 2check 3
falsefalsefalse
labels.map((label, idx) => (
  <li key={idx}>
    <label>
      <input type="checkbox" checked={checkList[idx]} onClick={() => onCheck(idx)} />
      {label}
    </label>
  </li>
));

checked={checkList[idx]} ← props로 전달 받음

onClick 이벤트 발생 시 () => onCheck(idx)

props → onCheck: (index: number) => void // 이후 handleCheckClick 를 받을 다른 이름(onCheck)

App.ts

const labels = ['check 1', 'check 2', 'check 3']

const App = () => {
  const [checkList, setCheckList] = useState([false, false, false])

  // index 번째 체크 상태를 반전시킨다
  const handleCheckClick = (index: number) => {
    setCheckList((checks) => checks.map((c, i) => (i === index ? !c : c)))
  }

  const isAllChecked = checkList.every((x) => x)

  return (
    <div>
      <Checks
        checkList={checkList}
        labels={labels}
        onCheck={handleCheckClick}
      />
      <p>
        <button disabled={!isAllChecked}>다음</button>
      </p>
    </div>
  )
}

이렇게 하면 확실히 컴포넌트를 분할할 수 있지만, 아직 이상적인 상황은 아닙니다.

지금 상황을 그림으로 나타내면 다음과 같습니다.

state

데이터 : checkList labels

메서드 : onCheck={handleCheckClick}

<input type="checkbox" checked={checkList[idx]} onClick={() => onCheck(idx)} />;

Checks.ts 에서 체크된 상태와 handleCheckClick 메서드를 호출

handleCheckClick 에서 checkList[idx] 와 labels[idx]를 비교

부모는 데이터와 메서드를 전달

자식은 각 항목의 받은 데이터를 수정하고 출력

수정된 데이터가 isAllChecked 에 부합하지 않으면 disabled

App.js 요소12컴포넌트 분리
상태 변수checkList
이벤트 핸들러handleCheckClickisAllChecked
렌더체크박스그 밖의 것체크박스 → Checks

checks를 제외한 4가지 기능은 여전히 App을 차지하며 재사용 불가능
checkList handleCheckClick isAllChecked 그 밖의 것
App은 원래 '모두 체크되었는지'에만 관심이 있습니다.
'무엇이 체크되었는지'에 관한 정보 관리는 Checks에 맡기고 싶을 겁니다.

  • 특히 checkList가 여전히 Checks가 아닌 App에 남아있는 것은 주목할 만합니다.
    App이 '다음' 버튼의 disabled 속성을 계산해야 하기 때문입니다.
    이 부분이 그림에서 isAllChecked인데요. 
    이건 checkList에서 계산합니다. 따라서 필연적으로 checkList도 App에 있어야 합니다. 
    Checks 안에 checkList를 넣어 버리면 모두 체크되었을 때 App이 탐지할 수 없기 때문입니다.
    물론 Checks가 onAllChecked와 같은 이벤트 핸들러를 제공할 수는 있지만,
    별로 바람직한 해결책이 아닙니다. 그렇게 하면 App에 isAllChecked를 별도 상태 변수를 두고 같은 정보를 이중으로 관리해야 하기 때문입니다.
  • 현재 상황
    지향해야 할 본연의 모습보다 App이 비대해져 버리는 것은
    보통 컴포넌트를 분할할 때 발생하는 문제입니다.

커스텀 훅 도입

커스텀 훅은 컴포넌트 분할과는 달리 '컴포넌트 로직 자체를 분할하거나 재사용'할 수 있습니다.
이번 예제에서 checkList를 App에서 이동시킬 수는 없지만,
커스텀 훅을 이용하면 App에 속하는 로직을 뽑아낼 수는 있습니다. 

이번 글의 핵심은 'Checks를 사용한다'는 것 자체를 커스텀 훅 안으로 이동시키는 것입니다.
이를 구현한 커스텀 훅인 useChecks를 어떻게 구현했는지 살펴보겠습니다.

useChecks.ts

type UseChecksResult = [boolean, () => JSX.Element]
 
export const useChecks = (labels: readonly string[]): UseChecksResult => {
  const [checkList, setCheckList] = useState(() => labels.map(() => false))
 
  const handleCheckClick = (index: number) => {
    setCheckList((checks) => checks.map((c, i) => (i === index ? !c : c)))
  }
 
  const isAllChecked = checkList.every((x) => x)
 
  const renderChecks = () => (
    <Checks checkList={checkList} labels={labels} onCheck={handleCheckClick} />
  )
 
  return [isAllChecked, renderChecks]
}
const isAllChecked = checkList.every((x) => x)
const [isAllChecked, renderChecks] = useChecks(labels)

함수의 반환값이였던 isAllChecked 를 함수가 아닌 state로 변환

App의 기능

  • 라벨을 입력받기
  • isAllChecked 선언
  • 기타 render
    • renderChecks()
const labels = ['check 1', 'check 2', 'check 3']
{renderChecks()}
const [isAllChecked, renderChecks] = useChecks(labels)
<button *disabled*={!isAllChecked}>다음</button>

App의 역할

  • '체크박스 목록을 적절한 곳에 그릴 것'
  • '체크박스 이외의 부분을 그릴 것'
  • '체크박스가 모두 체크되어 있는지에 따라 표시를 바꿀 것'

useChecks Hook의 기능

const [isAllChecked, renderChecks] = useChecks(labels)
  • handleCheckClick 함수

    • labels 을 받아서 check 상태 전환
    • checkList 상태 관리
  • isAllChecked()

    • isAllChecked 상태 관리
  • renderChecks()

    • Checks 컴포넌트 렌더링
    • props
      • `checkList={checkList} *labels={labels}` onCheck*={handleCheckClick}
  • return [isAllChecked, renderChecks]

useChecks Hook의 역할

  • 체크 박스를 관리한다
  • Checks 컴포넌트가 App에서 직접 보이지 않게 한다

현재 샘플은 나중에 입력 labels가 변하면 대응할 수 없습니다.

profile
종착지이자 거점 A Destination

0개의 댓글