React LifeCycle
Dan Abramov가 2018년에 react lifecycle methods에 대한 다이어그램을 공유했습니다. React v16.x 이전에는 링크의 다이어그램에 더해서 componentWillMount, componentWillReceiveProps, 그리고 componentWillUpdate도 있었으나 async rendering에 대한 문제점이 발견되어서 deprecated 되었습니다. 혹자는 Function Component를 설명하기 위해서 Class Component의 lifecycle에 대응해서 설명하기도 하고, 혹자는 Class Component의 lifecycle을 잊어버리라고 말하기도 합니다. 양쪽 다 일리 있는 말씀(Hooks의 도입 이전에는 Class Component의 lifecycle이 전부였고, 빠른 이해를 위해서 대응을 하면 migration을 빠르게 진행할 수 있기 때문 vs Hooks가 만들어진 의도로는 cycle에 대해서 step by step으로 대응해서 동작하는 것이 아니라 특정 값, 혹은 상태를 관찰하고 그에 따라서 effects를 발생하는 것이기 때문)이라고 생각하며 시간이 허락하신다면 Class Component의 lifecycle을 읽어 보신 후에 각 생명주기를 대응하기보다는 비교하는 방식으로 진행하시는 것도 좋겠습니다.
Hook Flow
hook flow는 lifecycle diagram과 비슷하게 구성되어있습니다. 특정 값 혹은 상태에 입각해서 동작합니다. 소스 코드를 위에서 아래로 파싱 하며 진행하지만, hooks에 대해서는 사용하는 부분에 따라 다르게 동작합니다.
export const App: React.FC<Props> = ({ propValue }) => {
// 1
const value = 'myValue';
// 2
const [showChildren, setShowChildren] = useState<boolean>(false);
// 3
const [text, setText] = useState<string>(() => localStorage.getItem('text') || ''));
// 7
useEffect(() => {
console.log('with deps: ', propValue);
return(() => {
console.log('cleanup with deps');
}));
}, [propValue]);
// 6
useEffect(() => {
console.log('empty deps');
return(() => {
console.log('cleanup empty deps');
}));
}, []);
// 5
useEffect(() => {
console.log('no deps');
return(() => {
console.log('cleanup no deps');
}));
});
// 4
return (
<>
<button onClick={() => { setShowChildren(true) }}>{text}</button>
{showChildren && <MyComponent/>}
</>
);
}
declaration -> useState -> render function -> useEffect cleanup -> useEffect -> (If component is mounted) declaration ->... 와 같은 방식으로 동작하게 됩니다.
flow diagram의 useLayoutEffect는 브라우저가 화면을 그리기 전에 동작하며, useEffect가 비동기로 동작하는 것과 달리 해당 step을 순차적으로 진행하게 됩니다. 그래서 첫 화면 렌더링 이후 useEffect에 의해서 값이 변하는 것을 useLayoutEffect를 사용하는 것을 통해서 변한 값과 함께 렌더링을 진행하는 방식으로 우회할 수 있습니다. 다만 이 방식은 사용자에게 있어서 첫 화면을 보는 시간을 지연하기 때문에 꼭 필요한 경우이며 비용이 적은 경우 적용하는 것이 좋습니다. 연산 비용이 높은 상황에서 마크업이 크게 변하면서 깜빡거리는 경우는, 스켈레톤 UI를 적용하는 것을 고려해 볼 수 있습니다. (https://ui.toast.com/weekly-pick/ko_20201110). 서버사이드 렌더링의 경우는 이미 HTML을 서빙하고 있는 상황에서 react.hydrate를 진행하기 때문에 useEffect와 useLayoutEffect 어느 것도 자바스크립트가 모두 다운로드되기 전까지 실행되지 않습니다. 그렇기 때문에 서버에서 HTML을 보낼 때 effect 이후 교체의 대상이 되는 부분을 스켈레톤 UI를 적용하는 것을 통해 사용자에게 깨져 보일 수 있는 UI를 제공하지 않도록 방지할 수 있습니다. (https://reactjs.org/docs/hooks-reference.html#uselayouteffect)
Suspense(https://reactjs.org/docs/concurrent-mode-suspense.html)
스켈레톤 UI, 혹은 loading spinner를 적용하는 것에 있어서 React.lazy와 <Suspense>를 함께 사용한다면 useEffect를 사용해서 컴포넌트 내부에서 렌더링 직후 fetch 하는 방식의 약점을 보완할 수 있습니다. React v16.6에 추가된 기능이며 React stable v18.x 정식 문서로 추가될 기능으로, Suspense는 코드를 불러오는 동안 기다릴 수 있고, 기다리는 동안 로딩 상태를 선언적으로 지정할 수 있습니다.
const ProfilePage = React.lazy(() => import('./ProfilePage'));
<Suspense fallback={<LoadingSpinner />}>
<ProfilePage />
</Suspense>
Suspense 태그 하위의 요소가 렌더링을 완료하기 전까지 대기를 할 수 있게 합니다. 중요한 점은 Suspense는 데이터 fetch를 위한 용도가 아니라, 렌더링 완료를 관찰하고 완료에 대한 처리를 하기 위함입니다. 물론 그 기능 범주 내에 데이터 불러오기 라이브러리를 포함할 수 있기에 데이터 로드와 렌더의 기능을 우아하게 처리할 수 있습니다.
네트워크를 통해서 데이터를 가져오는 것은 결국 렌더링으로 귀결되는데, 컴포넌트 마운트에 대해서 기존에는 크게 두 가지 방식이 있었습니다. (말씀드릴 각각의 문제들을 해결 / 우회하며 vanilla로 구현하는 것도 한 방법입니다)
1. 첫 렌더링 이후 useEffect을 사용해서 data fetching을 하고, 그 결과를 re-render 하기
export const ProfilePage: React.FC = () => {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(u => setUser(u));
}, []);
if (user === null) {
return <p>Loading profile...</p>;
}
return (
<>
<h1>{user.name}</h1>
<ProfileTimeline />
</>
);
};
Profilepage 컴포넌트의 입장에서는 렌더링 이후 유저 정보를 가져와서 유저의 이름을 표기합니다.
function ProfileTimeline() {
const [posts, setPosts] = useState(null);
useEffect(() => {
fetchPosts().then(p => setPosts(p));
}, []);
if (posts === null) {
return <h2>Loading posts...</h2>;
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
};
ProfileTimeline 컴포넌트의 입장도 크게 다르지 않습니다. 컴포넌트 렌더링 이후 발송 정보를 가져와서 관련 정보를 표기합니다. 각각의 컴포넌트에 입장에서는 렌더링 이후 데이터를 로드해서 리렌더링을 진행하기에 매우 직관적이며 문제가 없어 보입니다. 그러나 ProfilePage가 ProfileTimeline 컴포넌트를 가지고 있기 때문에 최종적으로 사용자에게 데이터를 가지고 제공해야 하는 페이지를 그리는 시점이 늦어지게 됩니다. 그 이유는 (그럴리는 없겠지만) 데이터 로드에 걸리는 시간이 3초라고 가정을 했을 때, 발송 정보를 가져오는 것에 걸리는 시간은 상위 컴포넌트 렌더링 이후 3초, 즉 6초가 된다는 것입니다. 유저 정보를 토대로 발송 정보를 가져와야 하는 상황이었다면 어쩔 수 없지만, 독립적인 관계를 가지고 있을 경우에 더해서 마크업 구조가 포함 관계에 놓여 있다면 각각 독립적으로 호출하는 것은 불가능하기 때문에 waterfall로 작용하게 됩니다.
2. then chaining을 사용하는 방식과 비슷하게 데이터 로드 이후 첫 렌더링을 진행하기
function fetchProfileData() {
return Promise.all([
fetchUser(),
fetchPosts()
]).then(([user, posts]) => {
return {user, posts};
})
}
const promise = fetchProfileData();
function ProfilePage() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState(null);
useEffect(() => {
promise.then(data => {
setUser(data.user);
setPosts(data.posts);
});
}, []);
if (user === null) {
return <p>Loading profile...</p>;
}
return (
<>
<h1>{user.name}</h1>
<ProfileTimeline posts={posts} />
</>
);
}
function ProfileTimeline({ posts }) {
if (posts === null) {
return <h2>Loading posts...</h2>;
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
useEffect 훅 속에 promise.all을 호출하는 함수를 작성해서, 첫 화면 렌더링 이후 ProfileTimeline에 대해서는 같이 렌더링 할 수 있도록 처리할 수 있습니다. 해당 방식은 최종적인 화면을 제공하기까지의 시간을 최대한 단축하는 것처럼 보이지만 만약 fetchPosts()에 걸리는 시간이 fetchUser()에 걸리는 시간 대비 오래 걸린다면, promise.then은 모든 network call에 대해서 완료 이후 동작하기 때문에 상단의 유저 정보를 보는 것이 먼저 제공할 수 있음에도 불가능하게 됩니다. promise.all이 아닌 각각 데이터 로드를 호출하고, 각각의 then을 통해서 컴포넌트 렌더 시점을 결정하는 것으로 해결할 수 있지만 데이터와 컴포넌트 트리의 복잡도가 커짐에 따라 점점 더 수정이 어려워집니다.
2번 방식에 대해서 Suspense를 사용해서 직관성 있게 소스 코드를 작성할 수 있습니다. 데이터 로드 완료 시점을 기다리지 않고 렌더링을 진행하며 데이터 로드 자체도 병렬적으로 처리할 수 있습니다.
import React from "react";
import { graphql, usePreloadedQuery } from "react-relay";
const artistsQuery = graphql`
query ArtistQuery($artistID: String!) {
artist(id: $artistID) {
name
...ArtistDescription_artist
}
}
`;
const artistsQueryReference = loadQuery(
environment,
artistsQuery,
{artistId: "1"}
);
export default function ArtistPage() {
return (
<EnvironmentProvider environment={environment}>
<React.Suspense fallback={<LoadingIndicator />}>
<ArtistView />
</React.Suspense>
</EnvironmentProvider>
)
}
function ArtistView() {
const data = usePreloadedQuery(artistsQuery, artistsQueryReference);
return (
<>
<Name>{data?.artist?.name}</Name>
{data?.artist && <ArtistCard artist={data?.artist} />}
</>
);
}
Relay에서 제공하는 객체를 사용해서 데이터 로드가 완료되지 않았더라도 해당 정보를 읽기를 시도하게 됩니다. 불러온 데이터가 없어서 ArtistView 컴포넌트는 정지하고, 이에 대해서 Spinner가 렌더 되게 됩니다. 불러오기 이후 렌더링을 수행하는 것이 아니라 불러올 때에 렌더링을 수행하기 때문에 waterfall은 발생하지 않습니다. 그렇기 때문에 const artistsQueryReference처럼 렌더링을 수행하기 전에 불러오기를 먼저 발동시켜야 합니다.
추가적인 장점으로 데이터 로드 여부를 확인하는 if (something) 코드들을 걷어 낼 수 있어서 가독성 높은 소스 코드 작성이 가능합니다.
다만 최근까지도 수정이 이루어지고 있는 부분이며 useLayoutEffect와 같이 사용할 때 생기는 이슈들도 있었기에, 잘 적용하는 것이 필요하겠다는 생각이 들었습니다.
※ 잘못된 부분이나 궁금한 점 댓글로 작성해 주시면 최대한 답변하겠습니다. 읽어주셔서 감사합니다.
※ React Hooks #5 - Hooks(useRef)에서 dynamic form을 구성하는 방식을 함께 소개해 보겠습니다.
'React > Hooks' 카테고리의 다른 글
React Hooks #5 - Hooks(useRef) (0) | 2021.09.24 |
---|---|
React Hooks #3 - Hooks(useContext) (0) | 2021.08.28 |
React Hooks #2 - Hooks(useReducer) (0) | 2021.08.16 |
React Hooks #1 - Motivation (0) | 2021.07.04 |