2024년 11월 13일
React Router를 사용
설치
라우터 구성
라우터 사용
// App.tsx
import Layout from './components/layout/Layout';
import Home from './pages/Home';
import ThemeSwitcher from './components/header/ThemeSwitcher';
import { BookStoreThemeProvider } from './context/themeContext';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Error from './components/common/Error';
const router = createBrowserRouter([
{
path: "/",
element: <Layout><Home /></Layout>,
errorElement: <Error />
},
{
path: "/books",
element: <Layout><div>도서 목록</div></Layout>
}
]);
function App() {
return(
<BookStoreThemeProvider>
<ThemeSwitcher />
<RouterProvider router={router} />
</BookStoreThemeProvider>
);
}
export default App;
// components / common / Error.tsx
import { useRouteError } from 'react-router-dom';
interface RouteError {
statusText ?: string;
message?: string;
}
const Error = () => {
const error = useRouteError() as RouteError;
return(
<div>
<h1>오류가 발생했습니다.</h1>
<p>다음과 같은 오류가 발생했습니다.</p>
<p>{error.statusText || error.message}</p>
</div>
)
}
export default Error;
// components / common / Header.tsx
... 생략 ...
const Header = () => {
return (
<HeaderStyle>
<h1 className="logo">
<Link to="/">
<img src={logo} alt="book store"/ >
</Link>
</h1>
<nav className="category">
<ul>
{
CATEGORY.map((item) => (
<li key={item.id}>
<Link to={item.id === null ? '/books' : `/books?category_id=${item.id}`}>
{item.name}
</Link>
</li>
))
}
</ul>
</nav>
... 생략 ...
프로젝트 모델 정의
주요 모델
모델 정의
// models / user.model.ts
export interface User {
id: number;
email: string;
}
// models / category.model.ts
export interface Category {
category_id: number | null;
category_name: string;
}
// models / book.model.ts
export interface Book {
id: number;
title: string;
img: number;
category_id: number;
form: string;
isbn: string;
summary: string;
detail: string;
author: string;
pages: number;
contents: string;
price: number;
likes: number;
pubDate: string;
}
export interface BookDetail extends Book {
categoryName: string;
liked: boolean;
}
// models / cart.model.ts
export interface Cart{
id: number;
bookId: number;
title: string;
summary: string;
quantity: number;
price: number;
}
// models / order.model.ts
export interface Order {
id: number;
createdAt: string;
address: string;
receiver: string;
contact: string;
totalQuantity: number;
totalPrice: number;
}
// models / pagination.model.ts
export interface Pagination {
currentPage: number;
totalCount: number;
}
API 통신 모듈
category fetch
axios 사용 예정(설치 : npm install axios —save)
http 공통 모듈 클라이언트 작성
// api / http.ts
import axios, { AxiosRequestConfig } from 'axios';
const BASE_URL = "http://localhost:9999";
const DEFAULT_TIMEOUT = 30000;
export const createClient = (config?: AxiosRequestConfig) => {
const axiosInstance = axios.create({
baseURL : BASE_URL,
timeout : DEFAULT_TIMEOUT,
headers : {
"content-type" : "application/json"
},
withCredentials: true,
...config,
});
axiosInstance.interceptors.response.use(
(response) => { return response},
(error) => {return Promise.reject(error)}
);
return axiosInstance;
};
export const httpClient = createClient();
category api
// api / category.api.ts
import { Category } from '../models/category.model';
import { httpClient } from './http';
export const fetchCategory = async () => {
const response = await httpClient.get<Category[]>('/category');
return response.data;
}
Header에 적용하기
// components / common / Header
import { styled } from 'styled-components';
import logo from "../../assets/logo.png";
import { FaSignInAlt, FaRegUser } from "react-icons/fa";
import { Link } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Category } from '../../models/category.model';
import { fetchCategory } from '../../api/category.api';
const Header = () => {
const [category, setCategory] = useState<Category[]>([]);
useEffect(() =>{
fetchCategory().then((category) => {
setCategory(category);
});
}, []);
return (
<HeaderStyle>
<h1 className="logo">
<Link to="/">
<img src={logo} alt="book store"/ >
</Link>
</h1>
<nav className="category">
<ul>
{
category.map((item) => (
<li key={item.category_id}>
<Link to={item.category_id === null ? '/books' : `/books?category_id=${item.category_id}`}>
{item.category_name}
</Link>
</li>
))
}
</ul>
</nav>
... 생략 ...
header 컴포넌트는 렌더 역할을 하는데 장황한 코드가 많고, 매번 카테고리를 가져와야 하는 불편함이 있다. header 뿐만 아니라 category가 필요한 곳은 여러곳인데 매번 useState를 써서 fetch 해야하는 것은 번거러움 → 커스텀 훅 작성
커스텀 훅 생성
// hooks / useCategory.ts
import { useState, useEffect } from 'react';
import { Category } from '../models/category.model';
import { fetchCategory } from '../api/category.api';
export const useCategory = () => {
const [category, setCategory] = useState<Category[]>([]);
useEffect(() =>{
fetchCategory().then((category) => {
if(!category) return;
const categoryWithAll = [
{
category_id: null,
category_name: '전체',
},
...category,
]
setCategory(categoryWithAll);
});
}, []);
return { category };
}
// components / common / Header.tsx
import { styled } from 'styled-components';
import logo from "../../assets/logo.png";
import { FaSignInAlt, FaRegUser } from "react-icons/fa";
import { Link } from 'react-router-dom';
import { useCategory } from '../../hooks/useCategory';
const Header = () => {
const { category } = useCategory();
return (
<HeaderStyle>
<h1 className="logo">
<Link to="/">
<img src={logo} alt="book store"/ >
</Link>
</h1>
<nav className="category">
<ul>
{
category.map((item) => (
<li key={item.category_id}>
<Link to={item.category_id === null ? '/books' : `/books?category_id=${item.category_id}`}>
{item.category_name}
</Link>
</li>
))
}
</ul>
</nav>
... 생략 ...
// pages / Signup.tsx
import styled from 'styled-components';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link } from 'react-router-dom';
import { useState } from 'react';
const Signup = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
console.log(email, password);
}
return (
<>
<Title size="large">회원가입</Title>
<SignupStyle>
<form onSubmit={handleSubmit}>
<fieldset>
<InputText placeholder='이메일' inputType='email' value={email} onChange={(e) => setEmail(e.target.value)} />
</fieldset>
<fieldset>
<InputText placeholder='비밀번호' inputType='password' value={password} onChange={(e) => setPassword(e.target.value)} />
</fieldset>
<fieldset>
<Button type="submit" size="medium" scheme="primary">
회원가입
</Button>
</fieldset>
<div className="info">
<Link to="/reset">비밀번호 초기화</Link>
</div>
</form>
</SignupStyle>
</>
)
}
const SignupStyle = styled.div`
max-width: ${({theme}) => theme.layout.width.small};
margin: 80px auto;
fieldset {
border: 0;
padding: 0 0 8px 0;
.error-text {
color: red;
}
}
input {
width: 100%;
}
button {
width: 100%;
}
.info {
text-align: center;
padding: 16px 0 0 0;
}
`;
export default Signup;
// components / common / InputText.tsx
import React, { ForwardedRef } from 'react';
import styled from 'styled-components';
interface Props extends React.InputHTMLAttributes<HTMLInputElement>{
placeholder?: string;
inputType?: "text" | "email" | "password" | "number";
}
const InputText = React.forwardRef(({ placeholder, inputType, onChange, ...props }: Props, ref: ForwardedRef<HTMLInputElement>) => {
return(
<InputTextStyle placeholder={placeholder} ref={ref} type={inputType} onChange={onChange} {...props} />
);
})
... 생략 ...
// components / common / Button.tsx
import { styled } from 'styled-components'
import { ButtonScheme, ButtonSize } from '../../style/theme';
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement>{
... 생략 ...
// App.tsx
import Layout from './components/layout/Layout';
import Home from './pages/Home';
import ThemeSwitcher from './components/header/ThemeSwitcher';
import { BookStoreThemeProvider } from './context/themeContext';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Error from './components/common/Error';
import Signup from './pages/Signup';
const router = createBrowserRouter([
{
path: "/",
element: <Layout><Home /></Layout>,
errorElement: <Error />
},
{
path: "/books",
element: <Layout><div>도서 목록</div></Layout>,
errorElement: <Error />
},
{
path: "/signup",
element: <Layout><Signup /></Layout>,
}
]);
... 생략 ...
고민이 되는 것 : 필드의 개수만큼 상태의 onChange가 많아질 것임 → 상태 관리 힘들어짐
React Hook Form : 리액트의 폼과 validation에 특화된 라이브러리
// pages / Signup.tsx
import { useForm } from 'react-hook-form';
import styled from 'styled-components';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link } from 'react-router-dom';
import { useState } from 'react';
import { signUp } from '../api/auth.api';
export interface SignupProps {
email : string;
password : string;
}
const Signup = () => {
// const [email, setEmail] = useState("");
// const [password, setPassword] = useState("");
// const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
// event.preventDefault();
// console.log(email, password);
// }
const { register, handleSubmit, formState: {errors} } = useForm<SignupProps>();
const onSubmit = (data: SignupProps) => {
signUp(data).then((res) => {
// 성공
window.alert('회원 가입이 완료되었습니다.');
})
};
return (
<>
<Title size="large">회원가입</Title>
<SignupStyle>
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset>
<InputText placeholder='이메일' inputType='email' {...register("email", { required: true })} />
{errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
</fieldset>
<fieldset>
<InputText placeholder='비밀번호' inputType='password' {...register("password", { required: true })} />
{errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
</fieldset>
<fieldset>
<Button type="submit" size="medium" scheme="primary">
회원가입
</Button>
</fieldset>
<div className="info">
<Link to="/reset">비밀번호 초기화</Link>
</div>
</form>
</SignupStyle>
</>
)
}
const SignupStyle = styled.div`
max-width: ${({theme}) => theme.layout.width.small};
margin: 80px auto;
fieldset {
border: 0;
padding: 0 0 8px 0;
.error-text {
color: red;
}
}
input {
width: 100%;
}
button {
width: 100%;
}
.info {
text-align: center;
padding: 16px 0 0 0;
}
`;
export default Signup;
// api / auth.api.ts
import { SignupProps } from '../pages/Signup';
import { httpClient } from './http';
export const signUp = async (userData:SignupProps) => {
const response = await httpClient.post("/users/join", userData);
return response.data;
}
// pages / Signup.tsx
import { useForm } from 'react-hook-form';
import styled from 'styled-components';
import Title from '../components/common/Title';
import InputText from '../components/common/InputText';
import Button from '../components/common/Button';
import { Link, useNavigate } from 'react-router-dom';
import { signUp } from '../api/auth.api';
import { useAlert } from '../hooks/useAlert';
export interface SignupProps {
email : string;
password : string;
}
const Signup = () => {
const navigate = useNavigate();
const showAlert = useAlert();
const { register, handleSubmit, formState: {errors} } = useForm<SignupProps>();
const onSubmit = (data: SignupProps) => {
signUp(data).then((res) => {
// 성공
showAlert("회원 가입이 완료되었습니다.");
navigate("/login");
})
};
return (
<>
<Title size="large">회원가입</Title>
<SignupStyle>
<form onSubmit={handleSubmit(onSubmit)}>
<fieldset>
<InputText placeholder='이메일' inputType='email' {...register("email", { required: true })} />
{errors.email && <p className="error-text">이메일을 입력해주세요.</p>}
</fieldset>
<fieldset>
<InputText placeholder='비밀번호' inputType='password' {...register("password", { required: true })} />
{errors.password && <p className="error-text">비밀번호를 입력해주세요.</p>}
</fieldset>
<fieldset>
<Button type="submit" size="medium" scheme="primary">
회원가입
</Button>
</fieldset>
<div className="info">
<Link to="/reset">비밀번호 초기화</Link>
</div>
</form>
</SignupStyle>
</>
)
}
const SignupStyle = styled.div`
max-width: ${({theme}) => theme.layout.width.small};
margin: 80px auto;
fieldset {
border: 0;
padding: 0 0 8px 0;
.error-text {
color: red;
}
}
input {
width: 100%;
}
button {
width: 100%;
}
.info {
text-align: center;
padding: 16px 0 0 0;
}
`;
export default Signup;
// hooks / useAlert.ts
import { useCallback } from 'react'
export const useAlert = () => {
const showAlert = useCallback((message: string) => {
window.alert(message);
}, []);
return showAlert;
}
프론트 서버는 localhost:3000 이고,
백엔드 서버는 localhost:9999 이어서 발생하는 문제 였음 -> CORS 문제라고함!
// 백엔드 서버쪽
$ npm install cors
// app.js
// express 모듈
const express = require("express");
const app = express();
const cors = require("cors");
// dotenv 모듈
const dotenv = require("dotenv");
dotenv.config();
app.listen(process.env.PORT);
const userRouter = require("./routes/users");
const bookRouter = require("./routes/books");
const categoryRouter = require("./routes/category");
const likeRouter = require("./routes/likes");
const cartRouter = require("./routes/carts");
const orderRouter = require("./routes/orders");
app.use(cors({ origin: "http://localhost:3000", credentials: true }));
app.use("/users", userRouter);
app.use("/books", bookRouter);
app.use("/category", categoryRouter);
app.use("/likes", likeRouter);
app.use("/carts", cartRouter);
app.use("/orders", orderRouter);
참고 자료(다시 읽어보자!): https://velog.io/@seungchan__y/CORS-%EC%97%90%EB%9F%AC%EC%99%80-%ED%95%B4%EA%B2%B0%EB%B2%95
https://coding-maggot.tistory.com/100
메모이제이션 기법으로 컴포넌트 성능을 최적화 시켜주는 도구
참고 자료(다시 읽어보자!): https://velog.io/@hjthgus777/React-%EB%8B%A4%EC%8B%9C-%ED%95%9C%EB%B2%88-useCallback%EC%9D%84-%ED%8C%8C%ED%97%A4%EC%B3%90%EB%B3%B4%EC%9E%90