setState 동기적으로 사용하기

장동균·2022년 3월 7일
4

https://dev.to/bytebodger/synchronous-state-with-react-hooks-1k4f

해당 글을 번역한 내용입니다.


setState, 상태값 변경 함수는 비동기적으로 동작한다. 리액트는 효율적으로 렌더링하기 위해 여러 개의 상태값 변경 요청을 배치로 처리한다. 리액트가 상태값 변경 함수를 동기로 처리하면 하나의 상태값 변경 함수가 호출될 때마다 화면을 다시 그리기 때문에 성능 이슈가 생길 수 있다. 만약 동기로 처리하지만 매번 화면을 다시 그리지 않는다면 UI 데이터와 화면 간의 불일치가 발생해서 혼란스러울 수 있다.

이러한 비동기적인 특징 때문에 우리가 의도한 결과와는 다른 결과물이 나올 수 있다. 이를 피하기 위해서는 setState를 동기적으로 동작시킬 수 있어야 한다.

가장 간단한 방법은 이전 값을 받아서 실행하는 방법이다. ex) setState(prev => prev + 1)

하지만, 더욱 복잡한 로직에서는 단순히 이전 값을 받아서 실행하는 것으로 해결할 수 없는 경우들이 있다.


문제 상황

export default function App() {
  const [memberId, setMemberId] = useState('');
  const [validateEntireForm, setValidateEntireForm] = useState(false);

  const updateMemberId = ({ value }) => {
    // (1)
    setMemberId(value);
    // (2)
    validateMemberId();
    if (validateEntireForm)
      validateForm();
  }

  const validateForm = () => {
    if (!validateEntireForm)
      setValidateEntireForm(true);
    validateMemberId();
    // validate the rest of the fields in the form  
  }

  const validateMemberId = () => {
    if (memberId.length === 5) {
      return validOrNot;
    }
  }

  return (
    <>
    	<input onChange={updateMemberId} />
    </>
  );
  1. input에 텍스트 입력시 updateMerberId 함수가 동작하면서 memberId 값이 설정된다. 이후 validateMemberId 함수 호출
  2. validateMemberIdmemberId 값을 확인하게 된다.

이때 memberId의 값은 setMemberId()가 호출되기 이전인 (1)의 상태일까? 아니면 setMemberId()가 호출된 이후인 (2)의 상태일까?

setState 함수가 비동기로 동작한다는 사실을 모른다면 당연히 (2)의 상태이기를 기대하면서 코드를 작성하게 된다. 하지만, 우리의 기대와는 달리 setState 함수는 비동기로 동작하고 이 때문에 memberId의 값은 (1)의 상태를 가지게 된다.

setState 함수가 가지는 비동기적인 특징으로 인해 의도하지 않은 결과가 만들어지게 된다. 이러한 에러를 어떻게 해결할 수 있을까?


이를 해결하는 고전적인 방법들

  1. 모든 함수를 하나로 합친다.

    만약 모든 함수가 하나로 합쳐진다면, 그 안에서 하나의 임시변수를 통해 새로운 값을 저장하고 사용하면 된다. 하지만, 이 방법은 함수의 크기를 과하게 크게 만드는 부작용이 있다.

  2. 명시적으로 값을 다음 함수에 넘긴다.

    위의 경우에서는 updateMemberId 함수 내부에서 validateMemberId를 호출할 때 value를 인자로 넘겨주자는 의미이다. 하지만, 이러한 방식은 코드를 이해하기 더욱 어렵게 만든다. 만약 로직이 조금 더 복잡해진다면 계속해서 인자값이 넘어가야하는 상황이 발생하고, 이는 유지보수의 비용을 증가시킨다.

  3. reducer를 사용하는 방법

    useState() 대신 useReducer()를 사용하는 것이다. useReducer()는 동기적으로 동작하기 때문에 useState()가 비동기적으로 동작함으로 인해 발생됐던 문제들을 해결할 수 있다. 하지만, 하나의 컴포넌트에서만 사용될 상태값들을 이렇게 전역으로 관리하는 것 자체가 너무 별로이다.

  4. useRef()를 사용하는 방법

    값을 state, useRef() 이 두 곳에 저장하는 방법이다. 비동기로 인해 문제가 발생하는 지점에서는 state 대신 useRef()에 저장해놓은 값을 사용해서 이 문제를 해결할 수 있다. 고전적인 방법들 중에서는 가장 훌륭하지만, 저자는 뭔가 아쉽다고 한다.

  5. useEffect()를 사용하는 방법

    useEffect 훅이 마운트 이후에 실행된다는 특징을 이용한 방법이다.

    	import { useState, useEffect } from "react";
    	export default function CountWithEffect() {
    		const [count, setCount] = useState(0);
        	const [doubleCount, setDoubleCount] = useState(count * 2);
    		const handleCount = () => {
        		setCount(count + 1);
        	};
    		useEffect(() => {
        		setDoubleCount(count * 2); // This will always use latest value of count
        	}, [count]);
    		return ()
    	}

    이런 식으로 코드를 작성하여 setDoubleCount는 항상 맨 마지막에 실행되도록 보장시킬 수 있다. 하지만, setState 함수 하나하나 useEffect 훅으로 묶을 수는 없는 노릇이다.

  6. useCallback()을 사용하는 방법

    	const validateMemberId = useCallback(() => {
    		if (memberId.length === 5) {
      			return validOrNot;
    		}
    	}, [memberId])

    다음과 같이 useCallback을 활용하여 이슈를 해결할 수도 있다. 하지만 이 또한 동기적인 동작이 필요한 모든 함수에 useCallback을 감싸야 한다는 문제가 존재한다.


저자가 생각해낸 해결 방법

즉, useState를 동기적으로 사용할 수 있는 방법을 저자가 제시하고 있다.

export const useSyncState = <T> (initialState: T): [() => T, (newState: T) => void] => {
    const [state, updateState] = useState(initialState)
    let current = state
    const get = (): T => current
    const set = (newState: T) => {
        current = newState
        updateState(newState)
        return current
    }
    return [get, set]
}

const SomeComponent = () => {
  const [counter, setCounter] = useSyncState<number>(0);

  const increment = () => {
    console.log('counter =', counter()); // 0
    const newValue = setCounter(counter() + 1);
    console.log('newValue =', newValue); // 1
    console.log('counter =', counter()); // 1
  }

  return (
    <>
      Counter: {counter()}
      <br/>
      <button onClick={increment}>Increment</button>
    </>
  );
}

하지만, 이 방법 또한 다음과 같은 문제점들을 지닌다.

  1. useTrait() doesn't work if the value being saved is returned in a truly asynchronous manner. For example, if the variable is supposed to hold something that is returned from an API, then you won't be able to simply set() the value and then, on the very next line, get() the proper value. This is only meant for variables that you wouldn't normally think of as being "asynchronous" - like when you're doing something dead-simple, such as saving a number or a string.
  2. It will always be at least somewhat inefficient. For every "trait" that's saved, there are essentially two values being tracked. In the vast majority of code, trying to fix this "issue" would be a micro-optimization. But there are certainly some bulky values that should not be chunked into memory twice, merely for the convenience of being able to immediately retrieve the result of set() operations.
  3. It's potentially non-idiomatic. As mentioned above, I'm fully aware that the Children of Redux would almost certainly address this issue with useReducer(). I'm not going to try to argue them off that cliff. Similarly, the Children of Hooks would probably try to address this with useEffect(). Personally, I hate that approach, but I'm not trying to fight that Holy War here.
  4. I feel like I'm overlooking some simpler solution. I've done the requisite googling on this. I've read through a pile of StackOverflow threads. I haven't grokked any better approach yet. But this is one of those kinda problems where you just keep thinking that, "I gotta be overlooking some easier way..."

참고문헌

실전 리액트 프로그래밍 개정판 [인사이트]

https://dev.to/shareef/react-usestate-hook-is-asynchronous-1hia

https://dev.to/bytebodger/synchronous-state-with-react-hooks-1k4f

profile
프론트 개발자가 되고 싶어요

0개의 댓글