Skip to content
This page has been auto-translated and may contain errors.View in English

React로 생각하기

지금까지 각 장에서 하나씩 배웠습니다: 컴포넌트, props, 상태, 이벤트, 리스트. 이 장에서는 이 모든 것이 하나의 방법으로 모이게 됩니다. 디자인이 주어졌을 때, 어떤 것이 컴포넌트인지, 어떤 값이 상태인지, 그 상태가 어디에 있어야 하는지 어떻게 정할까요? 이를 풀어나가는 반복 가능한 방법이 있으며, 몇 번 해보면 거의 모든 화면에서 같은 5단계를 찾게 될 것입니다.

작은 UI를 처음부터 끝까지 따라가겠습니다: 검색 가능한 상품 목록입니다. 상품 목록, 이름으로 필터링하는 검색창, 품절 상품을 숨기는 체크박스가 있습니다. 다음이 렌더링할 데이터입니다:

jsx
const PRODUCTS = [
  { id: 1, name: '사과', price: '$1', stocked: true },
  { id: 2, name: '드래곤프루트', price: '$1', stocked: false },
  { id: 3, name: '패션프루트', price: '$2', stocked: true },
]

1단계: 디자인을 컴포넌트 트리로 분해하기

디자인을 보고 각 부분 주위에 박스를 그립니다. 좋은 박스는 하나의 일을 하는 것이며, 함수가 뭘 해야 할지 정할 때 쓰는 직관과 같습니다. 우리 화면은 몇 가지로 깔끔하게 나뉩니다:

  • FilterableProductList 전체를 감싼다.
  • SearchBar 텍스트 입력창과 품절 상품 체크박스를 가진다.
  • ProductTable 상품 목록을 표시한다.
  • ProductRow 표의 한 행이다.

이들은 서로 안에 중첩되므로 **컴포넌트 트리**를 이룹니다:

FilterableProductList
├── SearchBar
└── ProductTable
    └── ProductRow  (상품당 하나)

정해진 올바른 분해 방법은 없으며, 컴포넌트 장의 경험 법칙이 여기서도 적용됩니다: 각각을 작고 명확한 일을 하도록 유지합니다. 행은 한 상품을 그리는 방법을 알고, 테이블은 행들을 배치하는 방법을 알고, 검색창은 입력 컨트롤을 압니다. 한 부분이 관련 없는 두 가지 일을 하기 시작하면 보통 분해해야 한다는 신호입니다.

2단계: props만으로 정적 버전 만들기

이제 데이터를 렌더링하지만 아직 아무것도 하지 않는 버전을 만듭니다. 클릭도, 필터링도, 뭔가를 바꾸는 입력도 없습니다. 데이터는 한 방향으로, 트리를 내려가면서 props를 통해 흐릅니다. 이 단계에서는 useState가 나타나지 않습니다.

jsx
function FilterableProductList({ products }) {
  return (
    <div>
      <SearchBar />
      <ProductTable products={products} />
    </div>
  )
}

function ProductTable({ products }) {
  return (
    <table>
      <tbody>
        {products.map(product => (
          <ProductRow key={product.id} product={product} />
        ))}
      </tbody>
    </table>
  )
}

function ProductRow({ product }) {
  return (
    <tr>
      <td>{product.name}</td>
      <td>{product.price}</td>
    </tr>
  )
}

function SearchBar() {
  return (
    <form>
      <input type="text" placeholder="검색..." />
      <label>
        <input type="checkbox" /> 품절 상품만 숨기기
      </label>
    </form>
  )
}

이것은 매번 전체 목록을 렌더링합니다. 검색창과 체크박스는 화면에 있지만 아직 아무것도 하지 않습니다. 이것이 이 단계의 요점입니다: props만으로 전체 레이아웃이 작동하도록 하고, 1단계의 트리가 정말 함께 동작하는지 확인합니다. 움직이는 부분은 아직 관여하지 않습니다. products 배열은 맨 위에 prop으로 들어와 아래로 전달되고, 여기서는 아무것도 기억하거나 바꾸지 않습니다.

3단계: 최소한의 상태 찾기

이제 상호작용을 만들고, 첫 번째 질문은 어떤 값이 상태여야 하는가입니다. 상태는 올바르게 유지하기 어려우므로 가능한 한 적게 유지하고 싶습니다. 나머지는 계산합니다.

각 값을 테스트하는 것은 간단합니다. 두 가지를 물어봅니다:

  1. 시간이 지나면서 변하는가?
  2. 이미 가진 다른 값에서 계산할 수 없는가?

