레슨 9 / 12·3개 토픽
데이터 페칭과 폼 관리
fetch API로 데이터 불러오기
useEffect와 fetch API를 조합하면 컴포넌트가 마운트될 때 서버에서 데이터를 불러올 수 있습니다. 로딩, 에러, 성공 세 가지 상태를 관리하는 것이 핵심 패턴입니다.
tsx
import { useState, useEffect } from 'react';
interface Post {
id: number;
title: string;
body: string;
}
function PostList() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchPosts = async () => {
try {
setLoading(true);
setError(null);
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10');
if (!res.ok) throw new Error('데이터를 불러오지 못했습니다.');
const data: Post[] = await res.json();
setPosts(data);
} catch (err) {
setError(err instanceof Error ? err.message : '알 수 없는 오류');
} finally {
setLoading(false);
}
};
fetchPosts();
}, []);
if (loading) return <p>로딩 중...</p>;
if (error) return <p className="text-red-500">오류: {error}</p>;
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</li>
))}
</ul>
);
}커스텀 useFetch Hook
데이터 페칭 로직이 여러 컴포넌트에서 반복된다면 커스텀 Hook으로 추출하여 재사용할 수 있습니다.
tsx
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
setLoading(true);
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: T = await res.json();
setData(json);
setError(null);
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return;
setError(err instanceof Error ? err.message : '오류 발생');
} finally {
setLoading(false);
}
};
fetchData();
return () => controller.abort(); // 언마운트 시 요청 취소
}, [url]);
return { data, loading, error };
}
// 사용 예
function UserList() {
const { data: users, loading, error } = useFetch<User[]>('/api/users');
if (loading) return <p>로딩 중...</p>;
if (error) return <p>에러: {error}</p>;
return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}Controlled 폼 컴포넌트
React에서 폼은 각 입력 필드의 값을 state로 관리하는 Controlled 방식이 권장됩니다. value와 onChange를 짝지어 사용하며, 제출 시 e.preventDefault()로 기본 동작을 막습니다.
tsx
import { useState, FormEvent } from 'react';
interface FormData {
name: string;
email: string;
message: string;
}
interface FormErrors {
name?: string;
email?: string;
message?: string;
}
function ContactForm() {
const [form, setForm] = useState<FormData>({
name: '', email: '', message: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [submitting, setSubmitting] = useState(false);
const validate = (): FormErrors => {
const errs: FormErrors = {};
if (!form.name.trim()) errs.name = '이름을 입력하세요.';
if (!/^[^@]+@[^@]+\.[^@]+$/.test(form.email)) errs.email = '유효한 이메일을 입력하세요.';
if (form.message.length < 10) errs.message = '메시지는 10자 이상이어야 합니다.';
return errs;
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
// 입력 시 해당 필드 에러 초기화
setErrors(prev => ({ ...prev, [name]: undefined }));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const errs = validate();
if (Object.keys(errs).length > 0) {
setErrors(errs);
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (!res.ok) throw new Error('전송 실패');
alert('전송 완료!');
setForm({ name: '', email: '', message: '' });
} catch {
alert('오류가 발생했습니다.');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<input name="name" value={form.name} onChange={handleChange} placeholder="이름" />
{errors.name && <span className="text-red-500">{errors.name}</span>}
</div>
<div>
<input name="email" value={form.email} onChange={handleChange} placeholder="이메일" />
{errors.email && <span className="text-red-500">{errors.email}</span>}
</div>
<div>
<textarea name="message" value={form.message} onChange={handleChange} placeholder="메시지" />
{errors.message && <span className="text-red-500">{errors.message}</span>}
</div>
<button type="submit" disabled={submitting}>
{submitting ? '전송 중...' : '보내기'}
</button>
</form>
);
}💡
AbortController를 사용하면 컴포넌트가 언마운트될 때 진행 중인 fetch 요청을 취소할 수 있습니다. 메모리 누수와 상태 업데이트 경고를 방지하는 필수 패턴입니다.