본문 바로가기

FrontEnd

useState에 대해 좀 더 잘 알아보기

반응형

#3: Things to know about useState

#4: useState for one-time initializations

 

두 게시물을 짬뽕해서 정리함.

1. 함수형 업데이터

클로저를 사용하여 setState 함수를 호출할 시, 배치 처리되는것 같은 느낌을 줍니다.

이는 우리의 의도와 다를 수 있습니다.

함수형 업데이터는 매 setState호출마다 이전 호출 결과 상태를 사용할 수 있게 해줍니다.

 

기존 클로저를 사용한 업데이트 : 결과는 항상 1 증가합니다.

function App() {
  const [count, setCount] = React.useState(0)

  return (
    <button
      onClick={() => {
        setCount(count + 1)
        setCount(count + 1)
      }}
    >
      🚨 This will not work as expected, count is: {count}
    </button>
  )
}

render(<App />)

 

이는 리액트에게 다음과 같이 알려주는 것과 동일합니다.

  • count를 count+1로 증가시켜 주세요
  • count를 count+1로 증가시켜 주세요

해당 count는 항상 1로 동일합니다.

따라서 두번 setState를 호출해도 값은 2가 됩니다.

 

다음과 같이 함수형 업데이터를 사용하면 어떻게 될까요?

function App() {
  const [count, setCount] = React.useState(0)

  return (
    <button
      onClick={() => {
        setCount((previousCount) => previousCount + 1)
        setCount((previousCount) => previousCount + 1)
      }}
    >
      ✅ Increment by 2, count is: {count}
    </button>
  )
}

render(<App />)

이는 리액트에게 다음과 같이 알려주는 것과 동일합니다.

  • 현재 값에서 1 증가시켜주세요
  • 현재 값에서 1 증가시켜주세요

따라서 2가 증가된 값을 얻을 수 있습니다.

비동기 액션이 포함된 경우

function DelayedCounter() {
  const [count, setCount] = React.useState(0)
  const increment = async () => {
    await doSomethingAsync()
    setCount(previousCount => previousCount + 1)
  }
  return <button onClick={increment}>{count}</button>
}
이전 상태 혹은 외부 상태를 기반으로 새 상태를 계산해야 할 때마다 함수 업데이터를 사용합니다.
 
Kent C. Dodds는 여기에 대해 긴 게시물을 작성했으며 결론은 위와 같습니다. (here)
위 기사를 철저히 읽을 것을 권장합니다.
 

보너스1 : 의존성을 빼기

함수형 업데이터 형태는 useEffect, useMemo 또는 useCallback에 대한 종속성을 피하는 데도 도움이 됩니다.
메모된 자식 컴포넌트에 increment 함수를 전달한다고 가정합시다.
 
useCallback을 사용하여 함수가 너무 자주 변경되지 않도록 할 수 있지만,
카운트가 변경될 때마다 함수가 변경됩니다.
 
함수형 업데이터는 이 문제를 완전히 방지합니다.
function Counter({ incrementBy = 1 }) {
  const [count, setCount] = React.useState(0)

  // 🚨 will create a new function whenever count changes because we closure over it
  const increment = React.useCallback(() => setCount(count + incrementBy), [
    incrementBy,
    count,
  ])

  // ✅ avoids this problem by not using count at all
  const increment = React.useCallback(
    () => setCount((previousCount) => previousCount + incrementBy),
    [incrementBy]
  )
}

보너스2 : useReducer로 토글 구현

useState로 구현하면 약갼의 보일러플레이트가 추가됩니다.

const [value, setValue] = React.useState(true)

<button onClick={() => setValue(previousValue => !previousValue)}>Toggle</button>
한 가지 컴포넌트에서 상태 값을 여러 번 토글하려는 경우 useReducer가 더 나은 선택일 수 있습니다.
const [value, toggleValue] = React.useReducer(previous => !previous, true)

<button onClick={toggleValue}>Toggle</button>
  • 토글 로직을 setter 호출에서 useReducer 훅 호출로 이동합니다. (ui로직이 컴포넌트에서 리듀서로 이동)
  • 토글 함수의 이름을 지정할 수 있습니다. 토글 함수는 단순한 setter가 아닙니다.
  • 토글 함수를 여러번 사용할 때마다 작성하는 이벤트 핸들러의 상용구를 줄일 수 있습니다.

2. 지연 초기화 함수

useState에 초기 값을 전달하면 초기값 변수가 항상 생성되지만,
React는 사실 첫 번째 렌더링에만 이 변수를 사용합니다.
 
이것은 대부분의 사용 사례와 관련이 없긴 합니다만,
드문 경우, 상태를 초기화하기 위해 복잡한 계산을 수행해야 합니다.
 
이러한 상황에서는 함수를 useState에 초기 값으로 전달할 수 있습니다.
React는 결과가 정말로 필요할 때(= 컴포넌트가 마운트될 때)에만 이 함수를 호출합니다.
// 🚨 will unnecessarily be computed on every render
const [value, setValue] = React.useState(
  calculateExpensiveInitialValue(props)
)

// ✅ looks like a small difference, but the function is only called once
const [value, setValue] = React.useState(() =>
  calculateExpensiveInitialValue(props)
)

3: 업데이트 구제금융 (The update bailout)

