1. 상품 목록
🎯 상품 목록을 얻어서 표시하는 화면을 만들기
전자는 useFetchProducts 훅으로, 후자는 Products 컴포넌트로 구현하고, ProductListPage에서 이 둘을 조합
ProductListPage 구현
Copy // ProductListPage.tsx
import Products from '../components/product-list/Products';
import useFetchProducts from '../hooks/useFetchProducts';
export default function ProductListPage() {
// Todo 1. 상품 목록 얻기
const { products } = useFetchProducts();
// Todo 2. 화면에 보여주기
return (
<div>
<h2>Products</h2>
<Products products={products} />
</div>
);
}
useFetchProducts hook 생성
Copy // hooks/useFetchProducts.ts
const apiBaseUrl = 'https://shop-demo-api-01.fly.dev';
export default function useFetchProducts() {
type Data = {
products: ProductSummary[];
};
const { data } = useFetch<Data>(`${apiBaseUrl}/products`);
return {
products: data?.products ?? [],
loading: !data,
error,
};
}
BaseUrl은 추후 환경 변수로 분리 : 재활용과 유지보수가 쉬워짐
필요한 경우 loading과 error를 같이 내보낼 수 있음
Products 컴포넌트 구현
Product 컴포넌트 분리
Copy type ProductsProps = {
products: ProductSummary[];
}
export default function Products({ products }: ProductsProps) {
if (!products.length) {
return null;
}
return (
<Container>
<ul>
{products.map((product) => (
<li key={product.id}>
<Link to={`/products/${product.id}`}>
<Product product={product} />
</Link>
</li>
))}
</ul>
</Container>
);
}
언어에 맞는 숫자 서식을 지원하는 객체의 생성자
숫자를 읽기 좋게 보여주도록 numberFormat 유틸리티 함수를 준비
간단한 유틸 함수이기 때문에 테스트도 같이 작성
Copy export default function numberFormat(value: number) {
return new Intl.NumberFormat().format(value);
}
상품 목록을 Store로 관리
Copy // stores/ProductsStore.ts
const apiBaseUrl = '...';
@singleton()
@Store()
export default class ProductsStore {
products: ProductSummary[] = [];
async fetchProducts() {
this.setProducts([]);
const { data } = await axios.get(`${apiBaseUrl}/products`);
const { products } = data;
this.setProducts(products);
}
@Action()
setProducts(products: ProductSummary[]) {
this.products = products;
}
}
tsconfig.json 파일에 decorator 설정 주석 해제
useFetchProducts hook 변경
Copy export default function useFetchProducts(): {
products: ProductSummary[];
} {
const store = container.resolve(ProductsStore);
const [{ products }] = useStore(store);
useEffectOnce(() => {
store.fetchProducts();
});
return { products };
}
2. 카테고리 목록
🎯 헤더에서 카테고리 목록 을 표시하기
Copy export default function Header() {
const { categories } = useFetchCategories();
return (
<Container>
<h1>Shop</h1>
<nav>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/products">Products</Link>
{!!categories.length && (
<ul>
{categories.map((category) => (
<li key={category.id}>
<Link to={`/products?categoryId=${category.id}`}>
{category.name}
</Link>
</li>
))}
</ul>
)}
</li>
<li>
<Link to="/cart">Cart</Link>
</li>
</ul>
</nav>
</Container>
);
}
useFetchCategories hook 생성
hooks/useFetchCategories.ts
useFetchProducts 복사해서 수정하면 편리
Store 구현
Copy // stores/CategoriesStore.ts
@singleton()
@Store()
export default class CategoriesStore {
categories: Category[] = [];
async fetchCategories() {
this.setCategories([]);
const categories = await apiService.fetchCategories();
this.setCategories(categories);
}
@Action()
setCategories(categories: Category[]) {
this.categories = categories;
}
}
ApiService 파일 분리
services 폴더에 API 호출을 모아주는 ApiService 파일을 생성
API의 base URL 을 지정하기 위해 환경변수를 활용
Copy const API_BASE_URL = process.env.API_BASE_URL || 'https://...';
export default class ApiService {
private instance = axios.create({
baseURL: API_BASE_URL,
});
async fetchCategories(): Promise<Category[]> {
const { data } = await this.instance.get('/categories');
const { categories } = data;
return categories;
}
async fetchProducts(): Promise<ProductSummary[]> {
const { data } = await this.instance.get('/products');
const { products } = data;
return products;
}
}
export const apiService = new ApiService();
3. 카테고리별 상품 목록
🎯 카테고리 클릭 시 해당 카테고리 상품 보여주기
처음부터 고민해서 바로 만들어도 되고, 일단 만들고 고쳐나가도 됨
테스트 코드를 작성하면서 하면 오류를 줄일 수 있음
useSearchParams
현재 위치에 대한 URL의 쿼리 문자열을 읽고 수정하는 데 사용
React의 useState와 비슷하게, 현재 위치의 검색 매개변수 와 이를 업데이트하는 데 사용하는 함수 의 배열을 반환
Copy // 참고
window.location.search
ProductListPage에서 categoryId 얻어오기
Copy export default function ProductListPage() {
const [params] = useSearchParams();
const categoryId = params.get('categoryId') ?? undefined;
const { products } = useFetchProducts({ categoryId });
return (
<div>
<h2>Products</h2>
<Products products={products} />
</div>
);
}
Id가 없으면 null을 반환하는데, 여기서는 일부러 undefined 사용(있을 수도, 없을 수도 있음)
카테고리 ID를 쓰도록 hook 변경
Copy export default function useFetchProducts({ categoryId }: {
categoryId: string;
}): {
products: ProductSummary[];
} {
const store = container.resolve(ProductsStore);
const [{ products }] = useStore(store);
useEffect(() => {
store.fetchProducts({ categoryId });
}, [store, categoryId]);
return { products };
}
카테고리 ID가 바뀔 때마다 리렌더되야 하므로 useEffect
를 사용
useFetchCategories 도 useEffect
를 사용하고 의존성 배열에 store 추가
Store 변경
Copy async fetchProducts({ categoryId }: {
categoryId?: string;
}) {
this.setProducts([]);
const products = await apiService.fetchProducts({ categoryId });
this.setProducts(products);
}
API Service 변경
Copy export default class ApiService {
async fetchProducts({categoryId}: {
categoryId?: string;
} = {}): Promise<ProductSummary[]> {
const {data} = await this.instance.get('/products', {
params: {categoryId},
});
const {products} = data;
return products;
}
}