함수형 컴포넌트를 사용하다보면, useState 를 통한 상태변화를 동기적으로 처리하고 싶을 때가 있다. 예를 들면 우선 데이터를 받아와 상태를 변경하고, 이 변경된 상태를 props 로 다른 컴포넌트에 넘겨준다던지, 상태변화과정을 순차적으로 진행해서 연결시키고자 할때 등등이 있다.
그리고 이러한 의도로 코드를 작성하였다가, 제대로 작성하지 않은 경우를 맞이하게 되는데, useState 는 비동기적으로 작동하기 때문이다. 직관적으로 상태가 변화하고, 그 변화된 상태 데이터를 컴포넌트에 반영하는것이 이해과정에서 편하지만, useState 가 비동기적으로 작동되어야 하는 이유가 있다. 실제 리엑트 공식문서에서도 동기적 사용을 위한 메서드를 제공하긴 하지만, 거의 절대적으로 이를 사용하지 말라고 경고한다. 그만큼 함수형 컴포넌트 설계에 적합하지 않다는 말이기도 하다.
우선 useState 를 동기적으로 작동시키는 방법에 대해서 포스팅을 참조하여 알아보고, 그럼에도 왜 useState 는 비동기적으로 처리되어야 하는지 탐구해보자.
https://dev.to/ysfaran/usestatewithpromise-a-custom-hook-to-await-state-updates-of-usestate-284o
useStateWithPromise: a custom hook to await state updates of useState
Originally posted on my blog. The Problem The general problem is that we want to wait...
dev.to
useState 를 동기적으로 활용해보기
아래 코드는 예시로서 API 서버로부터 articles 를 받아오며, reset 함수를 실행시킬 시, 기존 article 를 제거한 뒤, 다시 서버로부터 데이터를 받아오는 과정을 함수형 컴포넌트로 보여준다.
const FiltersSidebar = () => {
const [articles, setArticles] = useState([]);
const [filters, setFilters] = useState({});
const fetchArticles = async () => {
const fetchedArticles = await API.getArticles(filters);
setArticles(fetchedArticles);
};
const reset = () => {
setFilters({});
// uuhh, ouhh .. fetchArticles will use old state of "filters"
fetchArticles();
};
const setColorFilter = color => {
setFilters(currentFilters => ({ ...currentFilters, color }));
};
// more filters & return ..
};
여기서의 문제는 useState 가 비동기적으로 작동하기때문에, 기존 데이터를 초기화 하기 전 fetchArticles() 를 먼저 실행한다는 점에 있다. 이를 useEffect 를 통해서 해결할 수 있다.
const FiltersSidebar = () => {
const [articles, setArticles] = useState([]);
const [filters, setFilters] = useState({});
const resettingRef = useRef(false);
const fetchArticles = async () => {
const fetchedArticles = await API.getArticles(filters);
setArticles(fetchedArticles);
};
const reset = () => {
resettingRef.current = true;
setFilters({});
};
useEffect(() => {
if (resettingRef.current) {
resettingRef.current = false;
fetchArticles();
}
}, [filters]);
// ...
};
useRef 를 통해 기존 값을 기억하며, 기존 값이 true 인 경우 useEffect 를 통해 의존성 배열 filters 의 변화를 감지하여 컴포넌트를 업데이트 시킨다. 정상적으로 작동하지만 만약 filter 로직이 더 복잡해져서 이를 custom hook 으로 전환하는것이 더 효율적일것이라 판단이 든다 가정하겠다. 아마 아래와 같은 모습일 것이다.
const useStringFilter = (initialValue = "") => {
const [value, setValue] = useState(initialValue);
// maybe more complex stuff here
const reset = () => {
setValue(initialValue);
};
return {
value,
setValue,
reset
};
};
// and filters for other types like useDateFilter etc..
위 훅은 내부 메서드 reset 까지 반환하는 훅이며, 이를 적용하면 전체 코드는 아래와 같다.
const FiltersSidebar = () => {
const [articles, setArticles] = useState([]);
const resettingRef = useRef(false);
const colorFilter = useStringFilter();
const nameFilter = useStringFilter();
const releaseDateFilter = useDateFilter();
const fetchArticles = async () => {
const filters = {
color: colorFilter.value,
name: nameFilter.value,
releaseDate: releaseDateFilter.value
};
const fetchedArticles = await API.getArticles(filters);
setArticles(fetchedArticles);
};
const reset = () => {
colorFilter.reset(); // will trigger a state update inside of useStringFilter
nameFilter.reset(); // will trigger a state update inside of useStringFilter
releaseDateFilter.reset(); // will trigger a state update inside of useDateFilter
// fetchArticles will use old state of colorFilter, nameFilter and releaseDateFilter
fetchArticles();
};
// ...
};
다만 여기서 끝내면, useEffect 와 useRef 를 활용한 업데이트를 할 수 없으니, 이를 위한 커스텀 훅을 새로 생성하자.
useStateWithPromise 라는 새로운 훅을 생성하여 이 안에서 useEffect 와 useRef 를 통해서 업데이트 로직을 꾸려줄 것이다.
const useStateWithPromise = initialState => {
const [state, setState] = useState(initialState);
const resolverRef = useRef(null);
useEffect(() => {
if (resolverRef.current) {
resolverRef.current(state);
resolverRef.current = null;
}
/**
* Since a state update could be triggered with the exact same state again,
* it's not enough to specify state as the only dependency of this useEffect.
* That's why resolverRef.current is also a dependency, because it will guarantee,
* that handleSetState was called in previous render
*/
}, [resolverRef.current, state]);
const handleSetState = useCallback(
stateAction => {
setState(stateAction);
return new Promise(resolve => {
resolverRef.current = resolve;
});
},
[setState]
);
return [state, handleSetState];
};
(참고로 왜 useEffeect 의 의존성 배열에 useRef 값을 추가하였냐면, 아래 함수 handleSetState 가 호출되지 않았음에도, useEffect 가 상태 업데이트를 진행할 수 있기 때문이다. state 하나만 의존성 배열에 놔두게 되면. 따라서 ref 값을 의존성 배열에 추가하여, handleSetState 가 실행되었을 때 ref 값이 변경되니, 이에 따라 useEffect 역시 업데이트를 진행하게 된다.)
여기서 주목할 부분은 useStateWithPromise 가 Promise 를 반환한다는 점이다. 이렇게 되면 setter 역할을 하는 useState 인 handleSetstate 에 await 을 적용할 수가 있게 된다.
이 커스텀 훅을 기존에 만든 커스텀 훅에 대입시켜보자.
const useStringFilter = (initialValue = "") => {
const [value, setValue] = useStateWithPromise(initialValue);
const reset = () => {
// this will return a promise containing the updated state
return setValue(initialValue);
};
return {
value,
setValue,
reset
};
};
setValue 는 전 커스텀훅에서 살펴보았듯이 Promise 를 반환한다. 따라서 reset 의 반환값에 await 처리가 가능하다.
이제 전체 코드로 이동해보면
const FiltersSidebar = () => {
// ...
const reset = async () => {
// wait for all state updates to be completed
await Promise.all([
colorFilter.reset(),
nameFilter.reset(),
releaseDateFilter.reset()
]);
// fetchArticles will STILL use old state of colorFilter, nameFilter and releaseDateFilter
fetchArticles();
};
// ...
};
이렇게 reset 함수를 async 로 감싸준 뒤, await 를 통해 reset 의 반환값이 처리되기를 기다릴 수 있게 된다. 이 후 fetchArticles()를 동작시키면 마치 동기적으로 작동하듯이 의도한 대로 작동하게 된다.
다만 이를 그대로 실행시키면 fetchArticles 는 여전히 업데이트되지 않은 기존 상태를 참조하게 되는데, 함수형 컴포넌트가 어떻게 동작하는 지 생각해보면 알 수 있다. 자바스크립트 내 reset 함수는 단순히 함수 내부의 함수일 뿐이다. 따라서 매번 호출이 일어날 때마다 reset 은 새로운 레퍼런스를 가진 새로운 함수가 된다. Promise.all 을 통해 실행한 상태 업데이트를 await으로 모두 대기한 후에도, reset은 여전히 기존의 fetchArticles의 레퍼런스를 참조하게 된다.
(번역만으로는 잘 이해되지 않을 수 있어 다시 설명하자면, 함수가 실행될 때, 내부 변수의 할당값은 선언될 때 결정이 된다. 예를 들어서 전체 함수가 새로 랜더링 되었다고 해보자. 즉 함수가 다시 실행이 된 것이고, 이때 fetchArticels 의 내부 const filters 는 reset 이 실행되기 전 상태값을 할당하고 있다. 그러니깐 reset 함수를 통해 상태값을 다 '' 로 변경하였다고 해도, 실행되는 fetchArticles 함수는 기존의 상태값을 그대로 참조하고 있는 것이다. 그래서 기존에 적용된 필터값으로 다시 API 에 요청을 보내게 되는 오류가 발생하게 된다. 결론적으로 한번 더 함수를 랜더링 할 필요가 있는 것이다.
- 필터값을 적용하는 과정에서 함수가 랜더링 된다.
- 현재 필터값은 적용된 값
- 이에 따른 fetchArticles 내부 filters의 상태값 : 적용된 필터값
- reset 함수를 실행시켜 상태값을 전부 reset 시킨다 : 현재 상태값은 모두 ""
- 하지만 reset 함수 마지막 부분의 fetchArticles() 가 참조하는 상태값 : 적용된 필터값
- 따라서 적용된 필터값으로 API.getArticles 호출
- 이전과 같은 결과 도출
)
이를 해결하기 위해 useEffect 를 다시 적용시키자.
const FiltersSidebar = () => {
// ...
const [resetted, setResetted] = useState(false);
useEffect(() => {
if (resetted) {
fetchArticles();
setResetted(false);
}
}, [resetted]);
const reset = async () => {
await Promise.all([
colorFilter.reset(),
nameFilter.reset(),
releaseDateFilter.reset()
]);
setResetted(true);
};
// ...
};
상태값 resetted 를 추가하여, 변경에 따른 컴포넌트를 다시 업데이트 시킨다. reset 으로 인해 상태값은 모두 초기화가 되어있고, 이에 따라 초기값으로 API 요청이 들어가게 된다.
이 부분은 사실 함수형 컴포넌트가 추구하는 불변성에 적합한 로직은 아니다. 어쩌면 클래스 컴포넌트처럼 기존 상태를 참조할 수 있는 로직에 어울린다고 할 수 있다. 사실 위처럼 useEffect 를 통해서 결국 다시 업데이트를 해줘야 하는 것은, 함수형 컴포넌트가 기본적으로 상태값에 대한 불변성을 추구하기 때문이다. 그렇기에 여러버전의 상태값이 존재할 수 있다. 필터가 하나 변할 때마다 기존의 상태값이 변하는것이 아니라, 새로운 상태 객체가 생성되는 것이다. 이로 인해 이전 상태와 현재 상태를 비교할 수 있게 된다는 점에서, 위 fetchArtilces() 함수가 이전 버전의 상태값을 참조하는 문제가 발생하는 것이다.
이러한 문제로 위와 같이 동기적으로 setState 를 사용하려 할 시 문제점이 있으니 적용시키기 전에 스스로가 짠 로직이 잘못된 것은 아닌지 먼저 고민해보라고 Yusuf Aran 은 제안한다.
위와 같은 문제성을 지닌 것도 사실이지만, 사실 리엑트에서 useState 를 비동기적으로 처리하는 데는 더 핵심적인 이유가 존재한다.
상태는 비동기적으로 한번에 업데이트 되어야 한다.
공식 홈페이지에서 setState를 굳이 동기적으로 작동하게 하고 싶다면 flushSync() 함수 내부에 콜백으로 setState 를 발동시키면 된다. 이미 방법을 구현해놓았다.
// Force this state update to be synchronous.
flushSync(() => {
setCount(count + 1);
});
// By this point, DOM is updated.
다만 렌더링 성능에 너무나 취약하기에 공식문서에서 이를 사용할 때 신중하라고 경고하고 있다. 동기적으로 상태값이 업데이트 되는것에 어떠한 문제가 있기 때문일까?
예를 들어서 setState 가 동기적으로 작동이 되어, 매 업데이트 마다 DOM 을 업데이트하여 렌더한다고 가정해보자. 보통 리엑트로 설계를 하다보면 하나의 state 가 여러 컴포넌트의 props 로 전달되어 사용되는 경우가 많고, 상태의 업데이트는 곧 연관된 모든 컴포넌트를 업데이트 시킨다. 저번에 리엑트를 구현해보면서 알 수 있었던 점은, 가상돔을 생성하는 과정에서 재조정(reconciliation) 이 이루어지며, 이전 가상돔과 현 가상돔을 구별하여 변경된 부분을 파악한 다음, 실제 DOM 에 적용시키는 과정을 거치는 것을 알 수 있었다. 만약 아래와 같이 하나의 상태를 순차적으로 업데이트시킨다면 그만큼 재조정 과정을 순차적으로 진행하여야 한다.
const updateState = () => {
setState(count + 1);
console.log(count); // 1
setState(count + 1);
console.log(count); // 2
setState(count + 1);
console.log(count); // 3
}
즉 하나의 상태가 업데이트 되고, 재조정과정을 거친 뒤 화면이 랜더링된다. 동기적이니 이후 콘솔로그로 진행하여 1을 출력한다. 이후 다시 상태가 변경되고, 다시 랜더링 된 다음, 다음 콘솔이 찍혀 2가 출력되는 과정을 거치게 된다. 우리가 원하는것은 그저 이전 상태값에서 3이 증가하는 과정인데, 불필요하게 3번의 랜더링을 진행하게 된다.
만일 하나의 이벤트에서 변경되는 상태값이 여러개이며, 실제 같은 상태값이 아니기에 병합되는 과정조차 없다면(위 count 는 batching 을 통해 1의 증가로 병합될 수도 있다..) 각각 동기적으로 상태값 하나하나 랜더링 과정을 거치게 된다. 더 최악의 조건으로 각 상태값 하나당 10개의 컴포넌트가 공유하고 있다고 가정한다면, 과도한 랜더링을 유발하여 너무나도 비효율적인 랜더과정을 진행할 것이다.
실제로 기존 React 17 버전에서는 지금과같이 기본적으로 상태변화를 일괄처리하는것이 아니었다.
promise.then(() => {
// We're not in an event handler, so these are flushed separately.
this.setState({a: true}); // Re-renders with {a: true, b: false }
this.setState({b: true}); // Re-renders with {a: true, b: true }
this.props.setParentState(); // Re-renders the parent
});
랜더 과정이 동기적으로 이루어지고, 이에 따라 효율성이 많이 떨어지게 되어 현재 React18 에서는 기본적으로 상태값은 일괄적으로 처리하는 과정을 거치게 된다.
promise.then(() => {
// Forces batching
ReactDOM.unstable_batchedUpdates(() => {
this.setState({a: true}); // Doesn't re-render yet
this.setState({b: true}); // Doesn't re-render yet
this.props.setParentState(); // Doesn't re-render yet
});
// When we exit unstable_batchedUpdates, re-renders once
});
즉 위와 같은 과정을 우리가 직접 작성하지 않아도 자동적으로 React 가 알아서 감싸준다. 일괄적으로 처리하여 이전 DOM 과의 재조정 과정을 거치는것이 훨씬 효율적이기 때문이다.
참고로 상태값을 업데이트하는 과정이 비동기적이지만, 순차성은 지니고 있다. 즉 발생한 순서대로 병합이 된다고 생각하면 된다.
setValue({a: 10});
setValue({b: 20});
setValue({a: 30});
// value = { a: 30, b: 20 }
이 외에도 비동기적이며 병합적으로 상태값을 업데이트 하지만, 이전 상태값에 대한 업데이트를 순차적으로 이어가야 한다면,
setValue((prev) => prev + 1):
setValue((prev) => prev + 1):
setValue((prev) => prev + 1):
이렇게 내부 콜백함수로 이전 상태값을 가져오면서 처리할 수 있다. 순차성은 있으니 최종적으로 이전값에서 3이 더해지는 결과를 얻을 수 있다. 단순 상태값에 대한 동기적인 처리를 위해서라면 위처럼 콜백함수를 이용하는것이 가장 좋겠다.
결론적으로, useState 는 랜더링 과정의 효율성을 위해서라도 state 와 props 모두 reconciliation 의 과정을 모두 거친 뒤에야 업데이트 된다.
그렇기 때문에 왠만하면 위 포스팅처럼 useState 를 동기적으로 사용해야 하는 상황을 회피하고, 정 사용해야한다면 내부콜백함수나 useEffect 를 통해서 처리하도록 하자.
그럼에도 useEffect 사용을 최소화 하려면..
사실 useEffect 를 무분별하게 사용하는것을 지양하는 것이 현재 흐름인 것 같다. 실제 이에 대해 다룬 포스팅도 해외에서 소개되기도 하고 공식 홈페이지에서도 이에 대해 언급하는것 같다.
https://beta.reactjs.org/learn/you-might-not-need-an-effect
You Might Not Need an Effect
A JavaScript library for building user interfaces
beta.reactjs.org
useState 를 비동기적으로 사용하여야 하는 이유에 대해선 알 수 있었지만, 여전히 동기적으로 처리되어야 할 상황이 오곤 한다. 나 역시 filter 를 처리할 때나 fetch 를 통해 데이터를 반영해서 화면에 렌더링을 하여야 할 때 등등 여러 사례에서 useState 가 동기적으로 처리되기를 바란적이 있었다.
급한 경우라면 위처럼 useEffect 를 적용하는것이 정답으로 생각이 된다. 다만 조금 더 여유가 있다면 useEffect 를 활용하는 방법보다 함수 컴포넌트라는 성질(즉 함수의 성질)을 활용해서 useEffect 를 활용하지 않는 예시에 대해서 다음에 포스팅해보겠다. 실제로 도움을 많은 받았던 포스팅이라...
어찌되었던, 그냥 코드를 작성하는 것보다 처음 설계할때부터 로직을 잘 짜는게 중요하다는 점을 다시 한 번 느꼈다.
포스팅하기까지 참고한 글
https://github.com/facebook/react/issues/11527
RFClarification: why is `setState` asynchronous? · Issue #11527 · facebook/react
For quite a while I've tried to understood why setState is asynchronous. And failing to find an answer to it in the past, I came to the conclusion that it was for historical reasons and probabl...
github.com
Does React keep the order for state updates?
I know that React may perform state updates asynchronously and in batch for performance optimization. Therefore you can never trust the state to be updated after having called setState. But can you...
stackoverflow.com
https://ko.reactjs.org/docs/reconciliation.html
재조정 (Reconciliation) – React
A JavaScript library for building user interfaces
ko.reactjs.org
'Programing > React' 카테고리의 다른 글
useEffect을 사용하기 전 생각해봐야 할 상황들 (1) | 2023.03.12 |
---|---|
Redux의 비동기 처리 (feat. middleware chaining) (1) | 2023.02.25 |
Redux를 간략하게 구현해보자 (0) | 2023.02.01 |
Hook을 올바르게 사용하기 (0) | 2023.01.26 |
React는 어떻게 작동하는지 간단하게 구현해보자 (0) | 2023.01.26 |