2025.03.07 - [Programming Language/React] - [React] Design Pattern in React (1)
[React] Design Pattern in React (1)
리액트에서 일반적으로 자주 쓰이는 패턴이 뭐다 이렇게 정의할 정도로 아직은 깊이가 없지만,실무에서 흔히들 많이 쓴다는 패턴을 정리해보려고 한다. 다룰 패턴들은렌더 프롭스 / 훅스 / 프
juniortunar.tistory.com
이전 글에 이어,
이번엔 Hooks 패턴의 자주 쓰이는 용례들을 적어보고자 한다.
Hooks 패턴
Hooks는 React 16.8에서 도입된 기능으로, 함수형 컴포넌트에서 상태 관리와 생명주기 기능을 사용할 수 있게 해주는 패턴이다.
Render Props 패턴의 많은 사용 사례를 더 간결하고 직관적인 방식으로 대체할 수 있게 해준다.
1. 기본 구조
React에서 제공하는 기본 Hooks
function Counter() {
// 상태 관리
const [count, setCount] = useState(0);
// 부수 효과 처리
useEffect(() => {
document.title = `Count: ${count}`;
// 클린업 함수
return () => {
document.title = 'React App';
};
}, [count]); // 의존성 배열
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
2. 커스텀 Hooks
로직을 재사용 가능한 함수로 추출하는 패턴
// 커스텀 Hook 정의
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// 사용 예시
function CounterComponent() {
const { count, increment, decrement, reset } = useCounter(10);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}
3. 상태 공유
이전 Render Props 예제와 비교하여 Toggle 기능을 Hooks로 구현:
// Render Props 방식
interface ToggleProps {
children: (props: {
isOn: boolean;
toggle: () => void;
}) => React.ReactNode;
}
function Toggle({ children }: ToggleProps) {
const [isOn, setIsOn] = useState(false);
const toggle = () => setIsOn(!isOn);
return children({ isOn, toggle });
}
// Hooks 방식
function useToggle(initialState = false) {
const [isOn, setIsOn] = useState(initialState);
const toggle = useCallback(() => setIsOn(prev => !prev), []);
return [isOn, toggle] as const;
}
// 사용 예시
function ToggleButton() {
const [isOn, toggle] = useToggle();
return (
<button onClick={toggle}>
{isOn ? 'ON' : 'OFF'}
</button>
);
}
4. 데이터 페칭
API 호출 로직을 Hooks로 추출:
function useFetch<T>(url: string) {
const [state, setState] = useState<{
data: T | null;
loading: boolean;
error: Error | null;
}>({
data: null,
loading: true,
error: null
});
useEffect(() => {
// AbortController를 사용하여 요청 취소 관리
const controller = new AbortController();
const fetchData = async () => {
// 새 요청을 시작할 때 상태 재설정
setState(prev => ({ ...prev, loading: true }));
try {
const response = await fetch(url, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const json = await response.json();
setState({ data: json, loading: false, error: null });
} catch (err) {
// AbortError는 정상적인 취소이므로 에러로 처리하지 않음
if (err instanceof Error && err.name === 'AbortError') {
return;
}
setState({
data: null,
loading: false,
error: err instanceof Error ? err : new Error('Failed to fetch')
});
}
};
fetchData();
// 클린업 함수에서 요청 취소
return () => {
controller.abort();
};
}, [url]);
return state;
}
// 사용 예시
function UserProfile() {
const { data, loading, error } = useFetch<User>('/api/user/1');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return <div>No data</div>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
5. 조건부 렌더링
인증 상태 관리를 Hooks로 구현:
function useAuth() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkAuth = async () => {
try {
setLoading(true);
// 토큰 확인 로직
const token = localStorage.getItem('token');
if (token) {
// 사용자 정보 가져오기
const response = await fetch('/api/me', {
headers: { Authorization: `Bearer ${token}` }
});
const userData = await response.json();
setUser(userData);
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
setUser(null);
}
} catch (error) {
console.error('Auth check failed:', error);
setIsAuthenticated(false);
setUser(null);
} finally {
setLoading(false);
}
};
checkAuth();
}, []);
const login = async (credentials: { email: string; password: string }) => {
// 로그인 로직
// ...
};
const logout = () => {
localStorage.removeItem('token');
setIsAuthenticated(false);
setUser(null);
};
return { isAuthenticated, user, loading, login, logout };
}
// 사용 예시
function App() {
const { isAuthenticated, loading } = useAuth();
if (loading) return <div>Loading...</div>;
return isAuthenticated ? <Dashboard /> : <LoginForm />;
}
6. 에러 처리
에러 처리를 위한 커스텀 Hook:
function useErrorHandler() {
const [error, setError] = useState<Error | null>(null);
const handleError = useCallback((err: unknown) => {
if (err instanceof Error) {
setError(err);
} else {
setError(new Error(String(err)));
}
}, []);
const resetError = useCallback(() => {
setError(null);
}, []);
return { error, handleError, resetError };
}
// 사용 예시
function RiskyComponent() {
const { error, handleError, resetError } = useErrorHandler();
const doRiskyOperation = () => {
try {
// 위험한 작업
throw new Error('Something went wrong!');
} catch (err) {
handleError(err);
}
};
if (error) {
return (
<div className="error-container">
<h2>오류가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={resetError}>다시 시도</button>
</div>
);
}
return (
<button onClick={doRiskyOperation}>
위험한 작업 실행
</button>
);
}
Next.js에서 고려해야할 사항
1. 서버 컴포넌트와 클라이언트 컴포넌트 구분.
App Router에서는 모든 컴포넌트가 기본적으로 서버 컴포넌트이다.
Hooks를 사용하려면 명시적으로 'use client' 지시문을 추가해야한다.
서버 컴포넌트
// 서버 컴포넌트
import UserProfileClient from './UserProfileClient';
// 서버 사이드 데이터 페칭
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`, {
// Next.js 15의 캐싱 및 재검증 옵션
next: { revalidate: 60 } // 60초마다 재검증
});
if (!res.ok) throw new Error('Failed to fetch user');
return res.json();
}
export default async function UserPage({ params }: { params: { id: string } }) {
// 서버에서 데이터 페칭
const initialData = await getUser(params.id);
return (
<div className="container">
<h1>User Profile</h1>
{/* 초기 데이터를 클라이언트 컴포넌트에 전달 */}
<UserProfileClient initialData={initialData} userId={params.id} />
</div>
);
}
클라이언트 컴포넌트
'use client';
import { useState } from 'react';
import useFetch from '@/hooks/useFetch';
interface User {
id: string;
name: string;
email: string;
}
interface UserProfileClientProps {
initialData: User;
userId: string;
}
export default function UserProfileClient({ initialData, userId }: UserProfileClientProps) {
// 초기 데이터로 시작하고, 필요시 클라이언트에서 새로고침
const [shouldRefetch, setShouldRefetch] = useState(false);
// 초기에는 서버에서 가져온 데이터 사용, 새로고침 요청 시에만 클라이언트에서 페칭
const { data, loading, error } = shouldRefetch
? useFetch<User>(`/api/users/${userId}`)
: { data: initialData, loading: false, error: null };
if (loading) return <div>Refreshing...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return <div>No data</div>;
return (
<div className="user-profile">
<h2>{data.name}</h2>
<p>{data.email}</p>
<button onClick={() => setShouldRefetch(true)}>
Refresh Data
</button>
</div>
);
}
2. 서버 액션과 클라이언트 상태 통합
서버 액션을 활용하여 데이터 변경을 처리하는 예시
서버 컴포넌트
'use server';
import { revalidatePath } from 'next/cache';
export async function updateUserName(userId: string, newName: string) {
try {
const response = await fetch(`https://api.example.com/users/${userId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: newName }),
});
if (!response.ok) {
throw new Error('Failed to update user');
}
// 해당 경로의 캐시 무효화
revalidatePath(`/users/${userId}`);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
클라이언트 컴포넌트
'use client';
import { useState } from 'react';
import { updateUserName } from './actions';
export default function EditNameForm({ userId, currentName }: { userId: string, currentName: string }) {
const [name, setName] = useState(currentName);
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setStatus('submitting');
const result = await updateUserName(userId, name);
if (result.success) {
setStatus('success');
// 성공 메시지 표시 후 상태 리셋
setTimeout(() => setStatus('idle'), 3000);
} else {
setStatus('error');
setErrorMessage(result.error || 'Failed to update name');
}
}
return (
<form onSubmit={handleSubmit} className="edit-name-form">
<div className="form-group">
<label htmlFor="name">Name:</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={status === 'submitting'}
/>
</div>
<button
type="submit"
disabled={status === 'submitting' || name === currentName}
>
{status === 'submitting' ? 'Updating...' : 'Update Name'}
</button>
{status === 'success' && (
<p className="success-message">Name updated successfully!</p>
)}
{status === 'error' && (
<p className="error-message">Error: {errorMessage}</p>
)}
</form>
);
}
3. 라우팅과 Hooks 통합
정의부
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useCallback } from 'react';
export default function useNavigation() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// 페이지 이동
const navigate = useCallback((path: string) => {
router.push(path);
}, [router]);
// 쿼리 파라미터 가져오기
const getQueryParam = useCallback((key: string) => {
return searchParams.get(key);
}, [searchParams]);
// 쿼리 파라미터 설정 (App Router 방식)
const setQueryParam = useCallback((key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(key, value);
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}, [pathname, router, searchParams]);
// 여러 쿼리 파라미터 한번에 설정
const setQueryParams = useCallback((paramsObject: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(paramsObject).forEach(([key, value]) => {
params.set(key, value);
});
router.push(`${pathname}?${params.toString()}`, { scroll: false });
}, [pathname, router, searchParams]);
return {
currentPath: pathname,
navigate,
getQueryParam,
setQueryParam,
setQueryParams,
// 추가 유틸리티 메서드
back: router.back,
refresh: router.refresh
};
}
실행부
'use client';
import { useState, useEffect } from 'react';
import useNavigation from '@/hooks/useNavigation';
interface Product {
id: string;
name: string;
category: string;
price: number;
}
export default function FilterableProductList({ products }: { products: Product[] }) {
const { getQueryParam, setQueryParams } = useNavigation();
// URL에서 초기 필터 상태 가져오기
const initialCategory = getQueryParam('category') || 'all';
const initialSort = getQueryParam('sort') || 'name';
const [category, setCategory] = useState(initialCategory);
const [sortBy, setSortBy] = useState(initialSort);
// 필터 변경 시 URL 업데이트
useEffect(() => {
setQueryParams({
category,
sort: sortBy
});
}, [category, sortBy, setQueryParams]);
// 제품 필터링 및 정렬
const filteredProducts = products
.filter(product => category === 'all' || product.category === category)
.sort((a, b) => {
if (sortBy === 'price') return a.price - b.price;
return a.name.localeCompare(b.name);
});
return (
<div className="filterable-product-list">
<div className="filters">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
</select>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
>
<option value="name">Sort by Name</option>
<option value="price">Sort by Price</option>
</select>
</div>
<ul className="product-list">
{filteredProducts.map(product => (
<li key={product.id} className="product-item">
<h3>{product.name}</h3>
<p>Category: {product.category}</p>
<p>Price: ${product.price.toFixed(2)}</p>
</li>
))}
</ul>
{filteredProducts.length === 0 && (
<p className="no-results">No products found matching your criteria.</p>
)}
</div>
);
}
장점
- 간결성: Render Props보다 더 간결하고 직관적인 코드 작성 가능
- 재사용성: 로직을 쉽게 추출하고 재사용할 수 있음
- 테스트 용이성: 독립적인 함수로 분리되어 테스트하기 쉬움
- 타입 안전성: TypeScript와 잘 통합됨
- 컴포지션: 여러 Hooks를 조합하여 복잡한 로직 구성 가능
단점
- 규칙 제약: Hooks 사용 규칙(최상위에서만 호출, 조건부로 호출 불가 등)을 따라야 함
- 디버깅 어려움: 복잡한 Hooks 체인에서 문제 추적이 어려울 수 있음
- 의존성 관리: useEffect, useCallback, useMemo의 의존성 배열 관리가 복잡할 수 있음
- 클래스 컴포넌트 비호환: 클래스 컴포넌트에서는 사용할 수 없음
어느 때에 쓰는게 적합할까
- 상태 로직 재사용: 여러 컴포넌트에서 동일한 상태 로직을 사용해야 할 때
- 관심사 분리: UI와 비즈니스 로직을 명확히 분리하고 싶을 때
- 코드 간결화: Render Props나 HOC 패턴으로 인한 중첩을 줄이고 싶을 때
- 함수형 프로그래밍: 선언적이고 함수형 스타일의 코드를 선호할 때
- 테스트 용이성: 로직을 독립적으로 테스트하고 싶을 때
참고자료
React 공식 문서 - Hooks 소개
React 공식 문서 - 커스텀 Hooks 작성하기
Next.js 공식 문서 - 클라이언트 컴포넌트
프론트엔드의 복잡도가 높아질수록,
UI와 비즈니스 로직을 분리를 잘해야할 필요성이 증가되는데,
그걸 잘 관리하기 위해서는 커스텀 훅을 잘 활용해야 가독성과 유지보수가 높아지는 것 같다.