tags:
- TIL
- React
created: 2025-06-26
📘 2025-06-26 TIL
로그인 성공
→ 토큰 획득
→ Zustand 전역 상태 변경 (isLoggedIn: true)
→ localStorage 저장
→ axios Authorization 헤더에 자동 설정
const { success, error, loading, dismiss } = useAlert();
const { storeLogin } = useAuthStore();
const onSubmit = (data: LoginForm) => {
const toastId = loading('로그인 중입니다...');
login(data)
.then((res) => {
dismiss(toastId);
setToken(res.token); // localStorage 저장
storeLogin(res.token); // Zustand 상태 변경
success('로그인 완료되었습니다.');
navigate('/');
})
.catch((err) => {
dismiss(toastId);
error('로그인에 실패했습니다.');
});
};
import { create } from 'zustand';
import { getToken, setToken, removeToken } from '@/utils/token';
export const useAuthStore = create((set) => ({
isLoggedIn: !!getToken(),
token: getToken(),
storeLogin: (token) => {
set({ isLoggedIn: true, token });
setToken(token);
},
storeLogout: () => {
set({ isLoggedIn: false, token: null });
removeToken();
},
}));
import axios from 'axios';
import type { AxiosRequestConfig } from 'axios';
import { getToken, removeToken } from '@/utils/token';
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',
Authorization: getToken() ? getToken() : '',
},
withCredentials: true,
...config,
});
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// 401(인증 만료) 처리
if (error.response && error.response.status === 401) {
removeToken();
window.location.href = '/login';
return;
}
return Promise.reject(error);
}
);
return axiosInstance;
};
export const httpClient = createClient();
// src/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;
pub_date: string;
likes: number;
}
export interface Book {
id: number;
title: string;
author: string;
summary: string;
price: number;
likes: number;
img?: number;
}
// src/components/books/BooksList.tsx
import BookItem from './BookItem';
import type { Book } from '@/models/book.model';
function BooksList({ books }: { books: Book[] }) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{books.map(book => (
<BookItem key={book.id} book={book} />
))}
</div>
);
}
export default BooksList;
// src/components/books/BookItem.tsx
import { getImgSrc } from '@/utils/image';
import { formatNumber } from '@/utils/format';
import { Heart } from 'lucide-react';
import type { Book } from '@/models/book.model';
function BookItem({ book }: { book: Book }) {
return (
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
<div className="aspect-[3/4] bg-gray-100 rounded mb-3 flex items-center justify-center overflow-hidden">
<img
src={getImgSrc(book.id)}
alt={book.title}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 flex flex-col gap-1">
<div className="font-bold text-base truncate">{book.title}</div>
<div className="text-xs text-gray-500">{book.author}</div>
<div className="text-xs text-gray-400 truncate">{book.summary}</div>
</div>
<div className="flex items-center justify-between mt-3">
<span className="font-semibold text-sm">{formatNumber(book.price)}원</span>
<span className="text-xs flex items-center gap-1">
<Heart className="text-red-400 fill-red-400" size={16} />
{book.likes}
</span>
</div>
</div>
);
}
export default BookItem;
// src/utils/image.ts
export const getImgSrc = (id: number) => `https://picsum.photos/id/${id}/600/600`;
// src/utils/format.ts
export const formatNumber = (number: number): string => number.toLocaleString();
import { useCategory } from '@/hooks/useCategory';
import { useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/Button/Button';
function BooksFilter() {
const { categories } = useCategory();
const [searchParams, setSearchParams] = useSearchParams();
// 현재 선택된 카테고리 id
const currentCategoryId = searchParams.get('category_id');
const currentIdNum = currentCategoryId !== null ? Number(currentCategoryId) : -1;
// 카테고리 변경
const handleCategory = (id: number | null) => {
const newSearchParams = new URLSearchParams(searchParams);
if (id === -1 || id === null) {
newSearchParams.delete('category_id');
} else {
newSearchParams.set('category_id', id.toString());
}
setSearchParams(newSearchParams);
};
// 신간 필터 토글
const handleNews = () => {
const newSearchParams = new URLSearchParams(searchParams);
const current = newSearchParams.get('news') === 'true';
newSearchParams.set('news', (!current).toString());
setSearchParams(newSearchParams);
};
const newsActive = searchParams.get('news') === 'true';
return (
<div className="flex gap-2 items-center flex-wrap">
<div className="category">
{categories.map(item => (
<Button
size="md"
intent={currentIdNum === item.category_id ? 'primary' : 'ghost'}
key={item.category_id}
onClick={() => handleCategory(item.category_id)}
>
{item.category_name}
</Button>
))}
</div>
<div className="new">
<Button
size="md"
intent={newsActive ? 'primary' : 'ghost'}
onClick={handleNews}
>
신간
</Button>
</div>
</div>
);
}
export default BooksFilter;
• 쿼리스트링 view=grid 또는 view=list에 따라 UI가 유동적으로 변경됨
• BooksViewSwitcher 컴포넌트에서 버튼 클릭 시 URL 파라미터를 업데이트
• BooksList 컴포넌트는 location.search를 이용해 현재 뷰 모드를 감지하고 상태를 반영
// BooksViewSwitcher.tsx
const handleSwitch = (value: ViewMode) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set('view', value);
setSearchParams(newSearchParams);
};
useEffect(() => {
if (!searchParams.get('view')) {
handleSwitch('grid');
}
}, []);
// BooksList.tsx
const [view, setView] = useState<ViewMode>('grid');
useEffect(() => {
const value = new URLSearchParams(location.search).get('view');
if (value === 'grid' || value === 'list') setView(value);
}, [location.search]);
• 새롭게 알게 된 점
• 어렵게 느껴졌던 부분
• 다음에 학습할 주제