많은 React 애플리케이션에서 흔히 발생하는 문제는 데이터 fetching, 상태 관리, 비즈니스 로직을 컴포넌트 안에 모두 넣어버리는 것이다.
다음과 같은 코드를 본 적 있을 것이다:
function UserProfile({ username }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${username}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, [username]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
);
}
이 방식은 다음 문제를 만든다:
이 구조의 문제:
로직 → Custom Hook으로 분리
UI → 순수 컴포넌트로 유지
장점:
// src/components/UserCard.tsx
import type { User, UserSearchItem } from "../types/User";
interface UserCardProps {
user: User | UserSearchItem;
onClick: () => void;
}
export const UserCard = ({ user, onClick }: UserCardProps) => {
return (
<div onClick={onClick}>
<img src={user.avatar_url} />
<h3>{"name" in user ? user.name : user.login}</h3>
<p>@{user.login}</p>
</div>
);
};
핵심:
// src/hooks/useSearchUsers.ts
import { useQuery } from "@tanstack/react-query";
import { githubApi } from "../services/githubApi";
export const useSearchUsers = (query: string) => {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["users", query],
queryFn: async () => {
const response = await githubApi.get(
`/search/users?q=${encodeURIComponent(query)}`
);
// 비즈니스 로직 (정렬)
return response.data.items.sort((a, b) => b.score - a.score);
},
enabled: query.length > 2,
staleTime: 5 * 60 * 1000,
});
return {
users: data || [],
isLoading,
error,
refetch,
};
};
핵심:
→ 모두 Hook 내부
// src/hooks/useHomePage.ts
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useSearchUsers } from "./useSearchUsers";
export const useHomePage = () => {
const [searchQuery, setSearchQuery] = useState("");
const { users, isLoading, error } = useSearchUsers(searchQuery);
const navigate = useNavigate();
const handleSelectUser = (username: string) => {
navigate(`/user/${username}`);
};
return {
searchQuery,
setSearchQuery,
users,
isLoading,
error,
handleSelectUser,
};
};
// src/pages/HomePage.tsx
import { useHomePage } from "../hooks/useHomePage";
export const HomePage = () => {
const {
searchQuery,
setSearchQuery,
users,
isLoading,
error,
handleSelectUser,
} = useHomePage();
return (
<div>
<input onChange={(e) => setSearchQuery(e.target.value)} />
{isLoading && <div>Loading...</div>}
{error && <div>Error</div>}
{users.map(user => (
<div key={user.login} onClick={() => handleSelectUser(user.login)}>
{user.login}
</div>
))}
</div>
);
};
// src/hooks/useFetchRepos.ts
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
export const useFetchRepos = (username: string) => {
const [sortCriteria, setSortCriteria] = useState("stars");
const { data, isLoading, error } = useQuery({
queryKey: ["repos", username],
queryFn: async () => {
const response = await fetch(`/users/${username}/repos`);
return response.json();
},
enabled: !!username,
});
const sortedRepos = data
? [...data].sort((a, b) => {
if (sortCriteria === "stars") {
return b.stargazers_count - a.stargazers_count;
}
return a.name.localeCompare(b.name);
})
: [];
return {
repos: data || [],
sortedRepos,
isLoading,
error,
sortBy: setSortCriteria,
};
};
Hook 테스트
import { renderHook } from "@testing-library/react";
test("useSearchUsers works", async () => {
const { result } = renderHook(() => useSearchUsers("react"));
});
컴포넌트 테스트
test("UserCard renders", () => {
render(<UserCard user={{ login: "test" }} onClick={() => {}} />);
});
Before
function UserProfile({ id }) {
useEffect(() => {
fetchUser(id);
}, []);
}
After
function useUser(id) {
return useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
});
}
→ 컴포넌트 = UI 전용
→ Hook = 로직 전용