React 컴포넌트 코드 읽기
Tab · Typography · Switch

컴포넌트의 목적부터 props, 상태관리, 조건부 렌더링, CSS Module, ...props까지 한 페이지에서 정리합니다.

useState ?? 연산자 ?.() optional chaining map() 동적 태그 ...props / ...rest CSS Module

01. Tab 컴포넌트

배열로 전달받은 탭 목록을 반복 생성하고, 현재 선택된 탭을 관리하는 컴포넌트입니다. button 탭과 link 탭을 모두 지원합니다.

전체 코드

import { useState } from 'react'
import { cn } from '@/utils/cn'
import styles from './Tab.module.css'

function getItemValue(item, index) {
  return item.value ?? item.href ?? String(index)
}

export default function Tab({
  items = [],
  type = 'button',
  value,
  defaultValue,
  onChange,
  className,
  itemClassName,
  activeItemClassName,
  ...props
}) {
  const firstValue = items[0] ? getItemValue(items[0], 0) : undefined
  const [internalValue, setInternalValue] = useState(defaultValue ?? firstValue)
  const selectedValue = value ?? internalValue
  const isLinkType = type === 'link'

  function handleChange(nextValue, item, event) {
    if (!isLinkType) {
      setInternalValue(nextValue)
    }

    onChange?.(nextValue, item, event)
  }

  if (!items.length) return null

  return (
    <div
      className={cn(styles.tab, className)}
      role={isLinkType ? 'navigation' : 'tablist'}
      {...props}
    >
      {items.map((item, index) => {
        const itemValue = getItemValue(item, index)
        const isActive = itemValue === selectedValue || item.active

        const classNameValue = cn(
          styles.item,
          isActive && styles.active,
          itemClassName,
          isActive && activeItemClassName
        )

        if (isLinkType) {
          return (
            <a
              key={itemValue}
              className={classNameValue}
              href={item.href}
              aria-current={isActive ? 'page' : undefined}
              target={item.target}
              rel={item.rel}
              onClick={(event) => handleChange(itemValue, item, event)}
            >
              {item.label}
            </a>
          )
        }

        return (
          <button
            key={itemValue}
            className={classNameValue}
            type="button"
            role="tab"
            aria-selected={isActive}
            disabled={item.disabled}
            onClick={(event) => handleChange(itemValue, item, event)}
          >
            {item.label}
          </button>
        )
      })}
    </div>
  )
}

1. getItemValue()

function getItemValue(item, index) {
  return item.value ?? item.href ?? String(index)
}

각 탭을 구분하기 위한 고유 값을 결정합니다. 앞의 값이 null 또는 undefined라면 다음 값을 사용합니다.

item.value 확인→ 없으면 item.href 확인→ 없으면 String(index)
{ label: '상품', value: 'product' }
// → "product"

{ label: '회사소개', href: '/company' }
// → "/company"

{ label: '기타' }   // index가 2
// → "2"
핵심: ??는 왼쪽 값이 null 또는 undefined일 때만 오른쪽 값을 사용합니다.

2. props 구조

export default function Tab({
  items = [],
  type = 'button',
  value,
  defaultValue,
  onChange,
  className,
  itemClassName,
  activeItemClassName,
  ...props
})
props역할
items탭 목록 배열
typebutton 또는 link
value부모가 직접 관리하는 현재 선택값
defaultValue내부 상태의 초기 선택값
onChange탭 클릭 후 부모에게 선택 결과 전달
className전체 Tab 영역 추가 클래스
itemClassName모든 탭 항목 추가 클래스
activeItemClassName활성화된 탭에만 추가 클래스
...props나머지 속성을 최상위 div에 전달

3. 첫 번째 탭 값 구하기

const firstValue =
  items[0] ? getItemValue(items[0], 0) : undefined

첫 번째 데이터가 있으면 첫 번째 탭의 값을 가져옵니다.

const items = [
  { label: '홈', value: 'home' },
  { label: '상품', value: 'product' }
]

// firstValue → "home"

4. 내부 상태

const [internalValue, setInternalValue] =
  useState(defaultValue ?? firstValue)

defaultValue가 있으면 그것을 초기값으로 사용하고, 없으면 첫 번째 탭의 값을 초기값으로 사용합니다.

defaultValue 있음
<Tab
  items={items}
  defaultValue="product"
