React로 사고하기
React는 여러분이 바라보는 디자인과 빌드하는 앱에 대해 생각하는 방식을 바꿀 수 있습니다. React로 사용자 인터페이스를 빌드할 때는 먼저 컴포넌트라고 하는 조각으로 분해합니다. 그런 다음 각 컴포넌트에 대해 서로 다른 시각적 상태를 설명합니다. 마지막으로 컴포넌트를 서로 연결해 데이터가 흐르도록 합니다. 이 튜토리얼에서는 React로 검색 가능한 제품 데이터 테이블을 구축하는 사고 과정을 안내합니다.
목업으로 시작하기
이미 디자이너가 제공한 JSON API와 목업이 있다고 가정해 보겠습니다.
JSON API는 다음과 같은 데이터를 반환합니다:
[
{ category: "Fruits", price: "$1", stocked: true, name: "Apple" },
{ category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit" },
{ category: "Fruits", price: "$2", stocked: false, name: "Passionfruit" },
{ category: "Vegetables", price: "$2", stocked: true, name: "Spinach" },
{ category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin" },
{ category: "Vegetables", price: "$1", stocked: true, name: "Peas" }
]
목업은 이처럼 보여집니다.
React에서 UI를 구현하려면 일반적으로 동일한 5단계를 따릅니다.
1단계: UI를 컴포넌트 계층 구조로 나누기
박스들을 그리기 위해, 먼저 목업의 모든 컴포넌트와 하위 컴포넌트의 이름을 지정합니다. 디자이너와 함께 작업하는 경우 디자이너가 디자인 툴에서 이러한 컴포넌트의 이름을 이미 지정했을 수 있습니다. 디자이너에게 물어보세요!
배경에 따라 디자인을 여러 가지 방식으로 컴포넌트로 분할하는 방법을 생각해 볼 수 있습니다:
프로그래밍 - 새 함수나 객체를 생성할지 여부를 결정할 때도 동일한 기법을 사용합니다. 이러한 기법 중 하나는 단일 책임 원칙으로, 컴포넌트는 이상적으로는 한 가지 일만 수행해야 한다는 것입니다. 컴포넌트가 커지면 더 작은 하위 컴포넌트로 분해해야 합니다.
CSS - 클래스 셀렉터(선택자)를 만들 목적을 고려하세요. (단, 컴포넌트는 조금 덜 세분화되어 있습니다.)
디자인 - 디자인의 레이어를 어떻게 구성할지 고려합니다.
JSON이 잘 구조화되어 있으면 UI의 컴포넌트 구조에 자연스럽게 매핑되는 것을 종종 발견할 수 있습니다. 이는 UI와 데이터 모델이 동일한 정보 아키텍처, 즉 동일한 형태를 가지고 있는 경우가 많기 때문입니다. UI를 컴포넌트로 분리하고 각 컴포넌트가 데이터 모델의 한 부분과 일치하도록 하세요.
이 화면에는 다섯 가지 컴포넌트가 있습니다:
- FilterableProductTable(회색)에는 전체 앱이 포함됩니다.
- SearchBar(파란색)는 사용자 입력을 받습니다.
- ProductTable(라벤더색)은 사용자 입력에 따라 목록을 표시하고 필터링합니다.
- ProductCategoryRow(녹색)는 각 카테고리에 대한 제목을 표시합니다.
- ProductRow(노란색)는 각 제품에 대한 행을 표시합니다.
제품 테이블(라벤더색)을 보면 테이블 헤더('이름' 및 '가격' 레이블이 포함된)가 자체 컴포넌트가 아닌 것을 알 수 있습니다. 이것은 선호도의 문제이며 다르게도 사용할 수 있습니다. 이 예제에서는 제품 테이블의 목록 안에 표시되므로 제품 테이블의 일부입니다. 그러나 이 헤더가 복잡해지면(예: 정렬을 추가하는 경우) 이를 자체 ProductTableHeader 컴포넌트로 만드는 것이 합리적일 수 있습니다.
이제 목업에서 컴포넌트를 식별했으므로 계층 구조로 정렬합니다. 목업의 다른 컴포넌트 안에 있는 컴포넌트는 계층 구조에서 하위로 표시되어야 합니다:
FilterableProductTable
ㄴSearchBar
ㄴProductTable
ㄴProductCategoryRow
ㄴProductRow
2단계: React에서 정적 버전 빌드하기
이제 컴포넌트 계층 구조가 완성되었으므로 이제 앱을 구현할 차례입니다. 가장 간단한 접근 방식은 아직 인터랙티브를 추가하지 않고 데이터 모델에서 UI를 렌더링하는 버전을 빌드하는 것입니다! 정적 버전을 먼저 빌드한 다음 인터랙티브를 별도로 추가하는 것이 더 쉬운 경우가 많습니다. 정적 버전을 빌드하려면 많은 타이핑이 필요하지만, 상호 작용을 추가하려면 많은 타이핑이 필요하지 않고 많은 생각이 필요합니다.
데이터 모델을 렌더링하는 정적 버전의 앱을 빌드하려면 다른 컴포넌트를 재사용하고 prop을 사용하여 데이터를 전달하는 컴포넌트를 빌드하는 것이 좋습니다. props는 부모에서 자식으로 데이터를 전달하는 방법입니다. (상태의 개념에 익숙하다면 이 정적 버전을 빌드할 때 상태를 전혀 사용하지 마세요. 상태는 상호작용, 즉 시간이 지남에 따라 변경되는 데이터에만 사용됩니다. 이 앱은 정적 버전이므로 필요하지 않습니다.)
계층 구조에서 상위 컴포넌트부터 빌드하는 '하향식' 빌드(예: FilterableProductTable) 또는 하위 컴포넌트부터 작업하는 '상향식' 빌드(예: ProductRow) 중 하나를 선택할 수 있습니다. 간단한 예제에서는 일반적으로 하향식으로 작성하는 것이 더 쉽고, 대규모 프로젝트에서는 상향식으로 작성하는 것이 더 쉽습니다.
function ProductCategoryRow({ category }) {
return (
<tr>
<th colSpan="2">
{category}
</th>
</tr>
);
}
function ProductRow({ product }) {
const name = product.stocked ? product.name :
<span style={{ color: 'red' }}>
{product.name}
</span>;
return (
<tr>
<td>{name}</td>
<td>{product.price}</td>
</tr>
);
}
function ProductTable({ products }) {
const rows = [];
let lastCategory = null;
products.forEach((product) => {
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow
category={product.category}
key={product.category} />
);
}
rows.push(
<ProductRow
product={product}
key={product.name} />
);
lastCategory = product.category;
});
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}
function SearchBar() {
return (
<form>
<input type="text" placeholder="Search..." />
<label>
<input type="checkbox" />
{' '}
Only show products in stock
</label>
</form>
);
}
function FilterableProductTable({ products }) {
return (
<div>
<SearchBar />
<ProductTable products={products} />
</div>
);
}
const PRODUCTS = [
{category: "Fruits", price: "$1", stocked: true, name: "Apple"},
{category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
{category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
{category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
{category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
{category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];
export default function App() {
return <FilterableProductTable products={PRODUCTS} />;
}
(이 코드가 어렵게 느껴진다면 Quick start를 먼저 진행하세요!)
컴포넌트를 빌드하고 나면 데이터 모델을 렌더링하는 재사용 가능한 컴포넌트 라이브러리를 갖게 됩니다. 이 앱은 정적 앱이므로 컴포넌트는 JSX만 반환합니다. 계층 구조의 맨 위에 있는 컴포넌트(FilterableProductTable)는 데이터 모델을 소품으로 사용합니다. 데이터가 최상위 컴포넌트에서 트리 하단에 있는 컴포넌트로 흘러내리기 때문에 이를 단방향 데이터 흐름이라고 합니다.
함정
이 시점에서는 어떤 상태 값도 사용해서는 안 됩니다. 다음 단계를 위한 것입니다!
3단계: 최소한의 완전한 UI 상태 표현 찾기
인터랙티브한 UI를 만들려면 사용자가 기본 데이터 모델을 변경할 수 있도록 해야 합니다. 이를 위해 state를 사용합니다.
상태는 앱이 기억해야 하는 최소한의 변경 데이터 집합이라고 생각하면 됩니다. 상태를 구조화할 때 가장 중요한 원칙은 반복하지 않는 것입니다(반복하지 않기!!!). 앱에 필요한 최소한의 상태 표현을 파악하고 그 외의 모든 것은 on-demand 방식으로 계산하세요. 예를 들어 쇼핑 목록을 작성하는 경우 항목을 상태 배열로 저장할 수 있습니다. 목록에 있는 항목의 개수도 표시하려면 항목의 개수를 다른 상태 값으로 저장하지 말고 배열의 길이를 읽으면 됩니다.
이제 이 예제 애플리케이션의 모든 데이터를 생각해 보세요:
- original 제품 목록
- 사용자가 입력한 검색 텍스트
- 체크박스 값
- 필터링된 제품 목록
다음 중 어떤 것이 상태인가요? 그렇지 않은 것을 식별하세요:
- 시간이 지나도 변하지 않나요? 그렇다면 상태가 아닙니다.
- 부모로부터 props를 통해 전달되나요? 그렇다면 상태가 아닙니다.
- 컴포넌트의 기존 상태나 props를 기반으로 계산할 수 있나요? 그렇다면 확실히 상태가 아닙니다!
그 외에 것들은 아마도 상태일 것입니다. 다시한번 하나씩 살펴봅시다.
- original 제품 목록은 props로 전달되므로 상태가 아닙니다.
- 검색 텍스트는 시간이 지남에 따라 변경되고 아무 것도 계산할 수 없으므로 상태인 것 같습니다.
- 체크박스 값은 시간이 지남에 따라 변경되고 아무 것도 계산할 수 없으므로 상태인 것 같습니다.
- 필터링된 제품 목록은 원래 제품 목록을 가져와서 검색 텍스트 및 확인란 값에 따라 필터링하여 계산할 수 있으므로 상태가 아닙니다.
즉, 검색 텍스트와 체크박스의 값만 상태입니다! 멋지네요!
DEEP DIVE - Props vs State
React에는 두 가지 유형의 "모델" 데이터가 있습니다: props와 state. 이 둘은 매우 다릅니다:
props는 함수에 전달하는 인자와 같습니다. 부모 컴포넌트가 자식 컴포넌트에 데이터를 전달하고 그 모양을 사용자 정의할 수 있게 해줍니다. 예를 들어, form은 버튼에 색상 prop을 전달할 수 있습니다.
state는 컴포넌트의 메모리와 같습니다. 컴포넌트가 일부 정보를 추적하고 상호작용에 반응하여 변경할 수 있게 해줍니다. 예를 들어 버튼은 isHovered 상태를 추적할 수 있습니다.
props와 state는 서로 다르지만 함께 작동합니다. 부모 컴포넌트는 종종 일부 정보를 state에 보관하고(변경할 수 있도록), 이를 자식 컴포넌트에 props로 전달합니다. 처음 읽을 때 그 차이가 여전히 모호하게 느껴지더라도 괜찮습니다. 실제로 적용하려면 약간의 연습이 필요합니다!
4단계: state를 정의할 위치 파악하기
앱의 최소 state data를 식별한 후에는 이 상태 변경을 담당하는 컴포넌트, 즉 상태를 소유하는 컴포넌트를 식별해야 합니다. 기억하세요: React는 단방향 데이터 흐름을 사용하며, 부모 컴포넌트에서 자식 컴포넌트로 컴포넌트 계층 구조를 따라 데이터를 전달합니다. 어떤 컴포넌트가 어떤 상태를 소유해야 하는지 바로 명확하게 떠오르지 않을 수 있습니다. 이 개념을 처음 접하는 경우 어려울 수 있지만 다음 단계를 따라하면 이해할 수 있습니다!
앱의 각각의 상태에 대해:
- 해당 상태를 기반으로 무언가를 렌더링하는 모든 컴포넌트를 식별합니다.
- 가장 가까운 공통 상위 컴포넌트, 즉 계층 구조에서 모든 컴포넌트 위에 있는 컴포넌트를 찾습니다.
- 상태가 어디에 위치할지 결정합니다:
이전 단계에서는 이 애플리케이션에서 검색 입력 텍스트와 체크박스 값이라는 두 가지 상태들을 발견했습니다. 이 예제에서는 항상 함께 표시되므로 하나의 상태로 생각하는 것이 더 쉽습니다.
이제 이 상태에 대한 전략을 살펴봅시다:
- state를 사용하는 컴포넌트를 식별합니다:
- 공통 상위 컴포넌트 찾기: 두 컴포넌트가 공유하는 첫 번째 상위 컴포넌트는 FilterableProductTable입니다.
- 상태가 어디에 있어야 할지 정합니다: 필터 텍스트와 체크된 상태 값은 FilterableProductTable에 유지합니다.
따라서 상태 값은 FilterableProductTable에 저장됩니다.
useState() Hook으로 컴포넌트에 state를 추가합니다. 훅을 사용하면 컴포넌트의 렌더링 주기에 "훅"할 수 있습니다. FilterableProductTable의 상단에 상태 변수 두 개를 추가하고 애플리케이션의 초기 상태를 지정합니다:
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
그런 다음 filterText 및 inStockOnly를 ProductTable 및 SearchBar에 props로 전달합니다:
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly} />
<ProductTable
products={products}
filterText={filterText}
inStockOnly={inStockOnly} />
</div>
이제 애플리케이션이 어떻게 동작하는지 확인할 수 있습니다. 아래 코드에서 filterText 초기값을 useState('')에서 useState('과일')로 편집합니다. 검색 인풋 텍스트와 테이블이 모두 업데이트된것을 볼수있습니다.
import { useState } from 'react';
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly} />
<ProductTable
products={products}
filterText={filterText}
inStockOnly={inStockOnly} />
</div>
);
}
function ProductCategoryRow({ category }) {
return (
<tr>
<th colSpan="2">
{category}
</th>
</tr>
);
}
function ProductRow({ product }) {
const name = product.stocked ? product.name :
<span style={{ color: 'red' }}>
{product.name}
</span>;
return (
<tr>
<td>{name}</td>
<td>{product.price}</td>
</tr>
);
}
function ProductTable({ products, filterText, inStockOnly }) {
const rows = [];
let lastCategory = null;
products.forEach((product) => {
if (
product.name.toLowerCase().indexOf(
filterText.toLowerCase()
) === -1
) {
return;
}
if (inStockOnly && !product.stocked) {
return;
}
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow
category={product.category}
key={product.category} />
);
}
rows.push(
<ProductRow
product={product}
key={product.name} />
);
lastCategory = product.category;
});
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}
function SearchBar({ filterText, inStockOnly }) {
return (
<form>
<input
type="text"
value={filterText}
placeholder="Search..."/>
<label>
<input
type="checkbox"
checked={inStockOnly} />
{' '}
Only show products in stock
</label>
</form>
);
}
const PRODUCTS = [
{category: "Fruits", price: "$1", stocked: true, name: "Apple"},
{category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
{category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
{category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
{category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
{category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];
export default function App() {
return <FilterableProductTable products={PRODUCTS} />;
}
form 편집이 아직 작동하지 않습니다. 위의 코드에서는 콘솔 오류가 발생하며, 그 이유를 설명합니다:
onChange` 핸들러가 없는 form 필드에 `value` prop을 제공했습니다. 이렇게 하면 읽기 전용으로 렌더링됩니다.
위의 코드에서 ProductTable과 SearchBar는 filterText 및 inStockOnly prop을 읽어 테이블, 입력 및 체크박스를 렌더링합니다. 예를 들어 SearchBar가 입력 값을 채우는 방식은 다음과 같습니다:
function SearchBar({ filterText, inStockOnly }) {
return (
<form>
<input
type="text"
value={filterText}
placeholder="Search..."/>
하지만 아직 입력과 같은 사용자 작업에 응답하는 코드를 추가하지 않았습니다. 이것이 마지막 단계가 될 것입니다.
5단계: 역 데이터 흐름 추가하기
현재 앱은 props와 state가 계층 구조 아래로 흐르면서 올바르게 렌더링됩니다. 하지만 사용자 입력에 따라 state를 변경하려면 계층 구조 깊숙한 곳에 있는 form 컴포넌트가 FilterableProductTable의 state를 업데이트해야 하는 등, 다른 방식으로 데이터가 흐르는 것을 지원해야 합니다.
React는 이 데이터 흐름을 명시적으로 만들지만, 양방향 데이터 바인딩보다 조금 더 많은 타이핑이 필요합니다. 위의 예시에서 입력하거나 확인란을 선택하려고 하면 React가 입력을 무시하는 것을 볼 수 있습니다. 이는 의도적인 것입니다. <input value={filterText} />를 작성함으로써, 입력의 값 prop이 항상 FilterableProductTable에서 전달된 filterText 상태와 같도록 설정했습니다. filterText의 state가 설정되지 않았으므로 입력은 변경되지 않습니다.
사용자가 form input을 변경할 때마다 해당 변경 사항을 반영하여 상태가 업데이트되도록 만들고 싶습니다. 상태는 FilterableProductTable이 소유하므로 이 함수만 setFilterText 및 setInStockOnly를 호출할 수 있습니다. SearchBar가 FilterableProductTable의 상태를 업데이트하도록 하려면 이러한 함수를 SearchBar에 전달해야 합니다:
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly}
onFilterTextChange={setFilterText}
onInStockOnlyChange={setInStockOnly} />
SearchBar 내부에 onChange 이벤트 핸들러를 추가하고 이 핸들러에서 상위 상태를 설정합니다:
<input
type="text"
value={filterText}
placeholder="Search..."
onChange={(e) => onFilterTextChange(e.target.value)} />
이제 앱이 완벽하게 작동합니다!
import { useState } from 'react';
function FilterableProductTable({ 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>
);
}
function ProductCategoryRow({ category }) {
return (
<tr>
<th colSpan="2">
{category}
</th>
</tr>
);
}
function ProductRow({ product }) {
const name = product.stocked ? product.name :
<span style={{ color: 'red' }}>
{product.name}
</span>;
return (
<tr>
<td>{name}</td>
<td>{product.price}</td>
</tr>
);
}
function ProductTable({ products, filterText, inStockOnly }) {
const rows = [];
let lastCategory = null;
products.forEach((product) => {
if (
product.name.toLowerCase().indexOf(
filterText.toLowerCase()
) === -1
) {
return;
}
if (inStockOnly && !product.stocked) {
return;
}
if (product.category !== lastCategory) {
rows.push(
<ProductCategoryRow
category={product.category}
key={product.category} />
);
}
rows.push(
<ProductRow
product={product}
key={product.name} />
);
lastCategory = product.category;
});
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>{rows}</tbody>
</table>
);
}
function SearchBar({
filterText,
inStockOnly,
onFilterTextChange,
onInStockOnlyChange
}) {
return (
<form>
<input
type="text"
value={filterText} placeholder="Search..."
onChange={(e) => onFilterTextChange(e.target.value)} />
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={(e) => onInStockOnlyChange(e.target.checked)} />
{' '}
Only show products in stock
</label>
</form>
);
}
const PRODUCTS = [
{category: "Fruits", price: "$1", stocked: true, name: "Apple"},
{category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"},
{category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"},
{category: "Vegetables", price: "$2", stocked: true, name: "Spinach"},
{category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"},
{category: "Vegetables", price: "$1", stocked: true, name: "Peas"}
];
export default function App() {
return <FilterableProductTable products={PRODUCTS} />;
}
이벤트 처리 및 상태 업데이트에 대한 자세한 내용은 인터랙션 추가하기 섹션에서 확인할 수 있습니다.
앞으로 나아갈 방향
지금까지 React로 컴포넌트와 애플리케이션을 구축하는 방법에 대해 아주 간략하게 소개했습니다.
지금 바로 React 프로젝트를 시작하거나 이 튜토리얼에서 사용된 모든 구문을 더 자세히 살펴볼 수 있습니다.