다음 파트 2에서는 React Query 라이브러리를 활용하여 더 간결하고 효율적으로 무한 스크롤을 구현하는 방법을 알아볼 예정입니다.
전체 코드는 여기 에서 확인하실 수 있습니다.
posts
, page
, loading
, hasMore
네 가지 상태 변수를 사용합니다.import React, { useState, useEffect, useCallback } from 'react';
import { View, FlatList, Text, ActivityIndicator, StyleSheet, SafeAreaView } from 'react-native';
import axios from 'axios';
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
const PostsScreenWithAxios: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState<number>(1); // 현재 로드 '해야 할' 페이지
const [loading, setLoading] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(true);
// ... (fetchPosts, useEffect 수정, renderItem, renderFooter, handleLoadMore 구현) ...
};
// ... (스타일 정의) ...
const fetchPosts = useCallback(async () => {
// 로딩 중이거나 더 가져올 데이터가 없으면 실행하지 않음 (중복 방지)
if (loading || !hasMore) {
console.log(`fetchPosts skipped: loading=${loading}, hasMore=${hasMore}`);
return;
}
setLoading(true);
console.log(`Fetching page ${page}...`);
try {
// --- 로딩 확인용 1초 지연 ---
await new Promise(resolve => setTimeout(resolve, 1000));
// --------------------------
const response = await axios.get<Post[]>(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=5`
);
if (response.data.length > 0) {
setPosts((prevPosts) => [...prevPosts, ...response.data]); // 기존 게시글에 새 게시글 추가
setPage((prevPage) => prevPage + 1); // 다음 페이지 번호로 업데이트
} else {
setHasMore(false);
console.log('No more posts to load.');
}
} catch (error) {
console.error("데이터 로딩 중 오류 발생:", error);
} finally {
setLoading(false);
}
}, [page, loading, hasMore]); // page, loading, hasMore가 변경될 때 함수 재생성
useEffect
의 의존성 배열을 빈 배열 []
로 설정하여 마운트 시 1회만 실행되도록 합니다. useEffect(() => {
// 컴포넌트 마운트 시 첫 페이지 데이터 로딩 시작 (page가 1일 때)
console.log('useEffect for initial fetch triggered.');
fetchPosts();
}, []);
onEndReached
속성에 fetchPosts
함수를 연결하여 스크롤이 리스트 끝에 도달했을 때 데이터를 추가로 로드하도록 합니다. // 아이템 렌더링 함수
const renderItem = ({ item }: { item: Post }) => (
<View style={styles.itemContainer}>
<Text style={styles.itemTitle}>{item.id}. {item.title}</Text>
<Text style={styles.itemBody}>{item.body}</Text>
</View>
);
// 푸터 로딩 인디케이터
const renderFooter = () => {
return loading ? <ActivityIndicator size="large" color="#007AFF" style={styles.loader} /> : null;
};
return (
<SafeAreaView style={styles.container}>
<FlatList
data={posts}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
onEndReached={fetchPosts} // 스크롤 기반 로딩 트리거
onEndReachedThreshold={1} // 스크롤이 리스트 끝에 도달했을 때 호출되는 임계값 (0~1 사이) - 1이면 리스트 끝에 도달했을 때 호출됨
ListFooterComponent={renderFooter}
contentContainerStyle={styles.listContainer}
/>
</SafeAreaView>
);
// --- 스타일 정의 ---
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
listContainer: { paddingHorizontal: 15, paddingTop: 10, paddingBottom: 30 },
itemContainer: { backgroundColor: '#f9f9f9', padding: 15, marginBottom: 12, borderRadius: 8, borderWidth: 1, borderColor: '#eee', shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
itemTitle: { fontSize: 17, fontWeight: '600', marginBottom: 6, color: '#333' },
itemBody: { fontSize: 14, color: '#666', lineHeight: 20 },
loader: { marginVertical: 25, alignItems: 'center' },
});
export default PostsScreenWithAxios;
// PostsScreenWithAxios.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { View, FlatList, Text, ActivityIndicator, StyleSheet, SafeAreaView } from 'react-native';
import axios from 'axios';
// 게시글 데이터 타입 정의
interface Post {
userId: number;
id: number;
title: string;
body: string;
}
const PostsScreenWithAxios: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]); // 불러온 게시글 데이터 배열
const [page, setPage] = useState<number>(1); // 현재 로드 '해야 할' 페이지 번호
const [loading, setLoading] = useState<boolean>(false); // 데이터 로딩 중 여부
const [hasMore, setHasMore] = useState<boolean>(true); // 더 불러올 데이터가 있는지 여부
// 데이터 가져오기 함수 (useCallback 사용)
const fetchPosts = useCallback(async () => {
// 로딩 중이거나 더 가져올 데이터가 없으면 실행하지 않음
if (loading || !hasMore) {
console.log(`fetchPosts skipped: loading=${loading}, hasMore=${hasMore}`);
return;
}
setLoading(true); // 로딩 시작
console.log(`Fetching page ${page}...`);
try {
// --- 로딩 상태 확인을 위한 인위적 지연 추가 (1초) ---
await new Promise(resolve => setTimeout(resolve, 1000));
// -------------------------------------------------
const response = await axios.get<Post[]>(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`
);
if (response.data.length > 0) {
setPosts((prevPosts) => [...prevPosts, ...response.data]);
// 다음 페이지 번호로 업데이트
setPage((prevPage) => prevPage + 1);
} else {
setHasMore(false); // 더 이상 데이터 없음
console.log('No more posts to load.');
}
} catch (error) {
console.error("데이터 로딩 중 오류 발생:", error);
// 에러 처리 로직
} finally {
setLoading(false); // 로딩 종료
}
// useCallback의 의존성 배열: 함수 내에서 직접 사용하는 상태/프롭스
}, [page, loading, hasMore]);
// *** 핵심 수정: 초기 데이터 로딩 ***
useEffect(() => {
// 컴포넌트 마운트 시 첫 페이지 데이터 로딩 시작 (page가 1일 때)
console.log('useEffect for initial fetch triggered.');
fetchPosts();
// 의존성 배열을 빈 배열([])로 설정하여 마운트 시 오직 한 번만 실행
}, []); // 빈 배열로 변경!
// 아이템 렌더링 함수
const renderItem = ({ item }: { item: Post }) => (
<View style={styles.itemContainer}>
<Text style={styles.itemTitle}>{item.id}. {item.title}</Text>
<Text style={styles.itemBody}>{item.body}</Text>
</View>
);
// 푸터 로딩 인디케이터
const renderFooter = () => {
return loading ? <ActivityIndicator size="large" color="#007AFF" style={styles.loader} /> : null;
};
// 스크롤이 리스트 끝에 도달하면 호출될 함수
const handleLoadMore = () => {
console.log(`onEndReached triggered. Current state: loading=${loading}, hasMore=${hasMore}, page=${page}`);
// 로딩 중이 아니고, 더 가져올 페이지가 있을 때만 fetchPosts 호출
if (!loading && hasMore) {
console.log('handleLoadMore is calling fetchPosts...');
fetchPosts();
} else {
console.log('handleLoadMore determined not to call fetchPosts.');
}
};
return (
<SafeAreaView style={styles.container}>
<FlatList
data={posts}
renderItem={renderItem}
keyExtractor={(item) => item.id.toString()}
onEndReached={handleLoadMore} // 스크롤 기반 로딩 트리거
onEndReachedThreshold={0.5} // 언제 트리거할지 결정 (중요)
ListFooterComponent={renderFooter}
contentContainerStyle={styles.listContainer}
/>
</SafeAreaView>
);
};
// --- 스타일 정의 ---
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
listContainer: { paddingHorizontal: 15, paddingTop: 10, paddingBottom: 30 },
itemContainer: { backgroundColor: '#f9f9f9', padding: 15, marginBottom: 12, borderRadius: 8, borderWidth: 1, borderColor: '#eee', shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.1, shadowRadius: 2, elevation: 2 },
itemTitle: { fontSize: 17, fontWeight: '600', marginBottom: 6, color: '#333' },
itemBody: { fontSize: 14, color: '#666', lineHeight: 20 },
loader: { marginVertical: 25, alignItems: 'center' },
});
export default PostsScreenWithAxios;
FlatList
: https://reactnative.dev/docs/flatlist↗useEffect
훅 공식 문서 : https://react.dev/reference/react/useEffect↗useCallback
훅 공식 문서: https://react.dev/reference/react/useCallback↗