/>

// 초기 선택 → product
defaultValue 없음
<Tab items={items} />

// 초기 선택 → 첫 번째 탭 home

5. selectedValue — controlled / uncontrolled

const selectedValue = value ?? internalValue

외부에서 value가 들어오면 외부 값을 사용하고, 없으면 내부 state를 사용합니다.

Uncontrolled 방식

Tab 자신이 선택 상태를 관리합니다.

<Tab
  items={items}
  defaultValue="home"
/>
Controlled 방식

부모 컴포넌트가 상태를 관리합니다.

const [tab, setTab] = useState('home')

<Tab
  items={items}
  value={tab}
  onChange={(value) => setTab(value)}
/>
중요: value ?? internalValue 구조 때문에 이 Tab은 controlled와 uncontrolled 방식을 모두 지원합니다.

6. button / link 판단

const isLinkType = type === 'link'
type="link"
// isLinkType → true

type="button"
// isLinkType → false

7. handleChange()

function handleChange(nextValue, item, event) {
  if (!isLinkType) {
    setInternalValue(nextValue)
  }

  onChange?.(nextValue, item, event)
}

button 탭이면 내부 상태를 변경하고, 부모가 onChange를 전달했다면 부모 함수도 호출합니다.

onChange?.(nextValue, item, event)

위 코드는 쉽게 쓰면 다음과 거의 같습니다.

if (onChange) {
  onChange(nextValue, item, event)
}

?.()는 함수가 존재할 때만 실행하는 optional chaining 문법입니다.

실제 사용

<Tab
  items={items}
  onChange={(value, item, event) => {
    console.log(value)
    console.log(item)
  }}
/>

상품 탭 클릭 시:

value
// "product"

item
// { label: "상품", value: "product" }

8. 데이터가 없으면 렌더링하지 않기

if (!items.length) return null

items = []이면 items.length가 0이므로 화면에 아무것도 렌더링하지 않습니다.

9. map()으로 탭 생성

{items.map((item, index) => {
  ...
})}

items가 3개라면 button 또는 a 태그도 3개 만들어집니다.

10. 활성화 여부

const isActive =
  itemValue === selectedValue || item.active

현재 선택값과 같거나, item 자체에 active: true가 있으면 활성 상태가 됩니다.

11. 클래스 조합

const classNameValue = cn(
  styles.item,
  isActive && styles.active,
  itemClassName,
  isActive && activeItemClassName
)

기본 클래스는 항상 적용하고, 활성화된 경우에만 active 관련 클래스를 추가합니다.

12. link 타입

<Tab
  type="link"
  items={[
    { label: '회사소개', href: '/company' },
    { label: '고객센터', href: '/support' }
  ]}
/>

이 경우 내부에서는 <a> 태그를 생성합니다.

13. button 타입

<Tab
  items={[
    { label: '홈', value: 'home' },
    { label: '상품', value: 'product' },
    { label: '회원', value: 'member', disabled: true }
  ]}
/>

기본 type="button"이므로 <button> 태그들이 만들어집니다.

items 전달→ 초기값 결정→ map 반복→ button/a 생성→ 클릭→ handleChange→ 활성 탭 변경

02. Typography 컴포넌트

텍스트용 HTML 태그를 상황에 따라 p, h1, span 등으로 바꾸고, CSS Module 클래스를 공통 처리하기 위한 컴포넌트입니다.

전체 코드

import { cn } from "@/utils/cn"
import styles from "./Typography.module.css"

export default function Typography({
  tagName: Tag = "p",
  className = "",
  children,
  ...rest
}) {
  const moduleClassName = className
    .split(/\s+/)
    .filter(Boolean)
    .map((className) => styles[className])

  return (
    <Tag className={cn(moduleClassName, className)} {...rest}>
      {children}
    </Tag>
  )
}

1. tagName: Tag = "p"

tagName: Tag = "p"

구조분해 할당을 하면서 tagName을 내부에서는 Tag라는 변수명으로 사용합니다. 값이 없으면 기본값은 "p"입니다.

<Typography tagName="h1">
  제목
</Typography>

// 실제 결과
<h1>제목</h1>
<Typography tagName="span">
  홍길동
</Typography>

// 실제 결과
<span>홍길동</span>
<Typography>
  기본 본문
</Typography>