리액트가 업데이터 함수를 호출할 때마다 항상 컴포넌트를 리렌더링 하는 것은 아닙니다.
상태를 현재 보유하고 있는 것과 동일한 값으로 업데이트하려고 하면 렌더링이 중단됩니다.
React는 Object.is를 사용하여 값이 다른지 확인합니다.
function App() {
  const [name, setName] = React.useState('Elias')

  // 🤯 clicking this button will not re-render the component
  return (
    <button onClick={() => setName('Elias')}>
      Name is: {name}, Date is: {new Date().getTime()}
    </button>
  )
}

render(<App />)

4: 편리한 타입 오버로딩 (The convenience overload)

이것은 모든 TypeScript 사용자를 위한 것입니다.
useState에 대한 타입 추론은 일반적으로 훌륭하게 작동하지만
undefined 또는 null로 값을 초기화하려면 일반 매개변수를 명시적으로 지정해야 합니다.
그렇지 않으면 TypeScript가 타입을 추론할 정보가 충분하지 않습니다.
 
// 🚨 age will be inferred to `undefined` which is kinda useless
const [age, setAge] = React.useState(undefined)

// 🆗 but a bit lengthy
const [age, setAge] = React.useState<number | null>(null)

 

기 값을 완전히 생략하면 전달된 타입에 undefined를 추가하는, useState의 편리한 오버로드가 있습니다.

매개변수를 전혀 전달하지 않는 것은 명시적으로 undefined를 전달하는 것과 동일하기 때문에 런타임에도 값은 undefined가 됩니다.

// ✅ 초기값을 전달하지 않으면, age 타입이 `number | undefined`로 추론됩니다.
const [age, setAge] = React.useState<number>()​

5: 구현 세부사항(The implementation detail)

useState는 사실 useReducer로 구현됩니다.

대부분 반대로 알고 있습니다만.여기 소스 코드에서 확인할 수 있습니다. : the source code here

 

useReducer로 useState를 구현하는 방법에 대한 Kent C. Dodds의 훌륭한 아티클도 있습니다.

: how to implement useState with useReducer


6 : 앱 전체 생명 주기동안 단 한번 초기화

리액트-쿼리의 쿼리클라이언트나 리덕스 스토어에서 많이 볼 수 있습니다.

즉, 앱에 단 하나만 필요한 리소스에 적절합니다.

// ✅ static instance is only created once
const resource = new Resource()

const Component = () => (
  <ResourceProvider resource={resource}>
    <App />
  </ResourceProvider>
)

하지만 마이크로프론트엔드와 같이, 컴포넌트 별로 자체 리소스가 필요한 경우는 컴포넌트 내로 리소스를 옮겨야 합니다.

이는 컴포넌트 리렌더링 마다 리소스가 새로 생성된가는 뜻입니다.

const Component = () => {
  // 🚨 be aware: new instance is created every render
  const resource = new Resource()
  return (
    <ResourceProvider resource={new Resource()}>
      <App />
    </ResourceProvider>
  )
}

보통 이와 같은 문제를 useMemo로 해결합니다.

const Component = () => {
  // 🚨 still not truly stable
  const resource = React.useMemo(() => new Resource(), [])
  return (
    <ResourceProvider resource={resource}>
      <App />
    </ResourceProvider>
  )
}

하지만 공식문서(react docs)를 잘 살펴보죠. 이게 정말 올바른 해답일까요?

미래에 React는 이전에 메모한 일부 값을 "잊어버리고" 다음 렌더에서 다시 계산하도록 선택할 수 있습니다.
즉 화면에 노출되지 않은 컴포넌트에 대한 메모리를 확보합니다.
useMemo 없이도 작동하도록 코드를 작성한 다음 useMemo를 추가하여 성능을 최적화하십시오.

useMemo는 성능 최적화를 위한 도구지 참조 투명성(안정성)을 위한 도구가 아닙니다.

그렇다면 이를 해결하기 위한 최고의 수단은 무엇일까요?

state to the rescue

바로 state입니다.

상태는 setter를 호출하는 경우에만 업데이트되도록 보장됩니다.

따라서 우리가 해야 할 일은 setter를 호출하지 않는 것입니다.

리소스 생성자가 한 번만 호출되도록 하기 위해 이것을 지연 초기화 함수와 아주 잘 결합할 수 있습니다.

const Component = () => {
  // ✅ truly stable
  const [resource] = React.useState(() => new Resource())
  return (
    <ResourceProvider resource={resource}>
      <App />
    </ResourceProvider>
  )
}

ref는 어떤가요?

useRef를 사용하여 동일한 결과를 얻을 수 있다고 생각하며

react 규칙(rules of react)에 따르면 이는 렌더링 함수의 순수성을 손상시키지도 않습니다.

(React에서는 대부분 idempotent 함을 의미합니다. 동일한 입력에 대해 항상 동일한 것을 반환합니다.)

const Component = () => {
  // ✅ also works, but meh
  const resource = React.useRef(null)
  if (!resource.current) {
    resource.current = new Resource()
  }
  return (
    <ResourceProvider resource={resource.current}>
      <App />
    </ResourceProvider>
  )
}

이것은 다소 복잡해 보이고 TypeScript도 그것을 좋아하지 않을 것입니다.

왜냐하면 resource.current는 기술적으로(실제로 어느 시점에) null일 수 있기 때문입니다.

나는 이러한 경우에 useState를 선호합니다.

 

반응형