Thinking in React 문서에서 소개한 UI를 구현하는 5단계 중 2단계만 먼저 살펴본다.
1단계. UI를 컴포넌트 계층구조로 쪼개는 것(컴포넌트 트리같은 것) 2단계. 리액트를 정적인 버전으로 만들기
시작에 앞서서 필요한 것들
1. 데이터
백엔드에서 JSON 형태의 데이터를 돌려주는 API를 제공한다고 가정한다.
REST API
GET, POST, PUT/PATCH, DELETE (CRUD) => Resource 중심
리소스는 위 그림과 같은 데이터를 의미한다.
GraphQL
그래프 자료구조
Query에서 얻고자 하는 것을 지정한다.
Query(Read), Mutation(Command: Create, Update, Delete), Subscription(Event)
2. mockup
디자이너가 만들어 놓은 UI의 모형을 뜻한다.
그리고 이 데이터와 목업을 가지고 사용자가 볼 수 있도록 UI를 구성한다.
React는 선언형(HTML과 유사한 모양의 DSL을 사용)으로 UI를 구성할 수 있다.
UI를 한번 선언해두면 안에 있는 내용이 바꼈을 때 자동으로 업데이트가 가능하다.
1단계 - UI를 컴포넌트 계층 구조로 쪼개기
리액트의 특징
컴포넌트를 기반으로 한다.
캡슐화된 간단한 컴포넌트들을 모아서 복잡한 UI를 만들어낸다. 자동차의 여러 부품들을 조합하다보면 다양한 제품을 만들 수 있는 것과 비슷하다.
컴포넌트를 나누는 기준
단일 책임 원칙(SRP) : SOLID 원칙 중에서 첫번째 원칙
한가지 컴포넌트가 한가지 일만 하도록 하는 것.
CSS
보통은 html 코드가 길어지는 경우가 많기 때문에 이를 해결하기 위해서 이미 우리가 알고 있는 css의 class 문법을 재활용한다.
<div class="product">
<div class="thumbnail">
<div class="price"></div>
</div>
</div>
Atomic Design Pattern - 원자식 디자인 패턴
위 그림 순서대로 원자 => 분자 => 조직 => 템플릿 => 페이지의 계층 구조로 폴더를 만드는 디자인 패턴이다.
이 패턴을 사용하면 컴포넌트 계층을 의식하게 되면서 컴포넌트를 더 잘 나눌 수 있다.
그런데 이 패턴의 단점은 프로그램의 덩치가 커진다면 애매한 부분이 많이 생겨서 또 팀 내 규칙을 만들어야 한다는 것이다.
또한 의미와 관계가 없이 계층만으로 5단계를 나누는 것에 한계가 있다.
Information Architecture(IA)
: 정보 구조도 => 실제로 가장 많이 쓴다
React Component & Props
리액트 컴포넌트
리액트 컴포넌트를 선언하는 방식에는 함수형이 있고 클래스형이 있다.
함수 컴포넌트는 반환값만 있으면 되지만 클래스형 컴포넌트는 render 함수가 꼭 있어야 하고 그 안에서 보여줄 jsx를 반환해야 한다.
함수 컴포넌트는 클래스 컴포넌트보다 선언하기가 훨씬 수월하고 자원도 덜 이용하고 빌드 후 배포 시에도 파일 크기가 더 작다.(큰 차이는 아님)
리액트 공식 문서에서는 컴포넌트를 새로 작성할 때 함수 컴포넌트와 Hooks를 사용하도록 권장하고 있다.
Props
props는 properties의 줄임말이다. 말 그대로 속성이라는 뜻이고 컴포넌트 속성을 설정할 때 사용한다.
javascript에서 함수의 인자를 넘겨주는 것과 비슷하다.
props를 따로 지정하지 않으면 아래처럼 defaultProps를 설정할 수 있다.
const Component = (name) => {
return <div>이름: {name}</div>;
};
Component.defaultProps = {
name: "홍길동",
};
디스트럭처링 할당으로 props 값 추출하기
const Component = (person) => {
const { name, age } = person;
return (
<div>
이름: {name}
나이: {age}
</div>
);
};
2단계 - 정적 페이지 만들기
[
{ 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" },
];
아래는 매우 길고 불편하지만 위 데이터와 mockup을 가지고 대강의 틀을 만든 코드이다.
type Product = {
category: string,
price: string,
stocked: boolean,
name: string,
};
const products: Product[] = [
{ 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() {
const categories = products.reduce(
(acc, product) =>
acc.includes(product.category) ? acc : [...acc, product.category],
[]
);
return (
<div className="filtered-products-container">
<div className="search-bar">
<div>
<input type="text" placeholder="Search..." />
</div>
<div>
<input type="checkbox" id="only-stock" />
<label htmlFor="only-stock">Only show products in stock</label>
</div>
</div>
<table className="product-table">
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>
<tr>
<th colSpan={2}>{categories[0]}</th>
</tr>
{products
.filter((product) => product.category === categories[0])
.map((product) => (
<tr key={product.name}>
<td>{product.name}</td>
<td>{product.price}</td>{" "}
</tr>
))}
<tr>
<th colSpan={2}>{categories[1]}</th>
</tr>
{products
.filter((product) => product.category === categories[1])
.map((product) => (
<tr key={product.name}>
<td>{product.name}</td>
<td>{product.price}</td>{" "}
</tr>
))}
</tbody>
</table>
</div>
);
}
모든 코드가 App파일 안에 담겨있는데 이제 이 긴 코드들을 컴포넌트로 쪼개서 리팩토링을 해줄 것이다.
먼저 아래 표시한 부분 코드의 컴포넌트를 따로 빼줄 것이다.
App 파일에서 지정해둔 Product 타입들도 따로 폴더를 만들어서 파일 분리 해준다.
interface Product {
category: string;
price: string;
stocked: boolean;
name: string;
}
export default Product;
import Product from "./types/Product";
type ProductsInCategoryProps = {
category: string,
products: Product[],
};
export default function ProductsInCategory({
category,
products,
}: ProductsInCategoryProps) {
const productsInCategory = products.filter(
(product) => product.category === category
);
return (
<>
<tr>
<th colSpan={2}>{category}</th>
</tr>
{productsInCategory.map((product) => {
<tr key={product.name}>
<td>{product.name}</td>
<td>{product.price}</td>{" "}
</tr>;
})}
</>
);
}
ProductsInCategory.tsx 파일도 components 폴더에 따로 만들어준 뒤 분리해주었다.
그리고 map을 이용하여 반복을 할 때는 key가 필요하다.
import ProductsInCategory from "./components/ProductsInCategory";
import Product from "./types/Product";
const products: Product[] = [
{ 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() {
const categories = products.reduce(
(acc, product) =>
acc.includes(product.category) ? acc : [...acc, product.category],
[]
);
return (
<div className="filtered-products-container">
<div className="search-bar">
<div>
<input type="text" placeholder="Search..." />
</div>
<div>
<input type="checkbox" id="only-stock" />
<label htmlFor="only-stock">Only show products in stock</label>
</div>
</div>
<table className="product-table">
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<ProductsInCategory
key={category}
category={category}
product={products}
/>
))}
</tbody>
</table>
</div>
);
}
남은 App파일의 <tbody>
내부가 조금 깔끔해졌다.
이제 ProductInCategory 내부에서 아래 그림처럼 컴포넌트를 분리해줄 것이다.
import Product from "../types/Product";
type ProductRowProps = {
product: Product,
};
export default function ProductRow({ product }: ProductRowProps) {
return (
<tr>
<td>{product.name}</td>
<td>{product.price}</td>
</tr>
);
}
export default function ProductCategoryRow({ category }: { category: string }) {
return (
<tr>
<th colSpan={2}>{category}</th>
</tr>
);
}
import Product from "../types/Product";
import ProductCategoryRow from "./ProductCategoryRow";
import ProductRow from "./ProductRow";
type ProductsInCategoryProps = {
category: string,
products: Product[],
};
export default function ProductsInCategory({
category,
products,
}: ProductsInCategoryProps) {
const productsInCategory = products.filter(
(product) => product.category === category
);
return (
<>
<ProductCategoryRow category={category} />
{productsInCategory.map((product) => (
<ProductRow key={product.name} product={product} />
))}
</>
);
}
이제 App 파일에서 ProductTable을 분리해줄 것이다.
import ProductsInCategory from "./components/ProductsInCategory";
import Product from "./types/Product";
const products: Product[] = [
{ 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" },
];
function ProductTable({ products }: { products: Product[] }) {
const categories = products.reduce(
(acc: string[], product: Product) =>
acc.includes(product.category) ? acc : [...acc, product.category],
[]
);
return (
<table className="product-table">
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<ProductsInCategory
key={category}
category={category}
products={products}
/>
))}
</tbody>
</table>
);
}
export default function App() {
return (
<div className="filtered-products-container">
<div className="search-bar">
<div>
<input type="text" placeholder="Search..." />
</div>
<div>
<input type="checkbox" id="only-stock" />
<label htmlFor="only-stock">Only show products in stock</label>
</div>
</div>
<ProductTable products={products} />
</div>
);
}
이제 컴포넌트 분리에 익숙해졌으니 한 번에 최종적으로 아래 그림처럼 컴포넌트 분리를 해줄 것이다.
분리 완성된 코드
import FilterableProductTable from "./components/FilterableProductTable";
import Product from "./types/Product";
const products: Product[] = [
{ 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 (
<div>
<FilterableProductTable products={products} />
</div>
);
}
interface Product {
category: string;
price: string;
stocked: boolean;
name: string;
}
export default Product;
/components/FilterableProductTable.tsx
import ProductTable from "./ProductTable";
import SearchBar from "./SearchBar";
import Product from "../types/Product";
type FilterableProductTableProps = {
products: Product[],
};
export default function FilterableProductTable({
products,
}: FilterableProductTableProps) {
return (
<div className="filtered-products-container">
<SearchBar />
<ProductTable products={products} />
</div>
);
}
/components/SearchBar.tsx
import CheckBoxField from "./CheckBoxField";
export default function SearchBar() {
return (
<div className="search-bar">
<div>
<input type="text" placeholder="Search..." />
</div>
<CheckBoxField label="Only show products in stock" />
</div>
);
}
/components/CheckBoxField.tsx
import { useRef } from "react";
type CheckBoxFieldProps = {
label: string,
};
export default function CheckBoxField({ label }: CheckBoxFieldProps) {
const id = useRef(`checkbox-${label}`.replace(/ /g, "-").toLowerCase());
return (
<div>
<input type="checkbox" id={id.current} />
<label htmlFor={id.current}>{label}</label>
</div>
);
}
/components/ProductTable.tsx
import ProductsInCategory from "./ProductsInCategory";
import Product from "../types/Product";
import selectCategories from "../util/selectCategories";
type ProductTableProps = {
products: Product[],
};
export default function ProductTable({ products }: ProductTableProps) {
const categories = selectCategories(products);
return (
<table className="product-table">
<thead>
<tr>
<th>Name</th>
<th>Price</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<ProductsInCategory
key={category}
category={category}
products={products}
/>
))}
</tbody>
</table>
);
}
원래 ProductsTable 파일에서는 데이터 안에서 같은 카테고리 종류들을 가져오는 연산을 했었다.
이것도 util 폴더로 분리해서 selectCategories 함수를 만들어주었다.
/util/selectCategories.tsx
import Product from "../types/Product";
export default function selectCategories(products: Product[]): string[] {
return products.reduce((acc: string[], product: Product) => {
const { category } = product;
return acc.includes(category) ? acc : [...acc, category];
}, []);
}
/components/ProductsInCategory.tsx
import selectProducts from "../util/selectProducts";
import ProductCategoryRow from "./ProductCategoryRow";
import ProductRow from "./ProductRow";
import Product from "../types/Product";
type ProductsInCategoryProps = {
category: string,
products: Product[],
};
export default function ProductsInCategory({
category,
products,
}: ProductsInCategoryProps) {
const productsInCategory = selectProducts(products, category);
return (
<>
<ProductCategoryRow category={category} />
{productsInCategory.map((product) => (
<ProductRow key={product.name} product={product} />
))}
</>
);
}
import Product from "../types/Product";
export default function selectProducts(
items: Product[],
category: string
): Product[] {
return items.filter((item) => item.category === category);
}
/components/ProductCategoryRow.tsx
export default function ProductCategoryRow({ category }: { category: string }) {
return (
<tr>
<th colSpan={2}>{category}</th>
</tr>
);
}
/components/ProductRow.tsx
import Product from "../types/Product";
type ProductRowProps = {
product: Product,
};
export default function ProductRow({ product }: ProductRowProps) {
return (
<tr>
<td>
<span
style={{
color: product.stocked ? "#000" : "#F00",
}}
>
{product.name}
</span>
</td>
<td>{product.price}</td>
</tr>
);
}