둘 다 그렇다면 상태입니다. 하나라도 아니면 상태가 아닙니다. UI의 값들을 이 테스트를 통해 살펴봅시다:

  • 상품 목록. 들어오는 데이터이고 사용자가 페이지를 건드리는 동안 변하지 않으므로 상태가 아닙니다. prop입니다.
  • 사용자가 입력한 검색 텍스트. 시간이 지나면서 변하고, 계산할 것이 없습니다. 상태입니다.
  • 품절 상품 체크박스가 켜져 있는지 여부. 같은 이야기입니다: 변하고 무엇도 이를 유도하지 않습니다. 상태입니다.
  • 화면에 실제로 표시되는 필터링된 목록. 변하지만 상품, 검색 텍스트, 체크박스에서 계산할 수 있습니다. 따라서 상태가 아닙니다.

마지막 것은 명심할 규칙입니다: 파생시키고 복제하지 말기. 보이는 목록은 현재 필터를 통과한 상품이므로 상태의 두 번째 복사본을 저장하는 대신 렌더링 중에 계산합니다:

jsx
const visible = products.filter(product => {
  const matchesText = product.name
    .toLowerCase()
    .includes(filterText.toLowerCase())
  const matchesStock = !inStockOnly || product.stocked
  return matchesText && matchesStock
})

visible을 자체 useState에 저장했다면, 상품, 텍스트, 체크박스가 변할 때마다 이를 업데이트하는 것을 기억해야 합니다. 하나라도 빠뜨리면 화면에 오래된 목록이 보입니다. 파생시키는 것은 진실의 유일한 원천이 하나라는 뜻이며, 매 렌더링마다 새로 계산되므로 절대 동기화가 깨질 수 없습니다. 따라서 여기서 최소한의 상태는 정확히 두 개의 값입니다: filterTextinStockOnly.

4단계: 각 상태 조각이 살 위치 정하기

두 개의 상태 조각이 있습니다. 이제 어떤 컴포넌트가 각각을 소유할지 알아봅시다. 규칙은 상태를 그것이 필요한 모든 컴포넌트의 가장 가까운 공통 부모에 두는 것이며, 이는 상태 올리기 장에서 깊이 있게 다루는 아이디어와 같습니다.

각 값이 누구에게 필요한지 작업해봅시다:

  • SearchBar는 두 값 모두 필요합니다. 입력창과 이들을 보여주는 체크박스를 렌더링하기 때문입니다.
  • ProductTable도 두 값이 모두 필요합니다. 이들을 사용해 행을 필터링하기 때문입니다.

SearchBarProductTable은 형제입니다. 값은 props를 통해서만 아래로 흐를 수 있으므로, 어느 형제도 다른 형제에게 상태를 전달할 수 없습니다. 둘 다 위에 앉아 있는 가장 가까운 컴포넌트는 FilterableProductList입니다. 상태는 여기로 갑니다:

jsx
import { useState } from 'react'

function FilterableProductList({ products }) {
  const [filterText, setFilterText] = useState('')
  const [inStockOnly, setInStockOnly] = useState(false)

  return (
    <div>
      <SearchBar
        filterText={filterText}
        inStockOnly={inStockOnly}
        onFilterTextChange={setFilterText}
        onInStockOnlyChange={setInStockOnly}
      />
      <ProductTable
        products={products}
        filterText={filterText}
        inStockOnly={inStockOnly}
      />
    </div>
  )
}

상태는 부모에 살고 값들은 prop으로 두 자식 모두에게 내려갑니다. ProductTable은 이들을 읽어 visible 목록을 만들고, SearchBar는 이들을 읽어 입력창과 체크박스를 채웁니다.

5단계: 상태를 업데이트하는 상호작용 추가하기

값은 자식에게 도달하지만 사용자는 아직 변경할 수 없습니다. 데이터는 아래로만 흐르므로, 변경을 다시 위로 보내려면 setter들을 prop으로 아래에 전달하고 자식이 이들을 호출하도록 합니다. FilterableProductList는 이미 위에서 onFilterTextChangeonInStockOnlyChange를 전달합니다. SearchBar는 이들을 입력창에 연결합니다:

jsx
function SearchBar({
  filterText,
  inStockOnly,
  onFilterTextChange,
  onInStockOnlyChange,
}) {
  return (
    <form>
      <input
        type="text"
        placeholder="검색..."
        value={filterText}
        onChange={e => onFilterTextChange(e.target.value)}
      />
      <label>
        <input
          type="checkbox"
          checked={inStockOnly}
          onChange={e => onInStockOnlyChange(e.target.checked)}
        />{' '}
        품절 상품만 숨기기
      </label>
    </form>
  )
}

이제 루프가 닫혔습니다. 박스에 입력하면 onFilterTextChange를 호출하고, 이는 부모에서 setFilterText입니다. 이는 상태를 업데이트하고, 부모가 다시 렌더링되고, 새로운 filterText가 두 자식 모두에게 다시 내려가고, ProductTablevisible 목록을 다시 계산하고, 화면이 사용자가 입력한 것과 일치합니다. 체크박스도 같은 방식으로 작동합니다. 상태는 값으로 아래로 가고, 변경은 함수 호출로 다시 위로 올라가고, 파생된 목록은 자체적으로 따라갑니다.

