참고자료:
React 컴포넌트를 커스텀 훅으로 제공하기 - LINE Engineering
React.FC를 사용하지 않는 이유
npx create-react-app "프로젝트이름" --template typescript
완성 코드
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;
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;
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 1 | check 2 | check 3 |
|---|---|---|
| false | false | false |
...
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 1 | check 2 | check 3 |
|---|---|---|
| false | false | false |
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 요소 | 1 | 2 | 컴포넌트 분리 | |
|---|---|---|---|---|
| 상태 변수 | checkList | |||
| 이벤트 핸들러 | handleCheckClick | isAllChecked | ||
| 렌더 | 체크박스 | 그 밖의 것 | 체크박스 → 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의 기능
const labels = ['check 1', 'check 2', 'check 3']
{renderChecks()}
const [isAllChecked, renderChecks] = useChecks(labels)
<button *disabled*={!isAllChecked}>다음</button>
App의 역할
useChecksHook의 기능
const [isAllChecked, renderChecks] = useChecks(labels)
handleCheckClick 함수
checkList 상태 관리isAllChecked()
isAllChecked 상태 관리renderChecks()
*labels={labels}` onCheck*={handleCheckClick}return [isAllChecked, renderChecks]
useChecksHook의 역할
Checks 컴포넌트가 App에서 직접 보이지 않게 한다현재 샘플은 나중에 입력 labels가 변하면 대응할 수 없습니다.