// tagName을 안 줬으므로
<p>기본 본문</p>
핵심: React에서는 대문자로 시작하는 변수 Tag에 태그 이름을 넣어 두고 <Tag>처럼 동적으로 렌더링할 수 있습니다.

2. className 문자열 분리

className.split(/\s+/)

공백을 기준으로 하나의 className 문자열을 배열로 나눕니다.

"text-title-xl bold"

// ↓

["text-title-xl", "bold"]

3. filter(Boolean)

.filter(Boolean)

배열 안의 빈 문자열 같은 falsy 값을 제거합니다.

"text-title-xl     bold"
  .split(/\s+/)
  .filter(Boolean)

// ["text-title-xl", "bold"]

4. CSS Module 클래스 찾기

.map((className) => styles[className])

문자열로 전달된 클래스 이름을 CSS Module 객체에서 실제 생성된 클래스 이름으로 변환합니다.

// Typography.module.css

.text-title-xl {
  font-size: 24px;
}

.bold {
  font-weight: bold;
}
styles["text-title-xl"]
styles["bold"]

// 빌드 후 대략
[
  "Typography_text-title-xl_a81sd",
  "Typography_bold_k29fd"
]

5. 최종 className 조합

<Tag
  className={cn(moduleClassName, className)}
>

cn()으로 CSS Module에서 변환된 클래스와 원래 전달받은 클래스 문자열을 합칩니다.

<Typography
  tagName="h1"
  className="text-display"
>
  제목
</Typography>

실제 결과는 대략 다음과 비슷합니다.

<h1 class="Typography_text-display_xxx text-display">
  제목
</h1>

6. ...rest

export default function Typography({
  tagName: Tag = "p",
  className = "",
  children,
  ...rest
})

직접 꺼낸 tagName, className, children을 제외한 나머지 props가 모두 rest에 들어갑니다.

<Typography
  tagName="span"
  className="text-title-xl"
  id="user-name"
  aria-label="사용자 이름"
  title="회원 이름"
>
  홍길동
</Typography>

여기서 rest에는 다음이 들어갑니다.

{
  id: "user-name",
  "aria-label": "사용자 이름",
  title: "회원 이름"
}

그리고:

<Tag {...rest}>

를 통해 실제 HTML 태그에 전달됩니다.

tagName 전달→ Tag로 변경→ className 분리→ CSS Module 검색→ cn() 결합→ 동적 태그 렌더링

03. Switch 컴포넌트

실제 기능은 input type="checkbox"가 담당하고, track과 thumb span은 토글 스위치처럼 보이게 만드는 디자인 요소입니다.

전체 코드

import { cn } from '@/utils/cn'
import styles from './Switch.module.css'

export default function Switch({
  children,
  label,
  className,
  trackClassName,
  textClassName,
  ...props
}) {
  const switchLabel = label ?? children

  return (
    <label className={cn(styles.switch, className)}>
      <input
        {...props}
        className={styles.input}
        type="checkbox"
        role="switch"
      />

      <span
        className={cn(styles.track, trackClassName)}
        aria-hidden="true"
      >
        <span className={styles.thumb} />
      </span>

      {switchLabel && (
        <span className={cn(styles.text, textClassName)}>
          {switchLabel}
        </span>
      )}
    </label>
  )
}

1. label ?? children

const switchLabel = label ?? children

label이 있으면 label을 사용하고, 없으면 children을 사용합니다.

label 방식
<Switch label="알림 받기" />
children 방식
<Switch>
  알림 받기
</Switch>

둘 다 결과적으로 switchLabel은 "알림 받기"가 됩니다.

2. label 태그로 전체 감싸기

<label className={cn(styles.switch, className)}>
  ...
</label>

input과 표시 텍스트가 하나의 label 영역 안에 있기 때문에 텍스트나 스위치 영역을 클릭해도 input이 동작할 수 있습니다.

3. 실제 기능은 checkbox

<input
  {...props}
  className={styles.input}
  type="checkbox"
  role="switch"
/>

화면에서는 스위치처럼 보이지만 실제 상태는 checkbox가 관리합니다.

핵심: track, thumb가 ON/OFF 기능을 만드는 것이 아니라 <input type="checkbox">가 실제 기능을 담당합니다.

4. role="switch"

role="switch"