이것이 전부입니다: 컴포넌트 트리를 그리고, props로 정적 버전을 만들고, 최소한의 상태를 찾고, 올바른 소유자에게 올리고, 상호작용을 연결합니다. 거의 모든 화면이 이 5단계에 응합니다.

3단계가 신경 쓸 가치가 있는 단계입니다. 왜냐하면 "최소한"이 처음 보이는 것보다 엄격하기 때문입니다. 작업하는 질문은: 화면의 다른 모든 값을 다시 계산할 수 있는 가장 작은 값의 집합은 무엇인가입니다. 우리 목록의 경우 filterTextinStockOnly이고, 보이는 모든 것이 이 둘과 들어오는 products의 순함수입니다. 렌더링 중에 파생할 수 있는 것은 모두 그렇게 해야 합니다. 필터링된 목록이 가장 명확한 경우이지만 같은 논리가 일치의 count 저장, hasResults 부울, 정렬된 배열 복사본을 배제합니다. 이들 각각은 이미 보유한 상태의 함수이므로 자체 useState에 유지하는 것은 동기화할 상태를 하나 더 사고 잘못될 새로운 방법을 삽니다. 파생된 값은 오래될 수 없습니다. 지속되지 않으므로 매 렌더링마다 하나의 진실의 원천에서 다시 계산됩니다.

다른 쪽에서 봐야 할 트레이드오프는 매 렌더링마다 비싼 것을 파생시키는 것입니다. 프로파일링이 실제 비용을 보여줄 때만 useMemo에 손을 대세요. 기본값은 렌더링 중에 파생시키고, 값이 시간 지나면서 변하고 이를 생성하는 것이 없을 때만 상태로 승격시킵니다.

1단계도 자신의 판단이 필요하며, 양쪽을 자릅니다. 너무 적게 분해하면 컴포넌트가 재사용하거나 추론하기 어려운 얽힘으로 커집니다. 너무 많이 분해하면 유일한 목적이 prop을 전달하기 위해 존재하는 층들을 통해 prop을 전달하는 작은 컴포넌트의 분무가 생기고, 이것이 자체적인 종류의 따라가기 어렵습니다. 유용한 테스트는 책임과 재사용입니다: 한 조각이 명확한 일을 가질 때, 반복될 때 (상품당 한 번 렌더링되는 ProductRow), 또는 이를 빼내면 부모가 읽기 쉬워질 때 경계가 제 자리를 얻습니다. 한 자리에서만 렌더링되고 건드리지 않고 prop을 전달하는 경계는 보통 인라인할 수 있습니다. 실제 복제와 실제 복잡성이 컴포넌트를 분해하도록 하고, 나중에 이음새가 필요할 거란 추측으로 분해하지 마세요.

Juno디자인에서 React까지 5단계 의존할 수 있는 레시피가 있습니다: 부분 주위에 박스를 그려 컴포넌트를 얻고, 데이터를 보여주고 그 이상은 아무것도 하지 않는 prop으로 먼저 만들고, 정말 변하고 다른 것에서 작업할 수 없는 소수의 값을 찾으세요. 그것들이 상태입니다. 각각을 그것이 필요한 모든 것 위의 가장 가까운 컴포넌트에 두고, 클릭이나 키스트로크가 이를 업데이트할 수 있도록 setter를 아래에 전달하세요. 화면에 표시하는 목록은 상태가 아닙니다. 매번 상품과 필터에서 계산합니다.
Juno디자인에서 React까지 5단계 방법은 매번 같습니다: 컴포넌트 트리, prop만으로 정적 버전, 최소한의 상태, 가장 가까운 공통 부모에 올리기, 상호작용 연결하기. 상태 질문이 날카로운 것입니다. 값이 시간 지나면서 변하고 파생시킬 수 없으면 상태입니다. 그렇지 않으면 렌더링 중에 계산하세요. 파생시키고 복제하지 마세요: filterTextinStockOnly를 저장하고 표시된 목록을 계산하여 절대 동기화가 깨질 수 없게 합니다.
Juno디자인에서 React까지 5단계 최소한의 상태는 다른 모든 것이 순함수인 가장 작은 값의 집합을 의미합니다. 여기서 filterTextinStockOnly이고, 필터링된 목록, 개수, 플래그는 모두 저장하는 대신 렌더링 중에 파생됩니다. 추가 상태는 동기화를 빼는 또 다른 방법이고, useMemo는 프로파일링이 실제 비용을 보여줄 때만 제 자리를 얻습니다. 컴포넌트 경계는 다른 판단 호출입니다: 실제 일, 반복, 가독성을 위해 분해하고, 사양상 추가한 통과형 래퍼를 인라인하세요.

다음: 접근성 있는 React, 방금 디자인한 인터페이스가 모두를 위해 사용 가능해집니다.