toy project를 진행하고 있었는데
TypeError: Cannot read property 'map' of null
다음과 같은 에러를 생산...
해결하기 위해서 코드를 들여다보니 한 눈에 들어오지 않았다. useEffect에 문제가 있는 줄 알았다.
해결되지 않자 기본을 생각하게 되고 이유를 찾았다.
//정상 코드
function App() {
const [movies, setMovies] = useState([]);
const [error, setError] = useState(false);
useEffect(()=> {
const axiosMovies = async () => {
try {
setError(null);
const response = await axios.get(FEATURED_API);
setMovies(response.data.results);
}catch(e){
setError(e);
}
}
axiosMovies();
}, []);
if(error) return <div>catch error</div>;
return (
<div className="App">
{ movies.map(movie => (
<Movie key= {movie.id}{...movie}
/>
))}
</div>
);
}
//에러 코드
function App() {
const [movies, setMovies] = useState(null);
const [error, setError] = useState(false);
useEffect(()=> {
const axiosMovies = async () => {
try {
setError(null);
const response = await axios.get(FEATURED_API);
setMovies(response.data.results);
}catch(e){
setError(e);
}
}
axiosMovies();
}, []);
if(error) return <div>catch error</div>;
return (
<div className="App">
{ movies.map(movie => (
<Movie key= {movie.id}{...movie}
/>
))}
</div>
);
}
const [movies, setMovies] = useState(null);
위 코드는 초기화를 null로 하느냐 빈배열[]로 하느냐 차이로 실행여부가 갈린다.
useEffect보다 렌더링이 먼저 되기 때문이다. 그렇기 때문에 null인 상태에서 배열 메소드인 map을 실행시킬 수 없다고 에러가 나오는 것이다. 해결법은 간단하게 null대신 빈 배열 []을 사용하면 된다. 그러면 맨 처음에 잠깐 빈 화면을 띄우다가, 이펙트 함수가 실행되면서 결과물을 띄울 것이다.
원인을 알자 또 다른 해결방법도 생각해봤다.
if (!movies) return null;
에러코드에 위 코드를 컴포넌트 안에 넣어주면 []을 사용하지 않아도 가능했다.
{ movies.length > 0 && movies.map(movie => ( <Movie key= {movie.id}{...movie} /> ))}
조건부 렌더링을 사용해서 movies에 배열이 들어가면 렌더링이 되도록 설정해도 된다.
또 다른 질문이 생기는데, null과 [] 중 선택적으로 사용하는 경우가 있을까 싶다.
function App() {
const [movies, setMovies] = useState([]);
const axiosMovies = async (api) => {
await axios.get(api)
.then(response=>{
const fetchData = response.data.results;
console.log(fetchData); // 1번
setMovies(fetchData);
console.log(movies); // 2번
});
};
useEffect(()=> {
axiosMovies(FEATURED_API);
},[]);
console.log(movies); //3번
return (
<>
<div className="movie-container">
{ movies.map(movie => (
<Movie key= {movie.id}{...movie}
/>
))}
</div>
</>
);
}
export default App;
코드가 다음과 같을 때 각각 1,2,3번은 어떻게 출력될까?
맨 처음 렌더링되면서 3번이 출력되고 이펙트함수가 실행되
면서 1번, 그리고 state가 업데이트 됐으니 리렌더링되면
서 3번, 마지막으로 2번이 출력된다.
여기서 3번은 결과를 잘 받아와서 배열을 출력시키지만 2번은 여전히 빈 배열을 출력시킨다.
useEffect의 의존성배열 []은 이펙트 함수를 통해서는 state가 변경될 일이 없다고 명시하는 것과 같다. 그래서 다시는 useEffect의 이펙트함수가 실행되지는 않는다.
처음 실행된 결과는 movies에 []을 집어넣고 이 결과를 끝가지 갖고 있는다.(v1) 그렇기때문에 setMovies가 잘 작동하는 것과 상관없이 v1값인 []을 항상 출력시킨다.
useState의 클로저 영역문제도 있다. 값을 변경하기위해서는 이펙트 함수를 다시 실행시켜서 클로저 함수가 갖고 있는 값을 업데이트 시켜줘야한다.
useEffect(() => { // 예를들어 }, [movies]);
이런식으로 작성하면 movies 변경에 따라 이펙트가 실행되고 클로저 영역이 다시 지정되면서 업데이트가 가능할 것이다.
//똑같은 문제를 유발한다!
function App() {
const [movies, setMovies] = useState([]);
const [fetch, setFetch] = useState(1);
const axiosMovies = async (api) => {
await axios.get(api)
.then(response=>{
const fetchData = response.data.results;
const mergeData = movies.concat(fetchData);
setMovies([...mergeData]);
});
};
useEffect(()=> {
axiosMovies(FEATURE_API+fetch);
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
},[]);
const onScroll = () => {
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const clientHeight = document.documentElement.clientHeight;
if (scrollTop + clientHeight >= scrollHeight) {
setFetch(fetch + 1);
console.log(fetch);
}
}
console.log(fetch);
return (
<>
<div className="movie-container">
{ movies.map(movie => (
<Movie key= {movie.id}{...movie}
/>
))}
</div>
</>
);
}
export default App;
스크롤을 내리면 다음 페이지가 출력되게끔 작성할려고 했으나 당연히 막혔다. 이유는 useEffect에 의존성배열 때문이다.
역시 위 문제와 같은 이유다
그래서 []은 렌더링 버전마다 최신값임을 보장할 수 없다. 역시 log를 보면 (v1)렌더링에다가 v2, v3 ... 결과를 더하고 있었다.
const mergeData = movies.concat(fetchData); setMovies([...mergeData]);
setMovies(prevMovies => ([...prevMovies, ...pageData]));
위 코드 대신 아래 코드를 사용해도 가능하다. useState의클로저 문제로 effect내부에서 값을 읽을 수는 없지만 콜백함수를 이용해 값을 변경할 수는 있었다.
문제를 해결하며 []는 초기 목록을 불러오는데만 사용하고 다른 경우에는 절대 사용하면 안되겠다고 생각했다.
function App() {
const [movies, setMovies] = useState([]);
const [fetch, setFetch] = useState(1);
const axiosMovies = async (api) => {
await axios.get(api)
.then(response=>{
const fetchData = response.data.results;
const mergeData = movies.concat(fetchData);
setMovies([...mergeData]);
});
};
useEffect(()=> {
axiosMovies(FEATURE_API);
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
},[]);
useEffect(()=>{
axiosMovies(FEATURE_API+fetch);
},[fetch])
const onScroll = () => {
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = document.documentElement.scrollTop;
const clientHeight = document.documentElement.clientHeight;
if (scrollTop + clientHeight >= scrollHeight) {
setFetch(fetch + 1);
}
}
console.log(fetch); //1
return (
<>
<div className="movie-container">
{ movies.map(movie => (
<Movie key= {movie.id}{...movie}
/>
))}
</div>
</>
);
}
새로운 이펙트 함수를 만들며 문제를 해결했지만 살펴볼 부분이 있다.
setFetch(fetch + 1);
위 코드의 문제도 역시 최신 값을 보장할 수 없다는 것이다. 리액트는 state를 비동기적으로 업데이트한다. 그래서 state를 변경하고자 할 때 old value인 상황이 가능하다.
//1 로그를 보면 실제로도 계속 setFetch(1 + 1);을 반복한다.
해결하기 위해서는 함수형 업데이트를 사용하면 된다.
함수형 업데이트는 useCallback과 같은 원리로 최적화할 때 유용하다.
setCount(prevCount => prevCount + 1);
fetch를 useState로 관리하지않고 useRef로 관리해도 좋다.
const fetch = useRef(1);
로 지정하고 조회 및 수정은 fetch.current로 하면된다.
currnet의 뜻과 같이 항상 최신값을 기대할 수 있습니다.