접근성 도구에 이 체크박스가 일반적인 체크 항목보다는 ON/OFF 스위치 역할을 한다는 의미를 알려줍니다.

5. ...props가 실제 input으로 전달

<input {...props} ... />

Switch 사용 시 별도로 선언하지 않은 input 속성을 그대로 사용할 수 있습니다.

const [enabled, setEnabled] = useState(false)

<Switch
  label="알림 받기"
  checked={enabled}
  onChange={(event) => {
    setEnabled(event.target.checked)
  }}
  disabled={false}
  name="notification"
/>

여기서 다음 값들이 모두 props로 들어갑니다.

checked
onChange
disabled
name

그리고 내부 input에 그대로 전달됩니다.

<input
  checked={enabled}
  onChange={...}
  disabled={false}
  name="notification"
  type="checkbox"
/>
장점: checked, onChange, disabled, name, id 등을 컴포넌트 props에서 하나씩 모두 선언하지 않아도 됩니다.

6. track과 thumb

<span className={styles.track}>
  <span className={styles.thumb} />
</span>
track = 스위치 바탕 + thumb = 움직이는 동그라미

예를 들어 CSS는 다음과 같은 구조로 만들 수 있습니다.

.track {
  width: 40px;
  height: 22px;
  border-radius: 20px;
}

.thumb {
  width: 18px;
  height: 18px;
  border-radius: 50%;
}

checkbox가 체크됐을 때 CSS 선택자를 이용해 배경이나 thumb 위치를 바꿉니다.

.input:checked + .track {
  /* ON 상태의 track */
}

.input:checked + .track .thumb {
  transform: translateX(18px);
}

7. aria-hidden="true"

<span
  className={styles.track}
  aria-hidden="true"
>

track/thumb은 디자인을 위한 요소이므로 스크린 리더가 별도의 의미 있는 콘텐츠로 읽지 않도록 합니다.

8. 조건부 label 렌더링

{switchLabel && (
  <span className={cn(styles.text, textClassName)}>
    {switchLabel}
  </span>
)}

switchLabel이 있을 때만 span을 생성합니다.

<Switch />
// → 텍스트 없음

<Switch label="자동 로그인" />
// → "자동 로그인" 텍스트 생성
Switch 클릭→ checkbox 상태 변경→ :checked CSS 적용→ track/thumb 모양 변경→ onChange 실행

04. 세 컴포넌트에서 반복되는 React 패턴

① 기본값
items = []
type = 'button'
className = ""
tagName: Tag = "p"

props가 없을 때 사용할 기본값을 구조분해 단계에서 지정합니다.

② nullish coalescing
label ?? children
value ?? internalValue
defaultValue ?? firstValue

왼쪽이 null/undefined일 때 오른쪽 값을 사용합니다.

③ 나머지 props 전달
...props
...rest

컴포넌트가 모든 HTML 속성을 일일이 선언하지 않아도 되게 해줍니다.

④ cn() 클래스 결합
cn(
  styles.item,
  isActive && styles.active,
  itemClassName
)

기본 클래스 + 조건부 클래스 + 외부 클래스를 합칩니다.

⑤ 조건부 렌더링
if (!items.length) return null

switchLabel && (
  <span>...</span>
)

조건에 따라 요소 자체를 만들거나 만들지 않습니다.

⑥ 접근성 속성
role="tab"
aria-selected={isActive}

role="switch"
aria-hidden="true"

화면 모양뿐 아니라 요소의 의미도 전달합니다.

05. 최종 비교

컴포넌트 핵심 역할 가장 중요한 코드
Tab 여러 탭 중 현재 선택값 관리 value ?? internalValue
items.map()
handleChange()
Typography 텍스트 태그와 CSS 스타일 공통화 tagName: Tag = "p"
styles[className]
...rest
Switch checkbox를 ON/OFF 스위치 UI로 표현 label ?? children
<input {...props}>
.input:checked

코드를 읽을 때 이렇게 보면 빠릅니다

1. props 확인→ 2. state 확인→ 3. 파생 변수 확인→ 4. 이벤트 함수 확인→ 5. return JSX 확인→ 6. 실제 사용 예제로 연결

특히 Tab에서는 controlled/uncontrolled 구조, Typography에서는 동적 Tag, Switch에서는 실제 input과 디자인용 span의 역할 구분을 이해하면 전체 코드가 훨씬 쉽게 보입니다.