이 포스트는 Kent C. DoddsReact Hooks: What's going to happen to react context?를 번역한 글입니다.

2018년 초, 리액트 팀은 처음으로 공식 컨텍스트(context) API를 소개했습니다. 이 새 API에 대해서 글을 쓰기도 했고 많은 사람들이 이 기능에 꽤나 흥분했습니다.

이 API에 대한 가장 흔한 푸념 중 하나는 컨텍스트를 실질적으로 사용하려면 렌더링 프로퍼티 API를 사용해야 한다는 점이었습니다. 여러 컨텍스트를 사용하려고 하면 결국 (로직 재활용을 위해) 여러 렌더링 프로퍼티 API를 사용하게 되고 결과적으로 많은 중첩(nesting)이 발생하게 됩니다. 그래서 여러 렌더링 프로퍼티 기반 API를 조합해서 하나의 함수 컴포넌트를 만들어 컨텍스트를 소비하는 방식을 이전 블로그 포스트에서 제시했습니다.

const ThemeContext = React.createContext('light')
class ThemeProvider extends React.Component {
  /* code */
}
const ThemeConsumer = ThemeContext.Consumer
const LanguageContext = React.createContext('en')
class LanguageProvider extends React.Component {
  /* code */
}
const LanguageConsumer = LanguageContext.Consumer

function AppProviders({children}) {
  return (
    <LanguageProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </LanguageProvider>
  )
}

function ThemeAndLanguageConsumer({children}) {
  return (
    <LanguageConsumer>
      {language => (
        <ThemeConsumer>{theme => children({language, theme})}</ThemeConsumer>
      )}
    </LanguageConsumer>
  )
}

function App() {
  return (
    <AppProviders>
      <ThemeAndLanguageConsumer>
        {({theme, language}) => (
          <div>
            {theme} and {language}
          </div>
        )}
      </ThemeAndLanguageConsumer>
    </AppProviders>
  )
}

리액트 컴포넌트의 강력한 합성 덕분에 위 예시처럼 문제를 해결할 수 있습니다. 이런 해법이 썩 맘에 들지 않습니다. 게다가 저만 그렇게 생각한 것이 아닙니다.

클래스 컴포넌트에서는 새 렌더링 프로퍼티 API를 적용하기 어렵다는 피드백을 받았습니다. 그래서 클래스 컴포넌트에서도 쉽게 컨텍스트 값을 사용할 수 있도록 새로운 편의 API를 추가했습니다. - React v16.6.0: lazy, memo와 contextType

이 새로운 편의 API는 클래스 컴포넌트에서 단 하나의 컨텍스트만 소비한다면 contextType이라는 정적 프로퍼티를 정의하는 것으로 사용하고 싶은 컨텍스트를 할당 받아 this.context로 접근할 수 있도록 기능을 제공합니다. 단일 컨텍스트만 필요로 한다면 일반적인 상황에서 꽤 깔끔하고 멋진 방법입니다.

이 편의 API는 정말 만족스럽습니다. 하지만 저는 리액트 컨텍스트의 미래에 영향을 줄 리액트 훅이 더 기대됩니다. 위에서 봤던 코드를 useContext 훅을 사용해서 다시 작성해보겠습니다.

const ThemeContext = React.createContext('light')
class ThemeProvider extends React.Component {
  /* code */
}
const LanguageContext = React.createContext('en')
class LanguageProvider extends React.Component {
  /* code */
}

function AppProviders({children}) {
  return (
    <LanguageProvider>
      <ThemeProvider>{children}</ThemeProvider>
    </LanguageProvider>
  )
}

function App() {
  const theme = useContext(ThemeContext)
  const language = useContext(LanguageContext)
  return (
    <div>
      {theme} and {language}
    </div>
  )
}

ReactDOM.render(
  <AppProviders>
    <App />
  </AppProviders>,
  document.getElementById('root'),
)

멋지지 않습니까? 렌더링 프로퍼티 기반으로 만든 코드만큼 강력하면서도 훨씬 쉽게 읽고, 이해할 수 있고, 리팩토링하고 관리하기 좋은 구현입니다. 단순히 적은 코드가 아닙니다. 간혹 코드 양을 줄이는 과정에서 코드가 주는 명료성을 해쳐 의사소통에 문제를 만들 때도 있습니다. 하지만 이 경우에는 적은 코드이면서도 동시에 쉽게 이해할 수 있습니다. 이 컨텍스트 API는 강력하며 동시에 새 훅 API에 큰 기능을 제공합니다.

리액트 훅의 또 다른 큰 기능은 완전히 선택적인 기능이며 하위호환성이 보장됩니다. 아마 페이스북은 세계에서 가장 크고 오래된 리액트 코드베이스를 운용하고 있으니 엔지니어에게 고통을 주는 결정을 할 수는 없었을 겁니다. 리액트가 점진적으로 새로운 훅의 세계로 이끌고 있다는 점이 너무나도 멋집니다. 리액트 팀에게 감사합니다. 공식 릴리즈가 기대되네요. (역자 주: useContext는 현재 16.8에 릴리즈되었습니다.)

결론

리액트의 멋진 점은 세세한 구현에 개의치 않고 실제 세계의 문제를 해결하는데 집중할 수 있도록 한다는 점입니다. 크로스 브라우징이나 성능 문제를 오랜 기간동안 겪었습니다. 이제는 리액트를 통해 더 나아가 문제를 단순화했습니다. 이제 간단히 읽고 이해할 수 있는 코드를 작성하고 리팩토링하며 유지관리만 하면 됩니다. 저도 제 코드를 통해서 다른 이들의 작업을 단순하게 할 수 있을 지 궁금하네요 🤔.

이 포스트는 Kent C. DoddsReact Hooks: What's going to happen to render props?를 번역한 글입니다.

작년에 "프로퍼티 게터(prop getters)를 사용해서 사용자에게 컴포넌트 렌더링 제어를 넘겨주는 방법"이란 포스트를 작성했습니다. 이 글에서 (당시) react-toggled의 전체 구현을 보여줬는데 downshift에서 사용했던 패턴을 가르치기 위해서 작성했던 라이브러리입니다. 제가 downshift에서 훨씬 간단하고 단순한 컴포넌트를 동일한 패턴으로 구현해서 사용했기 때문에 프로퍼티 게터 패턴을 가르치기 아주 좋은 방법이었습니다.

react-toggled와 downshift는 둘 다 렌더링 프로퍼티 패턴을 사용해서 리액트 컴포넌트 간의 로직 코드를 공유했습니다. 다른 포스트인 "렌더링 프로퍼티를 사용하지 말아야 하는 경우"에서 렌더링 프로퍼티 패턴을 적용하기 좋은 경우를 확인했습니다. 하지만 리액트 훅을 적용하기 좋은 경우도 동일합니다. 리액트 훅은 클래스 컴포넌트와 렌더링 프로퍼티 조합보다 훨씬 간단합니다.

리액트 훅이 안정 버전에서 제공되기 시작하면 더이상 렌더링 프로퍼티를 쓸 필요가 없다는 의미인가요? 아닙니다! 여전히 렌더링 프로퍼티 패턴이 아주 유용하게 쓰일 수 있는 부분이 있으며 그 부분을 곧 살펴보도록 하겠습니다. 하지만 현재의 react-toggled를 훅 기반으로 구현한 코드와 비교해서 왜 훅이 더 간단하다고 주장하는지 비교해보도록 하겠습니다.

코드가 궁금하다면 현재의 react-toggled를 확인해보기 바랍니다.

가장 일반적인 react-toggled 사용 방법입니다.

function App() {
  return (
    <Toggle>
      {({on, toggle}) => <button onClick={toggle}>{on ? 'on' : 'off'}</button>}
    </Toggle>
  )
}

단순히 간단한 토글 기능이 필요한 경우라면 훅으로 작성한 코드는 다음과 같습니다.

function useToggle(initialOn = false) {
  const [on, setOn] = useState(initialOn)
  const toggle = () => setOn(!on)
  return {on, toggle}
}

이제 다음과 같은 방식으로 사람들이 사용할 수 있게 됩니다.

function App() {
  const {on, toggle} = useToggle()
  return <button onClick={toggle}>{on ? 'on' : 'off'}</button>
}

훨씬 간단하고 멋집니다. 하지만 react-toggled의 Toggle 컴포넌트는 이보다 더 많은 기능을 제공합니다. 하나를 예로 들자면 getTogglerProps라는 헬퍼(helper)를 제공합니다. 이 함수는 토글하는 엘리먼트에 올바른 프로퍼티를 전달하기 위해 사용합니다. (접근성을 위한 aria 속성도 포함합니다.) 이 기능도 포함해봅니다.

// 주어진 함수 목록을 호출하는 함수를 반환
const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn(...args))

function useToggle(initialOn = false) {
  const [on, setOn] = useState(initialOn)
  const toggle = () => setOn(!on)
  const getTogglerProps = (props = {}) => ({
    'aria-expanded': on,
    tabIndex: 0,
    ...props,
    onClick: callAll(props.onClick, toggle),
  })
  return {
    on,
    toggle,
    getTogglerProps,
  }
}

이제 useToggle 훅에서 getTogglerProps 헬퍼도 사용할 수 있습니다.

function App() {
  const {on, getTogglerProps} = useToggle()
  return <button {...getTogglerProps()}>{on ? 'on' : 'off'}</button>
}

기존 방식보다 더 접근하기 좋고 편리합니다. 만약 getTogglerProps가 필요 없는 경우라면 어떨까요? 다음처럼 코드를 조금 더 나눌 수 있습니다.

// 주어진 함수 목록을 호출하는 함수를 반환
const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn(...args))

function useToggle(initialOn = false) {
  const [on, setOn] = useState(initialOn)
  const toggle = () => setOn(!on)
  return {on, toggle}
}

function useToggleWithPropGetter(initialOn) {
  const {on, toggle} = useToggle(initialOn)
  const getTogglerProps = (props = {}) => ({
    'aria-expanded': on,
    tabIndex: 0,
    ...props,
    onClick: callAll(props.onClick, toggle),
  })
  return {on, toggle, getTogglerProps}
}

이제 react-toggled가 지원하는 getInputTogglerPropsgetElementTogglerProps도 같은 방법으로 구현할 수 있습니다. 이 접근 방식이라면 앱에서 사용하지 않는 추가적인 유틸리티를 쉽게 떨어낼 수 있고 어떤 면에서는 렌더링 프로퍼티 해결책이 오히려 사용하기 조잡하다고 느껴질 수도 있겠습니다. (불가능한 것은 아니지만 그냥 좀 깔끔하지 않습니다.)

하지만! 앱에 있는 모든 렌더링 프로퍼티 API를 새 훅 API로 모두 리팩토링하고 싶지 않아요! 라고 말할 수 있습니다.

두려워하지 마세요! 다음 코드를 보기 바랍니다.

const Toggle = ({children, ...props}) => children(useToggle(props))

여기 렌더링 프로퍼티 컴포넌트가 있습니다. 그냥 간단하게 예전 코드를 사용하면서도 시간이 흐른 후에 고쳐도 됩니다. 사실, 이 방식이 제가 커스텀 훅을 만든 후에 테스트할 때 추천하는 방법입니다.

여기에는 좀 더 내용이 있습니다. (마치 렌더링 프로퍼티 패턴에서 리액트 훅으로 바꾼 방식과 비슷합니다.) 더 설명하지 않고 이 글을 읽고 있는 당신이 발견하도록 조금 남겨두려고 합니다. 무엇인지 조금 더 생각해본 후에 제가 작성한 과정을 확인해보세요. 훅을 사용하면서 테스팅하는데 약간 달라지는 부분을 확인해보기 바랍니다. (자바스크립트 클로저 만세!)

렌더링 프로퍼티 패턴을 적용할 만한 경우

자, 이제 컴포넌트에 훅을 사용해서 리팩토링을 하면서도 여전히 렌더링 프로퍼티 기반 API로 리액트 컴포넌트를 공유할 수 있습니다. (관심 있다면 하이드라 패턴을 사용할 수도 있습니다.) 하지만 미래를 상상해봅시다. 렌더링 프로퍼티를 더이상 로직 재사용에 사용하지 않고 모두가 훅을 사용하고 있는 시대 말이죠. 그렇다면 계속 렌더링 프로퍼티 API를 작성하거나 사용해야 하는 이유가 있을까요?

그렇습니다! 보세요! 여기에 downshift에서 react-virtualized와 함께 사용한 예시입니다. 연관된 부분을 아래서 확인해보세요.

<List
  // ... some props
  rowRenderer={({key, index, style}) => (
    <div
    // ... some props
    />
  )}
/>

여기 rowRenderer 프로퍼티를 확인해보세요. 이 코드가 무엇인지 아세요? 바로 렌더링 프로퍼티입니다. 뭐라고요?! 🙀 바로 제어 역전을 실현한 렌더링 프로퍼티가 여기에 있습니다. 이 프로퍼티는 react-virtualized에서 목록에서 각 행을 렌더링하는 데 있어서 제어를 사용자에게 넘겨주기 위해 존재하는 코드입니다. 만약 react-virtualized를 훅으로 다시 작성한다고 하면 아마도 rowRendereruseVirtualized 훅의 인자로 받을 수 있을 겁니다. 하지만 이런 경우라면 렌더링 프로퍼티 패턴보다 이득이 있다고 말하기는 조금 어렵습니다. 그러니 이런 사용 예시에서는 이 패턴(과 이 방식의 제어 역전)을 사용하는 방법이 더 적합하다고 봅니다.

결론

이 내용이 흥미 있고 도움이 되었기를 바랍니다. 리액트 훅은 아직 알파 단계이며 변경 예정에 있습니다. (역자 주: 현재 16.8.0에 포함되어 배포되었습니다.) 이 기능은 철저히 선택 기능이며 리액트의 다른 API에 문제를 만들지 않습니다. 제가 보기엔 정말 좋은 기능이라고 생각합니다. 앱을 다시 작성하지 마세요! 대신 리팩토링을 하기 바랍니다. (훅이 안정 단계에 들어오면 말입니다.)

이 포스트는 Kent C. DoddsReact Hooks: What's going to happen to my tests?를 번역한 글입니다.

새로 나오는 리액트의 훅(hook) 기능에 대해 가장 많이 받는 질문 중 하나가 바로 테스팅입니다. 만약 지금 테스트가 다음과 같은 모양이라면 그 걱정을 충분히 이해할 수 있습니다.

// 이전 블로그 포스트에서 빌린 예제
// https://kcd.im/implementation-details
test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />)
  expect(wrapper.state('openIndex')).toBe(0)
  wrapper.instance().setOpenIndex(1)
  expect(wrapper.state('openIndex')).toBe(1)
})

이 enzyme 테스트는 Accordion이 클래스 컴포넌트며 instance가 존재하는 경우에만 동작합니다. 하지만 함수 컴포넌트라면 "인스턴스"라는 개념이 존재하지 않습니다. 그러므로 상태와 생명주기가 있는 클래스 컴포넌트에서 훅을 사용한 컴포넌트로 리팩토링했을 때에는 이 테스트의 .instance().state()가 동작하지 않을 겁니다.

이제 Accordion 컴포넌트를 함수 컴포넌트로 리팩토링 한다면 이 테스트는 고장 나게 됩니다. 그럼 테스트를 던지거나 다시 작성하지 않고 훅을 사용해서 코드를 리팩토링할 방법은 없을까요? 위 테스트처럼 컴포넌트의 인스턴스를 참조하는 enzyme API의 사용을 피하는 일에서부터 시작할 수 있습니다. 이 내용은 제 "구현 상세(implementation details)" 포스트에서 확인할 수 있습니다.

이제 클래스 컴포넌트의 간단한 예제를 확인해봅시다. 제가 가장 좋아하는 <Counter /> 컴포넌트 예제는 다음과 같습니다.

// counter.js
import React from 'react'

class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return <button onClick={this.increment}>{this.state.count}</button>
  }
}

export default Counter

이제 이 컴포넌트를 테스트하는데 어떤 방식으로 작성하면 훅이든 클래스 컴포넌트든 상관없이 테스트할 수 있을까요?

// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent} from 'react-testing-library'
import Counter from '../counter.js'

test('counter increments the count', () => {
  const {container} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('0')
  fireEvent.click(button)
  expect(button.textContent).toBe('1')
})

이 테스트는 통과할 겁니다. 이제 동일한 컴포넌트를 훅으로 작성해보겠습니다.

// counter.js
import React from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  const incrementCount = () => setCount(c => c + 1)
  return <button onClick={incrementCount}>{count}</button>
}

export default Counter

이 테스트는 구현 상세를 피해서 테스트하고 있기 때문에 훅으로 만든 테스트도 문제 없습니다. 정말 멋지지 않나요? :)

useEffectcomponentDidMount + componentDidUpdate + componentWillUnmount가 아닙니다.

useEffect 훅을 사용하기 전에 고려해야 하는 점이 있습니다. 이 훅은 조금 특별하고 다르고 멋지기 때문인데요. 클래스 컴포넌트에서 훅으로 리팩토링할 때에 componentDidMount, componentDidUpdate, componentWillUnmount를 하나 이상의 useEffect 콜백 함수로 바꾸게 될 겁니다. (컴포넌트의 수명주기에 포함된 컴포넌트의 관심사에 따라 다릅니다.) 하지만 이 코드는 엄밀히 말해서 리팩토링이 아닙니다. "리팩토링"이 무엇인지 빠르게 살펴보도록 하겠습니다.

코드를 리팩토링할 때는 사용자가 볼 수 있는 변화를 만들지 않고 내부 구현을 바꿔야 합니다. 다음은 위키피디아가 말하는 "코드 리팩토링"입니다.

코드 리팩토링은 존재하는 컴퓨터 코드를 구조를 조정하는 과정으로 외부 동작을 바꾸지 않고 팩토링 즉, 분해(decomposition)를 수행하는 일을 의미합니다.

이제 다음 코드에서 개념을 살펴봅니다.

const sum = (a, b) => a + b

이제 이 함수를 리팩토링해 보도록 하겠습니다.

const sum = (a, b) => b + a

앞서 함수와 동일하게 동작하지만 구현은 약간 다릅니다. 근본적으로 이 과정이 "리팩토링"입니다. 다음은 리팩토링이 아닌 경우입니다.

const sum = (...args) => args.reduce((s, n) => s + n, 0)

멋진 코드입니다. sum은 더 멋지게 동작하지만, 엄밀히 따지면 리팩토링이 아니라 개선입니다. 비교해봅시다.

호출 이전 결과 이후 결과
sum() NaN 0
sum(1) NaN 1
sum(1, 2) 3 3
sum(1, 2, 3) 3 6

왜 이 변경은 리팩토링이 아닌가요? 그 이유는 "외부 동작을 변경"했기 때문입니다. 새 코드는 이전 코드보다 바람직하지만 기존 동작을 바꿨습니다.

그러면 왜 이 내용이 useEffect를 사용하는 것과 어떤 관련이 있을까요? 앞서 본 카운터 클래스 컴포넌트에서 새로운 기능을 추가한 예제를 살펴보도록 하겠습니다.

class Counter extends React.Component {
  state = {
    count: Number(window.localStorage.getItem('count') || 0),
  }
  increment = () => this.setState(({count}) => ({count: count + 1}))
  componentDidMount() {
    window.localStorage.setItem('count', this.state.count)
  }
  componentDidUpdate(prevProps, prevState) {
    if (prevState.count !== this.state.count) {
      window.localStorage.setItem('count', this.state.count)
    }
  }
  render() {
    return <button onClick={this.increment}>{this.state.count}</button>
  }
}

이 코드에서는 count의 값을 componentDidMountcomponentDidUpdate를 사용해서 localStorage에 저장합니다. 구현 상세에 영향받지 않는 테스트는 다음과 같습니다.

// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent, cleanup} from 'react-testing-library'
import Counter from '../counter.js'

afterEach(() => {
  window.localStorage.removeItem('count')
})

test('counter increments the count', () => {
  const {container} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('0')
  fireEvent.click(button)
  expect(button.textContent).toBe('1')
})

test('reads and updates localStorage', () => {
  window.localStorage.setItem('count', 3)
  const {container, rerender} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('3')
  fireEvent.click(button)
  expect(button.textContent).toBe('4')
  expect(window.localStorage.getItem('count')).toBe('4')
})

테스트가 통과했습니다! 이제 이 컴포넌트를 "리팩토링"하는데 훅을 사용해보겠습니다.

import React, {useState, useEffect} from 'react'

function Counter() {
  const [count, setCount] = useState(() =>
    Number(window.localStorage.getItem('count') || 0),
  )
  const incrementCount = () => setCount(c => c + 1)
  useEffect(() => {
    window.localStorage.setItem('count', count)
  }, [count])
  return <button onClick={incrementCount}>{count}</button>
}

export default Counter

좋습니다. 사용자의 관점에서 보면 이 컴포넌트는 이전과 똑같이 동작할 겁니다. 하지만 이 변경은 이전의 동작과는 다릅니다. 여기서 useEffect 콜백은 이후에 구동하도록 예정 되었습니다. 이전 코드에서는 컴포넌트가 렌더링 된 후에 값을 localStorage에 동기적으로 저장했습니다. 새 코드에서는 렌더링 된 후에 호출되도록 변경했습니다. 왜 그럴까요? 리액트 훅 문서에 설명된 부분을 확인해봅니다.

componentDidMountcomponentDidUpdate와 다르게 useEffect로 예정한 효과는 브라우저가 화면을 업데이트하는 것을 막지 않습니다. 앱이 더 높은 응답성을 제공할 수 있도록 이렇게 동작합니다. 대부분의 효과는 동기적으로 구동될 필요가 없습니다. 동기적으로 동작하는 경우는 레이아웃을 측정해야 한다거나 하는 등 특수한 경우에 해당합니다. 이런 코드는 useLayoutEffect에 넣을 수 있으며 useEffect와 동일하게 동작하는 API입니다.

이제 useEffect를 사용해서 더 나은 성능을 얻을 수 있게 되었습니다! 멋지지 않나요! 향상된 컴포넌트를 작성한 것 뿐만 아니라 더 가볍게 구동되는 컴포넌트 코드를 작성했습니다.

하지만 이 과정은 리팩토링이 아닙니다. 실제로는 컴포넌트의 동작을 변경했기 때문입니다. 최종 사용자의 입장에서는 이 변화를 눈치채기 힘듭니다. 테스트를 작성할 때 구현 상세를 피하려고 노력했다면 이런 변화 또한 테스트에서 관측되지 않을겁니다.

다른 두 컴포넌트를 문제 없이 테스트가 가능한 이유는 react-dom/test-utils에서 제공하는 새 act 유틸리티 덕분입니다. react-testing-library는 이 유틸리티와 통합되어 있습니다. 그 덕분에 위 테스트가 문제 없이 통과할 수 있는 것입니다. 그래서 구현 상세에 의존하지 않는 테스트를 계속 작성할 수 있으며 소프트웨어를 기존에 개발하던 방식과 큰 차이 없이 개발을 계속 할 수 있게 되었습니다.

렌더링 프로퍼티 컴포넌트는 어떻게 테스트하죠?

이 부분이 제가 좋아하는 부분입니다. 프로퍼티를 렌더링하는 간단한 카운터 컴포넌트를 확인합니다.

class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return this.props.children({
      count: this.state.count,
      increment: this.increment,
    })
  }
}
// 사용방법:
// <Counter>
//   {({ count, increment }) => <button onClick={increment}>{count}</button>}
// </Counter>
// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent} from 'react-testing-library'
import Counter from '../counter.js'

function renderCounter(props) {
  let utils
  const children = jest.fn(stateAndHelpers => {
    utils = stateAndHelpers
    return null
  })
  return {
    ...render(<Counter {...props}>{children}</Counter>),
    children,
    // 이 부분으로 컴포넌트 내부의 증감과 횟수에 접근할 수 있는 방법을 제공합니다
    ...utils,
  }
}

test('counter increments the count', () => {
  const {children, increment} = renderCounter()
  expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 0}))
  increment()
  expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 1}))
})

이제 이 컴포넌트를 훅을 사용해서 리팩토링 합니다.

function Counter(props) {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return props.children({
    count: count,
    increment,
  })
}

좋습니다. 앞서 작성한 테스트를 문제 없이 통과합니다. 하지만 리액트 훅: 렌더링 프로퍼티는 어떻게 되나요? (역자 주: 번역)에서 배운 것처럼 커스텀 훅(custom hook)은 리액트에서 코드 공유에 더 나은 자료 형식(primitive)입니다. 위 코드를 커스텀 훅으로 다시 작성해보도록 하겠습니다.

function useCounter() {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return {count, increment}
}

export default useCounter

// 사용방법:
// function Counter() {
//   const {count, increment} = useCounter()
//   return <button onClick={increment}>{count}</button>
// }

멋집니다... 하지만 이제 useCounter는 어떻게 테스트 하나요? 새 useCounter 훅을 사용한다고 코드 전체를 갱신할 수는 없습니다. <Counter />라는 렌더링 프로퍼티 기반 컴포넌트가 수 백 군데에 위치하면 어떻게 하죠? 정말 최악일 겁니다.

제가 도와드리죠. 다음처럼 해결하세요.

function useCounter() {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return {count, increment}
}

const Counter = ({children, ...props}) => children(useCounter(props))

export default Counter
export {useCounter}

이제 새로 작성한 <Counter /> 렌더링 프로퍼티 기반 컴포넌트는 기존에 사용하던 컴포넌트와 완전히 동일한 형태입니다. 이 방법이 진정한 의미의 리팩토링이죠. 또한 이제 누구든지 useCounter 커스텀 훅을 사용해서 코드를 개선하는 것도 가능하게 되었습니다.

더 대단한 점은 앞서 작성한 테스트가 여전히 통과한다는 점입니다. 멋지지 않습니까?

이제 모든 코드를 새로운 훅을 사용해서 갱신하고 나면 더이상 Counter 함수는 필요가 없어지겠죠? 물론 이 함수를 제거할 수도 있지만 그대로 __tests__ 폴더로 옮겨도 좋을 겁니다. 이 방법이 바로 커스텀 훅을 테스트하는 방법이기 때문입니다. 저는 커스텀 훅을 사용해서 렌더링 프로퍼티 기반 컴포넌트를 만드는 것을 선호하는데 실제로 렌더링 된 결과를 함수 호출 결과와 비교하는 데 유용하기 때문입니다.

흥미로운 기법이죠? 저의 새로운 egghead.io 코스에서도 확인할 수 있습니다.

그럼 훅 라이브러리는 어떻게 하죠?

일반적인 훅이나 오픈소스 훅을 작성한다면 특정 컴포넌트를 생각하지 않고서 테스트를 작성해야 합니다. 그런 경우에는 react-hooks-testing-library를 사용하길 권합니다.

결론

코드를 리팩토링하기 전에 할 수 있는 가장 좋은 일은 좋은 테스트 슈트(suite)와 타입 정의를 준비해서 우연히 무언가를 고장 내더라도 그 실수를 바로 알아차릴 수 있도록 하는 일입니다. 하지만 리팩토링을 할 때 테스트 슈트를 집어 던진다면 아무런 의미 없는 일이 되고 맙니다. 제가 드릴 수 있는 조언은 테스트 내에서 구현 상세를 피하라입니다. 지금 클래스 컴포넌트와도 동작하고 미래에 훅으로 만든 함수가 오더라도 동작하는 테스트를 작성하세요.

이 포스트는 Kent C. DoddsApplication State Management을 번역한 글입니다.

소프트웨어 개발에서 가장 어려운 부분 중 하나는 상태 관리(managing state)입니다. 만약 사용자가 애플리케이션과 상호작용을 전혀 하지 않았더라면 우리 삶은 훨씬 단순했을 겁니다. 물론 90년대 웹사이트에서나 할 이야기입니다. 우리는 상호작용이 필요한 애플리케이션을 만들어야 하니 어디엔가 상태를 저장해야 합니다.

상태는 정말 복잡하며 애플리케이션마다 사용 사례가 극과 극으로 다릅니다. 그래서 애플리케이션에서 상태를 관리하는 방법도 엄청나게 다양합니다. 각각의 방법은 특정 방법을 해결하는데 초점을 맞추고 있으며 어떤 추상이든 이 문제에 따른 고통을 줄이기 위해서 애플리케이션에 복잡도를 더해야 합니다.

어떤 추상을 사용해야 하는가에 대한 핵심은 그 추상의 비용과 이점을 이해하는 데 있습니다. 이득이 무엇인지 알려면 그 추상이 풀기 위한 문제가 무엇인지, 그 문제를 해결하기 위한 다른 해법은 무엇인지 이해할 필요가 있습니다. 이 글에서는 제가 사용해보고 좋았던 추상도 공유하려고 합니다. 물론 여기서는 책이 아니기 때문에 모든 경우를 다 다루지 않고 기본적인 부분에 대해서만 설명합니다.

이 글에서는 애플리케이션의 규모와 복잡도가 성장할 때 추상을 추가하는 것을 어떻게 고려하게 되는지와 동일한 방법으로 접근합니다. 추상을 너무 이르게 추가하지 않는 것은 중요합니다. 필요보다 먼저 추가한 추상은 그 이득보다 더 많은 비용을 지불하게 될 수 있습니다.

그리고 여기서는 리액트에 특정한 해법을 다루지만 다른 프레임워크에서도 충분히 적용할 수 있는 일반적인 개념이 되었으면 합니다.

컴포넌트의 상태

먼저 컴포넌트의 상태부터 시작합니다. 상호작용이 필요한 애플리케이션이라면 어디선가 setState를 사용하게 될겁니다. 저는 생각보다 리액트 컴포넌트의 상태 API가 심할 정도로 인정을 덜 받고 덜 사용된다고 느낍니다. 믿을 수 없을 정도로 단순한 API며 이 API를 사용하더라도 애플리케이션에 그다지 복잡함을 더하지도 않습니다.

이 지식을 배우거나 다시 살펴보고 싶다면 다음 7분 분량의 (무료) 비디오를 확인해보세요. "리액트에서 컴포넌트 상태 사용하기". 그리고 공식 문서의 상태 들어 올리기를 확인해보세요.

이후 내용을 더 보기 전에 상태 API를 어디까지 사용할 수 있는지 보는 것을 진지하게 추천합니다. 이 API 자체만으로도 정말 강력하기 때문입니다.

컴포넌트의 상태를 사용하다가 문제가 생기기 시작하는 지점은 "프로퍼티 내리꽂기"에서 문제를 마주하게 될 때입니다. 프로퍼티 내리꽂기를 참고하세요. (역주: 번역)

자바스크립트 모듈 (싱글톤)

만약 싱글톤(singleton) 패턴에 익숙하지 않더라도 걱정하지 마세요. 자바스크립트에서는 상당히 직관적인 편입니다. 이는 단순히 모듈 안에 상태를 보관하는 패턴입니다. 이 패턴을 JS로 간단히 구현하면 다음과 같습니다.

const state = {}

const getState = () => state
const setState = newState => Object.assign(state, newState)

export {getState, setState}

"프로퍼티 내리꽂기 문제"에서 오는 불편함을 느끼기 시작할 때면 컴포넌트 상태 API에서 다음 단계로 넘어가는 것을 고려하게 됩니다. 리액트 애플리케이션이 성장한다는 것은 "컴포넌트 트리"도 함께 성장한다는 뜻입니다. 이 과정에서 애플리케이션에서 널리 공유되는 상태를 트리 위로 끌어 올리게 됩니다. 비대해진 컴포넌트 트리에서는 각 컴포넌트가 필요로 하는 상태와 상태를 갱신하기 위한 핸들러를 내려받는 과정이 정말 번거로워집니다.

그래서 일반적인 JS 모듈을 사용해 필요한 상태를 불러올 수 있다는 점에서 싱글톤 패턴은 꽤 괜찮은 선택지입니다. 그러니 프로퍼티를 내려 꽂는 것보다 컴포넌트 스스로 모듈을 불러와서 상태를 사용할 수 있게 됩니다.

물론 이 방법은 심각한 한계가 있습니다. 이 싱글톤에서 상태를 갱신할 때마다 컴포넌트가 다시 렌더링을 하도록 주의해야 합니다. 이 과정은 구현에 조금 더 노력을 필요로 합니다. (간단히 말해서 이벤트 에미터 (event emitter)를 구현할 필요가 있습니다.) 하지만 엄청 어려운 것은 아니며 이 과정을 도와줄 라이브러리도 존재합니다. 그러니 싱글톤 패턴을 사용하는 방법이 그렇게 나쁜 선택지는 아닙니다. 서버-사이드 렌더링을 필요로 하지 않는 경우라면 말이죠. (물론 대부분은 이 부분도 필요로 할 겁니다...)

컨텍스트 Context

리액트의 새 컨텍스트 API는 정말 멋집니다. 아직 이 기능을 모르신다면 리액트⚛️ 의 새 컨텍스트 API를 확인해보세요.

리액트 컨텍스트를 사용하면 프로퍼티 내리꽂기 문제와 싱글톤을 갱신하는 문제를 간단한 내장 API로 극복할 수 있게 됩니다. 이 API는 간단히 <ContextInstance.Provider /><ContextInstnace.Consumer /> 컴포넌트를 사용해서 어디서든지 상태에 접근할 수 있도록 구성할 수 있게 됩니다.

새로운 컨텍스트 API가 매우 단순한 덕분에 정말 많은 상황에서 단순히 싱글톤 패턴을 적용하는 것이 훨씬 간단해졌습니다. 리액트에서 컴포넌트 상태를 setState로 간단히 해결하는 것처럼 createContext를 사용해서 애플리케이션 상태를 해결할 수 있게 되었다는 점에서 정말 멋진 기능입니다.

Unstated

제임스 카일은 새 컨텍스트 API와 함께 사용할 수 있는, 새로운 상태 라이브러리를 만들었습니다. 이 새로운 라이브러리는 제가 상태를 공유할 일이 있는 앱이 있을 때 사용할 정도로 최애 라이브러리입니다. 이 라이브러리는 컨텍스트 위에 많은 것을 올리지 않는 조그마한 라이브러리라 좋습니다. 그리고 상태 컨테이너와 표현 컴포넌트를 명확하게 분리하고 있어서 모든 코드가 테스트하기 편리하니 한번 염두해보세요.

redux

Redux가 풀려고 의도했던 문제는 flux를 좀 더 손쉽게 사용할 수 있도록 하는 부분이었습니다. Flux는 상태의 흐름을 예측 가능하도록 만들어서 상태를 디버깅하는 방법을 개선하려고 했습니다. 또한 저장소에서 나온 데이터를 연산하는 과정을 단순화하려고 했습니다. 이 두 문제를 해결하기 위해서 명확한 관심사 분리와 단방향 데이터 흐름을 사용했습니다.

Redux는 이 과정을 정말 단순화해서 "flux 전쟁"을 2015년에 종식했습니다. 기존 Flux라면 필요했던 수많은 추상을 구현하지 않고도 flux의 이점을 누릴 수 있게 되었습니다. Redux 기반 상태 관리는 강점을 갖는 경우도 많고 사용 사례 또한 많습니다. 저는 이 라이브러리가 애플리케이션 상태 관리의 어려움을 해결했다는 점에 너무 기쁩니다. 이 라이브러리는 정말 많은 사람에게 도움이 되었습니다!

다시 말하면, 이 라이브러리를 사용한 앱이라면 엄청난 양의 복잡도를 더하게 되었다는 의미입니다. 이 라이브러리는 우회적인 계층을 애플리케이션에 추가하는 것으로 복잡한 사용자 상호작용을 구현하고 상태를 갱신했습니다. 이 라이브러리를 사용하는 프로젝트에 참여한 적이 있다면 디스패치와 액션, 크리에이터와 리듀서를 다 열어서 액션 타입명을 비교하는 일을 해봤을 겁니다. 그렇다면 복잡도에 대한 이야기도 이해할거라 생각합니다.

많은 애플리케이션이 redux를 사용하고 채택하는 이유는 react-redux의 connect 고차 함수가 프로퍼티 내리꽂기 문제를 우연히 해결했기 때문이라 봅니다. 프로퍼티 내리꽂기 문제는 상대적으로 작은 애플리케이션에서도 겪을 수 있는 고통인데 이 문제를 해결하기 위해 redux를 고려하는 경우가 많습니다. 더 쉬운 대안이 있다는 것을 모른 상태로 말이죠.

프로퍼티 내리꽂기 문제를 해결하기 위해 redux를 선택한다면 해결할 필요 없던 문제를 위해 비용을 지불하게 되는데 그로 인해 실제로 얻는 이득보다 더 많은 비용을 내게 됩니다.

그러니 다른 해결책을 먼저 사용해보세요. 그리고 상태를 필요로 하는 트리를 기준으로 redux에 보관하는 상태의 분량을 제한하세요. (일반적인 redux 사용자라면 최상위에 위치하고 있을 겁니다.)

코리 하우스가 이른 redux 적용에 대해 다음과 같은 트윗을 남겼습니다.

깨달음: Redux를 회사 프레임워크에 기본으로 넣은 것은 실수였다.

결과:

  1. 사람들이 모든 컴포넌트를 연결함.
  2. 사람들이 재사용 가능한 컴포넌트에도 Redux를 포함함.
  3. 모두가 Redux를 사용함. 심지어 필요가 없는 경우에도.
  4. 사람들이 React만 갖고 앱을 만들 줄 모름.

코리 하우스

결론

이 문제를 해결하기 위해 구현할 수 있는 수많은 추상과 패턴이 있지만 제가 이걸 다 적으려면 올해 내내 적어도 시간이 모자랄 겁니다. 😉

그래서 제가 강조하고 싶은 부분은 상태가 존재한다면 필요한 곳에 최대한 가까이 보관하라는 점입니다. 실무적인 용어로 설명하면 폼 입력창의 오류 상태를 전역 스토어에 보관하지 말라는 뜻입니다. 정말로 필요한 경우가 아니라면 말이죠. (그런 경우는 분명 드물껍니다.) 이 규칙을 따른다면 애플리케이션에서 컴포넌트 상태를 사용할 가능성이 매우 높다 는 뜻이고 이런 경우에 컨텍스트나 싱글톤을 애플리케이션 어딘가에서 아마도 사용하고 싶어질 겁니다. 컴포넌트 트리의 작은 일부 영역이라도 이 접근 방법은 매우 유용합니다.

이 포스트는 Kent C. DoddsWhen to break up a component into multiple components를 번역한 글입니다.

리액트 애플리케이션을 작성할 때 하나의 리액트 컴포넌트로 작성해도 된다는 점을 알고 있나요? 하나의 거대한 컴포넌트 안에 애플리케이션 전체를 넣는다고 해도 기술적으로 불가능한 것은 전혀 아닙니다. 거대한 render 메소드와 엄청 많은 인스턴스 메소드, 수많은 상태가 있을 것이고 아마도 모든 생명주기 훅(lifecycle hook)을 사용해야 할 겁니다. (shouldComponentUpdatecomponentWillUnmount는 예외겠네요. 항상 상태가 갱신된 데다 컴포넌트가 언마운트 될 일이 없으니까요!)

이 방법을 사용하면 다음 문제를 마주하게 됩니다.

  1. 성능이 저하될 수 있습니다. 상태 변화가 일어날 때마다 애플리케이션 전체를 새로 그리게 됩니다.
  2. 코드 공유와 재사용성이... 쉽지 않을 겁니다.
  3. 상태 관리도 어렵습니다. 어느 상태와 이벤트 핸들러가 어느 JSX 부분에 사용되는지 보다 보면 두통 😬이 올겁니다. 그리고 버그 🐜를 추적하는 일도 쉽지 않습니다. (이 부분은 관심사 분리의 장점 중 하나입니다.)
  4. 테스트는 100% 통합 테스트입니다. 물론 반드시 나쁜 것은 아닙니다. 하지만 엣지 케이스를 테스트하기 쉽지 않고 테스트를 하려는 부분을 격리하는 일이 어렵습니다. 그래서 이런 테스트를 유지하는 것 자체가 큰 도전입니다.
  5. 여러 엔지니어와 함께 이 코드 위에서 작업하는 일은 끔찍합니다. git diff의 결과가 어떨지, merge로 발생하는 코드 충돌이 상상이라도 가나요?!
  6. 서드파티 컴포넌트 라이브러리를 사용하는 게 불가능하지 않을까요? 단일 컴포넌트에 모든 것을 작성하는 것이 목표라면 서드파티 라이브러리 사용 자체가 좀 이상한 상황이 될 겁니다. 서드파티 컴포넌트를 사용한다고 하더라도 고차컴포넌트를 사용하는 react-emotion 같은 라이브러리는 어떨까요? 쓸 수 없겠죠!
  7. 선언적 컴포넌트 안에서 명령형 추상/API를 캡슐화하는 일은 어렵습니다. 한 거대한 컴포넌트 속 생명주기 훅에 널리 흩뿌려져 있게 되어 코드를 따라가기 더욱 어려워집니다.

이런 이유에서 컴포넌트로 나눠 작성합니다. 컴포넌트를 나누면 위 문제를 해결할 수 있게 됩니다.

한동안 AMA에서 앱을 컴포넌트로 나누는 좋은 방법/패턴이 있는가에 대한 질문을 받았습니다. 이게 제 답변입니다. "만약 위에서 살펴본 문제를 직면하게 될 때, 컴포넌트를 여러 작은 컴포넌트로 나눠야 할 때입니다. 미리 나누지 말고요." 단일 컴포넌트를 여러 컴포넌트로 나누는 일을 우리는 "추상(abstraction)"이라고 부르고 있습니다. 추상은 멋지지만 모든 추상은 비용이 따릅니다. 그래서 추상에 되려 당하기 전에 이 비용과 이득에 대해 유의하고 있어야 합니다.

중복은 잘못된 추상보다 훨씬 저렴합니다.Sandi Metz

저는 제 컴포넌트의 render 메소드가 정말 길어져도 크게 개의치 않습니다. JSX는 JavaScript 표현식의 모음이고 컴포넌트를 위해 선언적 API를 사용한다는 점을 기억하세요. 컴포넌트를 작은 크기로 나눠 이곳저곳에 프로퍼티 내리꽂기 (역자 주: 번역)를 하지 않고 render 메소드를 그대로 유지한다고 해서 그렇게 코드가 엄청 잘못되거나 하는 것은 아니라는 의미입니다.

결론

컴포넌트를 더 작은 크기로 쪼개는 것은 자유입니다. 하지만 진짜 문제를 경험하기 전까지는 크기가 커지는 컴포넌트에 대해 두려워하지 마세요. 너무 이르게 추상적으로 나누는 것보다 정말로 필요할 때까지 기다렸다가 적용하는 것이 코드 관리에 훨씬 편리합니다.

이 포스트는 Kent C. DoddsProp Drilling을 번역한 글입니다.

이 글에서는 프로퍼티 내리꽂기(prop drilling)를 이해하는데 그치지 않고 어떤 부분이 문제가 될 수 있는지, 이 문제를 피하기 위해 사용할 수 있는 방법이 무엇인지 확인하려고 합니다. (혹자는 "나사 구멍 내기 threading" 라고도 합니다.)

프로퍼티 내리꽂기가 무엇인가요?

프로퍼티 내리꽂기(또는 나사 구멍 내기)는 리액트의 컴포넌트 트리에서 데이터를 전달하기 위해서 필요한 과정을 의미합니다. 다음 간단한 상태 컴포넌트 예제로 살펴보도록 합니다. (제가 가장 좋아하는 컴포넌트 예제입니다.)

class Toggle extends React.Component {
  state = {on: false}
  toggle = () => this.setState(({on}) => ({on: !on}))
  render() {
    return (
      <div>
        <div>The button is {on ? 'on' : 'off'}</div>
        <button onClick={this.toggle}>Toggle</button>
      </div>
    )
  }
}

이 컴포넌트를 둘로 리팩토링합니다.

class Toggle extends React.Component {
  state = {on: false}
  toggle = () => this.setState(({on}) => ({on: !on}))
  render() {
    return <Switch on={this.state.on} onToggle={this.toggle} />
  }
}

function Switch({on, onToggle}) {
  return (
    <div>
      <div>The button is {on ? 'on' : 'off'}</div>
      <button onClick={onToggle}>Toggle</button>
    </div>
  )
}

Switchtoggleon 상태(state)에 대한 참조가 필요하기 때문에 간단히 프로퍼티로 전달했습니다. 한번 더 리펙토링 과정을 거쳐 컴포넌트 트리에 하나의 레이어를 더 추가합니다.

class Toggle extends React.Component {
  state = {on: false}
  toggle = () => this.setState(({on}) => ({on: !on}))
  render() {
    return <Switch on={this.state.on} onToggle={this.toggle} />
  }
}

function Switch({on, onToggle}) {
  return (
    <div>
      <SwitchMessage on={on} />
      <SwitchButton onToggle={onToggle} />
    </div>
  )
}

function SwitchMessage({on}) {
  return <div>The button is {on ? 'on' : 'off'}</div>
}

function SwitchButton({onToggle}) {
  return <button onClick={onToggle}>Toggle</button>
}

이 과정을 프로퍼티 내리꽂기라고 합니다. on 상태와 toggle 핸들러를 필요한 위치에 두기 위해서 프로퍼티를 내려 꽂아(또는 나사 구멍을 내서) Switch 컴포넌트까지 전달해야 합니다. Switch 컴포넌트 자체는 동작을 위해 이 프로퍼티가 필요한 것이 아니더라도 자식 컴포넌트에 필요하기 때문에 프로퍼티로 받아서 전달해야 합니다.

왜 프로퍼티 내리꽂기가 좋은가요?

한번이라도 전역 변수를 사용하는 애플리케이션을 작업한 적이 있나요? AngularJS 애플리케이션에서 고립되지 않은 $scope를 사용하는 경우를 겪어본 적이 있나요? 많은 사람들이 이런 기법을 크게 거부하는 이유는 애플리케이션의 데이터 모델을 아주 혼란스러운 모습으로 이끌기 때문입니다. 누구든 데이터가 어디서 초기화되고 갱신되며 사용되는지 판단하기 쉽지 않은 상황에 마주하게 됩니다. 이런 상황에서 "이 코드를 수정하거나 지워도 다른 곳이 고장나지 않을까" 하는 질문에 답하기란 어렵습니다. 또한 이 질문은 당신이 작성한 코드를 최적화하는데 물어봐야 할 필수적인 질문입니다.

전역 변수보다 ES모듈을 선호하는 이유 중 하나는 이 모듈이 값이 어디서 사용되는지 더 명시적이며, 값을 추적하는데 더 쉽고, 코드 변경이 애플리케이션의 다른 영역에 어떤 영향을 주는지 파악하는데 용이하기 때문입니다.

가장 기초적인 수준의 프로퍼티 내리꽂기를 생각해보면 단순히 애플리케이션의 뷰에 어떤 값을 명시적으로 전달하는가에 해당합니다. 위 예제의 Toggle에서 on 상태를 열거형(enum)으로 리팩토링한다고 가정해보면 이 방식이 좋은 이유를 알 수 있습니다. 코드를 실행하지 않고도 정적으로 따라가는 것으로 어디서 사용하는지 쉽게 파악할 수 있고 수정도 간단하게 할 수 있습니다. 여기서 중요한 점은 암시적인 것보다 명시적으로 작성하는데 있습니다.

프로퍼티 내리꽂기로 발생하는 문제는 무엇인가요?

위에서 본 예제에서는 전혀 문제가 없습니다. 하지만 애플리케이션이 성장하면 여러 계층의 컴포넌트를 만들며 프로퍼티 내리꽂기를 하기 위해 이곳저곳에 깊은 구멍을 만드는 자신을 마주하게 될 겁니다. 일반적으로 작성하는 초기에는 큰 문제가 아닙니다. 코드를 계속 몇 주간 작성하다 보면 이런 과정이 거추장스럽게 느껴지는 몇 가지 경우를 겪게 됩니다.

  • 일부 데이터의 자료형을 바꾸게 되는 경우 (예: {user: {name: 'Joe West'}} -> {user: {firstName: 'Joe', lastName: 'West'}})
  • 필요보다 많은 프로퍼티를 전달하다가 컴포넌트를 분리하는 과정에서 필요 없는 프로퍼티가 계속 남는 경우
  • 필요보다 적은 프로퍼티를 전달하면서 동시에 defaultProps를 과용한 결과로 필요한 프로퍼티가 전달되지 않은 상황을 문제를 인지하지 어려운 경우 (또한 컴포넌트 분리 과정에서도)
  • 프로퍼티의 이름이 중간에서 변경되어서 값을 추적하는데 쉽지 않아지는 경우 (예를 들어 <Toggle on={this.state.on} />에서 <Switch toggleIsOn={on} />를 렌더링)

이외에도 프로퍼티 내리꽂기로 나타나는 여러 문제 상황이 있습니다. 특히 리펙토링을 진행하다보면 이런 부분에 고통을 겪습니다.

프로퍼티 내리꽂기에서 나타나는 문제를 어떻게 피할까요?

프로퍼티 내리꽂기에서 문제를 더 악화시키는 것 중 하나는 render 메소드를 불필요하게 여러 컴포넌트로 나누는 경우입니다. 큰 render 메소드 하나를 사용하는 것이 상황을 얼마나 쉽게 만들 수 있는가에 놀랄 겁니다. 가능한 한 메소드에 두세요. 이 메소드를 너무 성급하게 나눠야 할 필요가 없습니다. 다시 재사용해야 하는 상황이 될 때까지 기다린 후에 나누기 바랍니다. 그렇게 하면 불필요한 프로퍼티 내리꽂기를 할 필요가 없게 될겁니다.

재미있는 사실은 단 하나의 리액트 컴포넌트에 애플리케이션 전체를 작성한다고 한들 기술적으로 제약된 부분이 전혀 없습니다. 전체 애플리케이션의 상태를 관리하고 하나의 거대한 render 메소드를 사용하는 것도 가능합니다. 물론 이 방법을 권장하는 것은 아니지만요. 한번 생각해볼 만한 부분이라고 봅니다. :)

노트: 이 개념을 컴포넌트를 여러 컴포넌트로 나눠야 할 때 (역자 주: 번역)에서 작성했습니다. 관심있다면 확인해보세요.

프로퍼티 내리꽂기로 나타나는 문제를 완화하는 또 다른 방법은 defaultProps를 필수 프로퍼티에 사용하지 않는 방법입니다. defaultProps를 사용하면 컴포넌트가 제대로 동작하기 위해 실제로 필요한 프로퍼티를 전달받지 못한 상황인데도 중요한 오류가 숨겨지고 소리없이 실패하게 됩니다. 그렇기 때문에 defaultProps는 컴포넌트에 필수적이지 않은 부분에만 사용하기 바랍니다.

관련있는 상태는 될 수 있으면 가까이에 보관하기 바랍니다. 애플리케이션의 특정 부분에만 상태가 필요하다면 그 상태는 애플리케이션의 가장 높은 계층에 저장할 것이 아니라 최소 공통 부모 컴포넌트에서 관리해야 합니다. 상태 관리에 관해서는 애플리케이션 상태 관리 (역자 주: 번역)를 참고하기 바랍니다.

상태가 리액트 계층에서 정말로 깊숙이 위치한 경우라면 리액트의 새 컨텍스트 API를 사용하세요. 상태라고 애플리케이션 내 어디서나 다 접근할 필요는 없기 때문입니다. (컨텍스트 제공자는 어디서나 렌더링 할 수 있습니다.) 이 API는 프로퍼티 내리꽂기로 나타나는 몇 문제를 피하는 데 도움이 됩니다. 물론 이 컨텍스트는 전역 변수로 문제를 해결하는 방식과 유사성이 있습니다. 하지만 API가 디자인된 방식이 다르기 때문에 여전히 컨텍스트의 원천도 정적으로 찾을 수 있으며 어떻게 사용하든 상대적으로 손쉽게 사용 가능합니다.

결론

프로퍼티 내리꽂기는 장점도, 단점도 있는 양날의 검입니다. 위에서 언급한 올바른 사용법을 따르면 더 유지보수하기 쉬운 애플리케이션을 만드는데 도움되는 기능이 될 겁니다.

이 글은 Robin Wieruch8 things to learn in React before using Redux 번역입니다.


React에서 Redux 전에 배워야 할 8가지

상태 관리(State management)는 어렵습니다. React같은 뷰 라이브러리는 지역 컴포넌트 상태를 관리하는 일이 가능합니다. 하지만 이 상태는 특정 시점에서 확장해야 하는 일이 생깁니다. 리액트는 단순히 뷰 계층 라이브러리 입니다. 언젠가는 Redux와 같이 더 수준 높은 상태 관리 솔루션으로 넘어가는 결정을 하게 될 겁니다. 하지만 이 글에서는 Redux 열차에 올라타기 전에 React에서 알아야 하는 부분에 대해서 지적하고 싶습니다.

사람들은 간혹 React와 Redux를 함께 배웁니다. 하지만 거기에 문제점이 있습니다.

  • 지역 상태 (this.state)만 사용하는 경우에 왜 상태 관리에 확장 문제가 발생하는지 겪어보지 못합니다
    • 그래서 왜 Redux 같은 상태 관리 라이브러리가 필요한지 이해하지 못합니다
    • 그래서 너무 많은 보일러플레이트에 대해 불평합니다
  • React에서 지역 상태를 관리하는 방법을 배우지 못합니다
    • 그래서 모든 상태를 Redux에서 제공하는 상태 컨테이너에 담아두고 관리하려고 합니다
    • 그래서 지역 상태 관리를 전혀 사용하지 않게 됩니다

이런 문제점으로 인해서 React를 먼저 배우고 나중에 필요하다고 느낄 때 Redux를 배우도록 조언합니다. 확장 문제는 대형 애플리케이션에서만 나타납니다. 가끔 Redux를 사용하고 있으면서도 상태 관리 라이브러리가 필요하지 않은 경우가 있습니다. 책 The Road to learn React에서는 Redux와 같은 외부 의존성 없이 있는 그대로의 React로 애플리케이션을 만드는 방법을 설명합니다.

하지만 당신은 지금 Redux 열차에 올라타기로 결정했습니다. 그래서 이 글에서는 Redux를 쓰기 전에 React에서 알아야 할 내용을 살펴봅니다.

  • React에서의 지역 상태는 자연스럽다
  • React 함수형 지역 상태
  • React의 상태와 프로퍼티
  • React 상태 옮기기
  • React의 고차 컴포넌트
  • React의 Context API
  • React의 상태 컴포넌트
  • 컨테이너와 프레젠터 패턴
  • MobX 아니면 Redux?

React에서의 지역 상태는 자연스럽다

이미 언급했지만 가장 중요한 조언은 React를 먼저 학습하라는 점입니다. 컴포넌트에 지역 상태 즉, this.setState()this.state를 사용해서 생명을 불어 넣는 일을 피할 수는 없습니다. 이 방식에 익숙해져야 합니다.

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = { counter: 0 };
  }

  render() {
    return (
      <div>
        Counter: {this.state.counter}

        <button
          type="button"
          onClick={() => this.setState({ counter: this.state.counter + 1 })}>
          Click!
        </button>
      </div>
    );
  }
}

React 컴포넌트는 초기 상태를 생성자(constructor)에서 정의하고 있습니다. 그런 후에 this.setState() 메소드를 사용해서 갱신할 수 있습니다. 상태 객체의 갱신은 얕은 병합(shallow merge)으로 수행됩니다. 그러므로 지역 상태 객체를 부분적으로 갱신하고도 상태 객체의 다른 프로퍼티는 손대지 않고 그대로 유지할 수 있습니다. 상태가 갱신된 후에는 컴포넌트가 다시 렌더링을 수행합니다. 앞에서 예로 든 코드에서는 this.state.counter의 갱신된 값을 보여줄 것입니다. 이 예제에서는 React의 단방향 데이터 흐름을 사용해 하나의 닫힌 루프(loop)를 작성했습니다.

React 함수형 지역 상태

this.setState() 메소드는 지역 상태를 비동기적으로 갱신합니다. 그러므로 언제 상태가 갱신되는지에 대해 의존해서는 안됩니다. 상태 갱신은 결과적으로 나타납니다. 대부분의 경우에는 이런 방식이 별 문제 없습니다.

하지만 컴포넌트의 다음 상태를 위해 연산을 하는데 현재 지역 상태에 의존한다고 가정해봅시다. 앞서 작성했던 예제에서는 다음처럼 작성했습니다.

this.setState({ counter: this.state.counter + 1 });

지역 상태(this.state.counter)는 연산에서 바로 그 시점의 상태로 사용했습니다. 그러므로 this.setState()를 사용해서 상태를 갱신하긴 했지만 지역 상태는 비동기 실행이 수행되기 전에 신선하지 않은 상태값을 사용해 연산하게 됩니다. 이런 점은 처음 보고 나서는 바로 파악하기 어렵습니다. 천 마디 말 보다 다음 코드를 보는게 더 빠를 것 같습니다.

this.setState({ counter: this.state.counter + 1 }); // this.state: { counter: 0 }
this.setState({ counter: this.state.counter + 1 }); // this.state: { counter: 0 }
this.setState({ counter: this.state.counter + 1 }); // this.state: { counter: 0 }

// 예상한 상태: { counter: 3 }
// 실제 갱신된 상태: { counter: 1 }

이 코드에서 확인할 수 있는 것처럼 지역 상태를 갱신할 때 현재 상태에 의존해서는 안됩니다. 이런 접근 방식은 버그를 만듭니다. 그래서 이런 상황에서는 다음과 같은 방식으로 지역 상태를 갱신합니다.

this.setState()에는 객체 대신 함수도 사용할 수 있습니다. 함수는 비동기적으로 this.setState()가 실행될 때, 함수 시그니처에 지역 상태를 전달합니다. 그래서 이 함수는 콜백 함수로 정확한 시점에 올바른 상태를 갖고 실행되기 때문에 문제 없이 사용할 수 있게 됩니다.

this.setState(previousState => ({ counter: previousState.counter + 1 }));

이 방법으로 this.setState()를 여전히 이용하면서도 객체 대신 함수를 사용해서 이전 상태를 활용할 수 있습니다.

추가적으로 프로퍼티(props)에 의존적인 갱신이 필요한 경우에도 이 접근 방식을 따라야 합니다. 비동기적 실행이 수행되기 이전에 부모 컴포넌트에서 받은 프로퍼티가 변경되어서 값이 이전 정보가 되는 경우가 있기 때문입니다. 그래서 this.setState()의 두 번째 인자로 프로퍼티가 전달됩니다.

this.setState((prevState, props) => ...);

이제 올바른 상태와 프로퍼티를 사용해서 상태를 갱신할 수 있게 됩니다.

this.setState((prevState, props) => ({ counter: prevState.counter + props.addition }));

객체 대신에 함수를 사용하면서 얻을 수 있는 또 다른 장점은 바로 상태를 갱신하는 방법을 격리된 상태에서 테스트 해볼 수 있다는 점입니다. 단순히 this.setState(fn)을 사용하는 함수를 추출한 다음에 독립적으로 둔 다음에 테스트가 가능하도록 작성할 수 있습니다. 이 함수는 입력으로 간단히 출력을 확인할 수 있는 순수 함수여야 합니다.

React의 상태와 프로퍼티

상태는 컴포넌트 안에서 관리됩니다. 이 상태는 다른 컴포넌트에 프로퍼티로 내려줄 수 있습니다. 이 컴포넌트는 프로퍼티를 사용하거나 더 깊히 자식 컴포넌트로 전달할 수 있습니다. 덧붙여 자식 컴포넌트는 부모 컴포넌트로부터 콜백 함수를 전달 받을 수 있습니다. 이렇게 전달 받은 함수를 사용하면 부모 컴포넌트의 지역 상태를 변경하는 일도 가능합니다. 기본적으로 프로퍼티는 컴포넌트 트리를 타고 내려갑니다. 상태는 하나의 컴포넌트에서 관리합니다. 하위 컴포넌트에서는 프로퍼티로 전달한 함수를 사용해서 상태를 관리하는 컴포넌트까지 거슬러 올라와 상태를 변경할 수 있습니다. 갱신된 상태는 프로퍼티로 다시 하위 컴포넌트로 전달됩니다.

컴포넌트는 전체적인 상태를 관리할 수 있으며 자식 컴포넌트에게 프로퍼티를 전달할 수 있습니다. 프로퍼티에 함수를 전달하는 방법으로 자식 컴폰넌트가 부모 컴포넌트의 상태를 변경할 수 있게 합니다.

하지만 자식 컴포넌트는 전달된 함수의 출처가 어디인지, 프로퍼티로 받은 함수가 어떤 동작을 하는지 알지 못합니다. 이 함수는 부모 컴포넌트의 상태를 변경할 수도 있지만 다른 일을 할 가능성도 있습니다. 자식 컴포넌트는 단순히 실행하는 역할을 합니다. 프로퍼티도 동일합니다. 컴포넌트는 받은 프로퍼티가 프로퍼티인지, 상태인지, 또는 부모 컴포넌트에서 파생된 프로퍼티인지 알 방법이 없습니다. 자식 컴포넌트는 그저 사용할 뿐입니다.

프로퍼티와 상태의 개념을 이해하는 일은 중요합니다. 컴포넌트 트리에서 사용하는 모든 속성은 프로퍼티와 상태로 (그리고 프로퍼티와 상태에서 파생된 속성으로) 나눌 수 있습니다. 무엇이든 상호작용이 필요한 경우에는 상태에 보관되어야 합니다. 그 외 나머지는 모두 프로퍼티 형식으로 전달합니다.

수준 높은 상태 관리 라이브러리를 사용하기 전에 컴포넌트 트리를 따라 프로퍼티를 보내본 적이 있어야 합니다. 가장 끝에 있는 자식 컴포넌트에서 특정 값을 사용하려고 중간 컴포넌트에서는 전혀 쓰지 않는, 수많은 프로퍼티를 전달하는 코드를 작성하면서 "분명 이보다 더 나은 방법이 있을 거야" 생각해본 적이 었어야 합니다.

React 상태 옮기기

이미 지역 상태 계층(local state layer)을 옮겼나요? 이 방식은 일반 React에서 지역 상태 관리를 확장하는데 가장 중요한 전략입니다. 상태 계층은 올릴 수도, 내릴 수도 있습니다.

다른 컴포넌트에서의 접근을 줄이기 위해 지역 상태 계층을 하위로 내릴 수 있습니다. 컴포넌트 A가 자식 컴포넌트로 B와 C를 갖고 있다고 상상해봅시다. B와 C는 A의 자식 컴포넌트로 동등합니다. 컴포넌트 A는 유일하게 지역 상태를 관리하며 자식 컴포넌트에 프로퍼티를 전달합니다. 덧붙여 B와 C에서 A의 상태를 변경할 수 있는 함수도 전달합니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |    Stateful    |
          |                |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |                |
|       B        |    |        C       |
|                |    |                |
|                |    |                |
+----------------+    +----------------+

이제 컴포넌트 A의 지역 상태 절반은 컴포넌트 C에서 프로퍼티를 통해 쓰고 있으며 컴포넌트 B에서는 전혀 사용하고 있지 않습니다. 게다가 C는 A 컴포넌트에서 C에서만 사용하는 상태만 제어할 수 있는 함수를 프로퍼티로 전달했습니다. 여기서 볼 수 있는 것처럼 컴포넌트 A는 컴포넌트 C를 대신해서 상태를 관리하고 있습니다. 대부분의 경우에는 한 컴포넌트가 자식 컴포넌트의 모든 상태를 관리하는 일에 큰 문제가 없습니다. 하지만 컴포넌트 A와 컴포넌트 C 사이에 다른 컴포넌트가 추가된다고 생각해봅시다. 컴포넌트 A에서 컴포넌트 C에 전달해야 하는 프로퍼티를 컴포넌트 트리에 따라 전달합니다. 컴포넌트 A는 여전히 컴포넌트 C의 상태를 관리하고 있습니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |        +       |
|       B        |    |        |Props  |
|                |    |        v       |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |        +       |
                      |        |Props  |
                      |        v       |
                      |                |
                      +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |                |
                      +----------------+

이런 경우가 React의 상태를 아래로 내려야 하는 완벽한 경우입니다. 컴포넌트 A는 컴포넌트 C의 상태를 관리하고 있지만 이 상태 일부는 컴포넌트 C가 스스로 관리해도 문제가 없습니다. 즉, 각각의 상태에 대해 각 컴포넌트가 자율적으로 움직일 수 있습니다. 지역 상태 관리를 컴포넌트 C로 옮기면 더이상 컴포넌트 트리를 따라 프로퍼티를 전달하지 않아도 됩니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |                |
|       B        |    |                |
|                |    |                |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |                |
                      |                |
                      |                |
                      +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |     Stateful   |
                      +----------------+

컴포넌트 A의 상태도 덩달아 깔끔해졌습니다. 이 컴포넌트는 필요에 따라 자신의 상태와 가장 가까운 자식 컴포넌트의 상태만 관리하게 됩니다.

React에서 상태 옮기기는 다른 방향, 즉 상태 위로 옮기기도 가능합니다. 부모 컴포넌트인 컴포넌트 A와 자식 컴포넌트인 컴포넌트 B, C로 다시 돌아와서 살펴봅니다. A, B, C 사이에 얼마나 많은 컴포넌트가 있는지 상관 없습니다. 하지만 이번에는 컴포넌트 C가 이미 자신의 상태를 관리하고 있습니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |                |
|       B        |    |                |
|                |    |                |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |    Stateful    |
                      +----------------+

만약 컴포넌트 B가 C에서 관리하는 상태가 필요하다면 어떻게 해야 할까요? 이 상황에서는 공유할 수 없습니다. 상태는 프로퍼티 형태로 아래로만 넘겨줄 수 있기 때문인데요. 이런 이유에서 상태 계층을 위로 이동시킬 필요가 있습니다. 컴포넌트 C의 상태를 컴포넌트 B와 C가 공통으로 갖는 부모 컴포넌트의 위치로 올릴 수 있습니다. (여기서는 A가 해당되겠군요.) 만약 C가 관리하는 상태를 B에서 필요로 한다면 C는 상태 없는 컴포넌트가 됩니다. 상태는 A에서 관리되며 B와 C에 공유됩니다.

          +----------------+
          |                |
          |       A        |
          |                |
          |                |
          |    Stateful    |
          +--------+-------+
                   |
         +---------+-----------+
         |                     |
         |                     |
+--------+-------+    +--------+-------+
|                |    |                |
|                |    |        +       |
|       B        |    |        |Props  |
|                |    |        v       |
|                |    |                |
+----------------+    +--------+-------+
                               |
                      +--------+-------+
                      |                |
                      |                |
                      |        C       |
                      |                |
                      |                |
                      +----------------+

상태를 위로, 또는 아래로 옮기는 전략에서 단순 React를 사용할 때는 어떻게 상태 관리를 확장하는지 배울 수 있습니다. 더 많은 컴포넌트가 특정 상태에 관심을 가져야 하는 경우에은 상태에 접근해야 하는 컴포넌트 간의 공통 부모 컴포넌트까지 거슬러 올라가 상태를 둬야 합니다. 덧붙여 지역 상태 관리에서 충분히 관리할 수 있다면 컴포넌트는 필요한 만큼 상태를 관리하고 있기 때문입니다. 만약 컴포넌트 자체나 자식 컴포넌트에서 사용하지 않는 상태가 있다면 그 상태는 상태가 필요한 컴포넌트의 위치로 이동해야 합니다.

React의 상태 들어 올리기는 공식 문서에서 더 자세히 살펴볼 수 있습니다.

React의 고차 컴포넌트

고차 컴포넌트 (Higher order components, HOCs)는 React의 고급 패턴입니다. 이 패턴은 추상적인 기능이 필요할 때 사용할 수 있으며 여러 컴포넌트에서 선택적으로 기능이 필요할 때 활용할 수 있습니다. 고차 컴포넌트는 컴포넌트를 받아서 선택적 설정을 입력으로 받아 강화된 버전의 컴포넌트를 반환합니다. 이 기능은 JavaScript의 고차 함수 원칙인 함수를 반환하는 함수처럼 구현되었습니다.

만약 고차 컴포넌트가 익숙하지 않다면 React의 고차 컴포넌트 안내를 읽어보길 추천합니다. 이 글은 React의 고차 컴포넌트를 React의 조건부 렌더링의 용례와 함께 설명합니다.

고차 컴포넌트는 뒤에서 더 중요해지는데 Redux와 같은 라이브러리를 사용하게 되면 마주하게 되기 때문입니다. Redux 같은 라이브러리는 React의 뷰 계층(view layer)와 라이브러리의 상태 관리 계층과 "연결"하게 되며 이 과정에서 고차 컴포넌트를 사용해 처리하게 됩니다. (고차 컴포넌트로 이뤄지는 연결은 react-redux를 사용합니다.)

MobX와 같은 다른 상태 관리 라이브러리도 동일한 방식으로 적용합니다. 고차 컴포넌트는 라이브러리에서 제공하는 상태 관리 계층과 React의 뷰 계층을 붙이는데 사용합니다.

React의 Context API

React의 context API는 드물게 사용됩니다. 이 API를 사용하라 충고하지 않는 편인데 이 API는 안정적이지 않고 애플리케이션의 묵시적 복잡도(implicit complexity)를 높이기 때문입니다. 하지만 어떤 기능을 하는지 들어보면 왜 이런 기능이 있는지 충분히 이해할 수 있을 겁니다.

왜 이 기능을 알아야 할까요? React의 context는 컴포넌트 트리에서 속성을 묵시적으로 전달할 때 사용됩니다. 부모 컴포넌트에서 속성을 context로 선언하면 컴포넌트 트리 아래에 있는 자식 컴포넌트에서 활용할 수 있습니다. 명시적으로 각각의 컴포넌트 계층에 일일이 전달할 필요 없이 단순히 부모-자식 관계라면 부모 컴포넌트가 생성한 context를 자식 컴포넌트가 집어 사용할 수 있습니다. 모든 컴포넌트 트리에 걸쳐 언제든 꺼내서 쓸 수 있는, 보이지 않는 컨테이너가 존재합니다. 이 컨테이너 덕분에 컴포넌트에서 필요하지 않는 프로퍼티는 접근할 일이 없어지기 때문에 React에서 "프로퍼티 내려꽂기(props drilling)"라고 하는 일을 피할 수 있게 됩니다. 다시 원래 주제로 돌아와서 왜 이런 API를 알아야 할까요?

Redux나 MobX와 같은 세련된 상태 관리 라이브러리를 사용하다보면 어떤 시점에서 상태 관리 계층을 React 뷰 계층에 붙여야 하는 상황이 생깁니다. React의 고차 컴포넌트를 언급한 이유가 여기에 있습니다. 이 붙이는 과정을 통해 상태에 접근하고 수정할 수 있게 됩니다. 상태 자체는 일종의 상태 컨테이너 안에서 관리됩니다.

하지만 어떻게 모든 컴포넌트에서 이 상태 컨테이너에 접근할 수 있도록 붙일 수 있을까요? 이런 상황에서 React의 context를 사용할 수 있습니다. 최상위 컴포넌트 즉, React의 루트 컴포넌트(root component)에서 상태 컨테이너를 context로 지정합니다. 그래서 컴포넌트 트리에 있는 모든 컴포넌트에 명시적으로 전달하지 않으면서도 모두 접근할 수 있게 됩니다. 이 모든 과정은 React의 프로바이더 패턴으로 적용할 수 있습니다.

물론 이런 방식을 사용한다는 게 Redux 같은 라이브러리를 사용할 때마다 React의 context를 직접 제어해야 할 필요가 있다는 의미는 아닙니다. 이런 라이브러리는 이미 모든 컴포넌트에서 상태 컨테이너에 접근 가능하도록 모든 기능이 함께 제공되고 있습니다. 하지만 이 기능이 보이지 않는 곳에서 어떤 방식으로 동작하고 있는지 이해하게 된다면 여러 컴포넌트에서 상태를 제어하면서 도대체 이 상태 컨테이너는 어디서 오는 것일까 걱정할 필요도 없어지게 됩니다.

React의 상태 컴포넌트

React는 두 종류의 컴포넌트 선언이 존재합니다. ES6 클래스 컴포넌트와 함수형 상태 없는 컴포넌트(functional stateless component)입니다. 함수형 상태 없는 컴포넌트는 props를 인자로 받고 JSX를 반환하는 단순한 함수입니다. 이 함수는 어떤 상태도 갖지 않으며 React의 생애주기(lifecycle) 메소드에도 접근하지 않습니다. 이 컴포넌트는 이름 붙여진 그대로 상태가 없습니다.

function Counter({ counter }) {
  return (
    <div>
      {counter}
    </div>
  );
}

반면, ES6 클래스 컴포넌트는 지역 상태와 생애주기 메소드를 사용할 수 있습니다. 이 컴포넌트는 this.statethis.setState() 메소드에 접근 가능합니다. ES6 클래스 컴포넌트는 상태 컴포넌트로 사용할 수 있다는 의미입니다. 물론 이 컴포넌트가 꼭 지역 상태를 사용해야 한다는 뜻은 아니며 상태 없는 컴포넌트로도 작성할 수 있습니다. 일반적으로 상태가 없는 ES6 클래스 컴포넌트라면 생애주기 메소드를 사용하기 위해 클래스 형태로 작성한 경우입니다.

class FocusedInputField extends React.Component {
  constructor(props) {
    super(props);
  }

  componentDidMount() {
    this.input.focus();
  }

  render() {
    return (
      <input
        type="text"
        value={this.props.value}
        ref={node => this.input = node}
        onChange={event => this.props.onChange(event.target.value)}
      />
    );
  }
}

결론적으로 ES6 클래스 컴포넌트만 상태를 가질 수도 가지지 않을 수도 있습니다. 함수형 상태 없는 컴포넌트는 항상 상태가 없습니다.

덧붙여 고차 컴포넌트도 React 컴포넌트에 상태를 덧붙일 수 있습니다. 상태를 관리하기 위해 직접 고차 컴포넌트를 만들거나 recompose와 같은 라이브러리에서 제공하는 withState 고차 컴포넌트를 사용하는 것도 가능합니다.

import { withState } from `recompose`;

const enhance = withState('counter', 'setCounter', 0);

const Counter = enhance(({ counter, setCounter }) =>
  <div>
    Count: {counter}
    <button onClick={() => setCounter(n => n + 1)}>Increment</button>
    <button onClick={() => setCounter(n => n - 1)}>Decrement</button>
  </div>
);

고차 컴포넌트를 사용하면 어떤 컴포넌트에든 지역 상태를 추가할 수 있습니다.

컨테이너와 프레젠터 패턴

컨테이너와 프레젠터 패턴은 Dan Abramov의 블로그 포스트 이후 유명해졌습니다. 이 패턴에 익숙하지 않다면 지금이 살펴 볼 차례입니다. 컴포넌트를 컨테이너와 프레젠터로 구분합니다. 컨테이너 컴포넌트는 어떻게 동작하는가를, 프레젠터 컴포넌트는 어떻게 보이는가를 정의합니다. 컨테이너 컴포넌트는 ES6 클래스 컴포넌트로 구현되어 지역 상태를 관리합니다. 프레젠터 컴포넌트는 함수형 상태 없는 컴포넌트로 작성하여 프로퍼티로 받은 내용을 표현하고 부모 컴포넌트로부터 받은 함수 몇 가지를 실행하는 역할을 합니다.

Redux로 뛰어들기 전에 이 패턴 뒤에 있는 원칙을 이해할 필요가 있습니다. 상태 관리 라이브러리는 컴포넌트를 상태와 "연결"해줍니다. 상태 관리 계층과 연결된 컴포넌트를 **연결된 컴포넌트(connected component)**라는 용어로 부르기도 합니다. 이 컴포넌트는 어떻게 보이는가는 신경쓰지 않지만 어떻게 동작하는가에 집중하게 됩니다. 이런 컴포넌트가 바로 컨테이너 컴포넌트의 역할을 합니다.

MobX 아니면 Redux?

모든 상태 관리 라이브러리를 통틀어 Redux가 가장 유명하고 MobX는 살펴 볼 가치 있는 대안입니다. 두 라이브러리는 다른 철학과 프로그래밍 패러다임을 따라가고 있습니다.

둘 라이브러리 중 하나로 고르기 전에 이 글에서 설명한 내용에 대해 이해하고 있어야 합니다. 기본 React의 상태 관리를 확장하기 위해 다른 개념을 적용할 정도로 지역 상태 관리에 익숙해야 합니다. 미래에 규모가 커질 애플리케이션을 염두해서 상태 관리도 확장해야 한다는 점을 기억합시다. 상태 관리 위치를 변경하거나 React의 프로바이더 패턴을 활용해 context를 사용하는 것으로 이미 어느 정도 문제를 해결할 수 있어야 합니다.

Redux나 MobX를 사용하기로 결정했다면 Redux 또는 MobX: 혼란을 해결하려는 시도에서 더 심층적으로 다루고 있으니 읽어보기 바랍니다. 두 라이브러리를 비교하고 어떻게 적용할지 설명하고 있습니다. 아니면 React + Redux 학습 팁으로 바로 Redux를 시작할 수 있습니다.

이 글이 상태 관리 라이브러리를 사용하기 전에 어떤 역할을 하는지 이해하는데 도움이 되었으면 좋겠습니다. Redux와 MobX에 대해 더 궁금하다면 전자책/코스인 Taming the State in React를 확인해보세요.


2018-11-24: Rinae님의 피드백으로 프로퍼티 구멍내기를 프로퍼티 내려꽂기로 수정했습니다. 피드백 감사드립니다.

리액트의 Advanced guides 페이지를 따라하면서 노트한 내용이다. 가이드 쪽은 옴니버스 같은 기분이라서 반반으로 나눠 읽기로 했다. 기록하고 싶은 부분만 남겼기 때문에 자세한 내용은 각 페이지를 참고한다.

Reconciliation

React는 선언형 API를 사용하고 있어서 변경에 대해 일일이 신경쓰지 않아도 된다. 이 가이드에서는 React가 어떤 비교 알고리즘을 사용해서 고성능을 내는지 설명한다.

모든 컴포넌트를 다 새로 그리면 O(n3)인데 다음 두 가정으로 발견적(휴리스틱, heuristic) O(n) 알고리즘을 사용한다. 대부분의 경우는 이 가정에 문제가 없다.

  1. 다른 타입의 두 엘리먼트는 다른 트리를 만듬
  2. 개발자가 key로 힌트를 제공해서 자식 엘리먼트가 반복되는 렌더링에서 안정된 상태인걸 확인할 수 있음

엘리먼트가 갱신될 때, 어떤 식으로 갱신이 발생하는가는 다음과 같다.

  • 다른 타입의 엘리먼트인 경우, 트리 전체를 다시 그림 (언마운트 && 마운트 발생)
  • 동일 타입의 DOM 엘리먼트의 경우, 어트리뷰트만 갱신함, 어트리뷰트 일부만 갱신된 경우 변경된 부분만 갱신 (e.g. stylefontWeight)
  • 동일 타입의 컴포넌트 엘리먼트의 경우, 인스턴스가 유지되며 state도 보존됨. 대신 하위 컴포넌트에는 변경 사항을 componentWillReceiveProps()componentWillUpdate()로 전파함.

자식노드가 갱신될 때는 신경써야 한다.

// 1.
<ul>
  <li>Edward</li>
</ul>

// 2. 뒤로 추가되는 경우에는 기존 엘리먼트가 유지됨
<ul>
  <li>Edward</li>
  <li>Mindy</li>
</ul>

// 3. 앞으로 추가되는 경우에는 노드 전체를 다시 그림
//    당연히 성능 하락 발생하며 컴포넌트 엘리먼트 경우
//    언마운트 마운트하게 된다
<ul>
  <li>Mindy</li>
  <li>Edward</li>
</ul>

// 4. 앞서의 가정 2에 따라서 `key`를 제공하면
//    새로 그리지 않고 반영할 수 있게 됨
<ul>
  <li key="1029">Edward</li>
</ul>

<ul>
  <li key="2012">Mindy</li>
  <li key="1029">Edward</li>
</ul>

id가 없다면 적당히 hash를 생성해서 쓴다. 동일 계층에서만 유일값을 가지면 된다. 최후의 수단은 배열의 index인데 배열 순서가 바뀌지 않는다는 가정이 있어야 한다. 순서가 바뀌면 key를 써도 느리다. 별로 권장하지 않는다.

최종적인 결과는 동일하지만 어떻게 구현되어 있는지 아는 것으로 성능 향상을 할 수 있다. 휴리스틱에 기반한 알고리즘이라서 다음 경우엔 좋지 않다.

  1. 하위 트리의 컴포넌트가 일치하는지 검사하지 않는다. 두 컴포넌트가 비슷한 결과를 낸다면 하나로 만드는걸 고려한다.
  2. 키는 안정적이고 예측 가능하며 유일해야 한다. 이 규칙을 지키지 않으면 성능 하락이나 자식 컴포넌트가 state를 잃어버리는 경우가 발생한다.

Context

props로 일일이 내려주기 번거로울 때 contextTypes로 지정하면 하위 트리에 전역으로 전달된다. prop-types가 필요하다. 흑마법이므로 사용하지 말 것. 실험적인 API로 차후 문제가 될 가능성이 높다. 문서 내내 쓰지 말라는 말이 반복된다.

Fragments

엘리먼트를 반환할 때 컨테이너 역할을 하는 <div> 등을 쓰기 마련인데 테이블 같은 걸 조립할 때는 마크업에 맞지 않다. 이런 경우를 위해 정말 컨테이너 역할만 하는 <React.Fragment>를 사용할 수 있다.

class Columns extends React.Component {
  render() {
    return (
      <React.Fragment>
        <td>Hello</td>
        <td>World</td>
      </React.Fragment>
    );
  }
}

약식 표기로 <>, </>를 사용할 수 있지만 현재 툴에서 지원하지 않을 수도 있으니 위 방식으로 사용한다.

<React.Fragment>로 묶을 때 key도 지정할 수 있다.

function TodoList(props) {
  return (
    <dl>
    {props.items.map(item => (
      <React.Fragment key={item.id}>
        <dt>{item.thing}</dt>
        <dd>{item.done}</dd>
      </React.Fragment>
    ))}
    </dl>
  );
}

Portals

컴포넌트를 부모 컴포넌트의 DOM 트리 바깥에 붙일 때 사용하는 방법이다. 전체화면에 모달 띄우기 이런 동작 필요할 때 쓴다. 아래 코드에서 child는 렌더링 가능한 리액트 엘리먼트고 container는 DOM 엘리먼트다.

ReactDOM.createPortal(child, container)

DOM에서 이벤트 전파(Event bubbling)는 실제 노드를 타고 올라가는 식이지만 포털은 다른 DOM 노드에 위치하고 있더라도 React 컴포넌트의 노드 트리를 타고 전파된다. DOM의 바인딩만 트리 바깥에서 일어나고 실제 모든 컴포넌트는 기존과 동일한 방식으로 동작한다.

에러 바운더리

기존엔 에러가 발생하면 컴포넌트 트리가 멍텅구리 되었는데 에러 바운더리를 사용해서 해결할 수 있다. 오류가 발생했을 때 동작을 componentDidCatch(error, info)에 선언한다. error는 발생한 오류고 infocomponentStack 키를 포함한 객체로 오류 발생 시, 스택 정보를 제공한다.

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  componentDidCatch(error, info) {
    // 오류 발생 UI 표시
    this.setState({ hasError: true });
    // 로그로 남김
    logErrorToMyService(error, info);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}
<ErrorBoundary>
  <MyWidget />
</ErrorBoundary>

에러 바운더리는 다음처럼 컨텍스트 바깥에서 발생하는 오류는 잡지 못한다.

  • 이벤트 핸들러
  • 비동기 코드
  • 서버측 렌더링
  • 하위 컴포넌트가 아닌 에러 바운더리 자체에서 발생한 오류

react 16부터는 에러 바운더리로 에러가 잡히지 않았을 때 리액트 컴포넌트 트리 전체를 언마운트한다. 문제 있는 UI를 그대로 두면 사용자의 잘못된 조작을 야기할 수 있기 때문이라고 한다.

create react app 사용하면 스택 추적에서 어디서 오류가 났는지 명확히 보인다. create react app을 사용하지 않는다면 플러그인을 설치하면 된다. 다만 프로덕션에서는 꺼야 한다.

try ... catch는 명령행 코드에서만 동작한다. 이벤트 핸들러의 에러는 이벤트 바운더리에서 잡지 못한다. 이벤트 핸들러의 에러는 try ... catch를 사용한다.

15에서는 unstable_handleError였다고 한다.

웹 컴포넌트

웹 컴포넌트를 react 내에서 사용할 경우에는 일반 DOM 컴포넌트를 사용하는 것처럼 쓸 수 있다. 웹 컴포넌트는 명령형 API를 쓰는 경우가 종종 있는데 ref로 참조를 받아와 DOM 노드를 직접 호출해야 할 수도 있다. 또한 웹 컴포넌트에서 발생한 이벤트가 리액트 컴포넌트에 제대로 전이되지 않을 수 있으므로 직접 핸들러를 연결해야 할 수도 있다.

웹 컴포넌트에서 React 컴포넌트를 사용하려면 ReactDOM.render직접 렌더링 해야한다.

고차 컴포넌트

고차 컴포넌트는 컴포넌트를 입력 받아 새로운 컴포넌트를 반환하는 함수를 의미한다. (고차 함수와 같은 접근 방식이다.) Redux의 connect, Relay의 createFragmentContainer도 동일한 방식이다.

function withSubscription(WrappedComponent, selectedData) {
  return class extends React.Component {
    // ...
  }
}

고차 컴포넌트 내에서 기존 컴포넌트를 변경하지 않도록 주의한다. 고차 컴포넌트 내에서 기존 컴포넌트를 변경하면 추상성이 무너진다. 순수 함수처럼 작성해야 한다. 이 접근 방식은 책임을 분리한다는 점에서 컨테이너 컴포넌트와 유사한 점이 있다.

render() {
  // 불필요한 prop을 제거하거나 추가적으로 필요한 prop을 전달한다.
  const { extraProp, ...passThroughProps } = this.props;
  const injectedProp = someStateOrinstanceMethod;
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}

compose 같은 합성 함수를 사용하면 편리하다. lodashRedux, Ramda에서도 제공한다.

고차 컴포넌트에서 반환하기 전에 displayName을 추가하면 디버그를 쉽게 할 수 있다.

function withSubscription(WrappedComponent) {
  class WithSubscription extends React.Component { /* ... */ };
  WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`;
  return WithSubscription;
}

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

고차 컴포넌트에서 지켜야 할 사항이 있다.

  1. render() 내에서는 고차 컴포넌트를 사용하지 않는다. 렌더링 할 때마다 새로운 컴포넌트를 만들고 최악의 경우 노드의 모든 상태를 잃게 된다.
  2. 고차 컴포넌트를 만들 때는 정적 메소드도 직접 복사해야 한다. hoist-non-react-statics 같은 패키지를 사용해도 된다.
  3. 고차 컴포넌트는 ref를 전달하지 못한다. ref는 일반 prop이 아니기 때문인데 부모 컴포넌트에 ref 노출하는 식으로 별도의 prop을 만들어서 전달해야 한다. 깔끔한 해결책은 아니다.

Render Props

Render props은 prop에 엘리먼트를 반환하는 함수를 전달해서 재사용성을 높이는 방법이다. React Routerdownshift에서 사용하는 방식이라고 한다.

<Mouse render={mouse => (
  <Cat mouse={mouse} />
)}/>

구체적으로 구현하는 방식보다 동작을 사용자에게 위임하는 방식으로 구현하는 접근법으로 Mouse 커서 위치를 전달하는 예를 들었다.

이런 방식으로 사용하는걸 render props라고 하지만 꼭 props가 render일 필요는 없다. 패턴 이름일 뿐이다.

설명에는 ShallowEquals 때문에 React.PureComponent에서는 익명함수가 계속 새로 생성된다고 나오는데 내가 테스트를 제대로 못하는건지 React.Component에서 하는거랑 차이가 없어 보인다. 여튼 익명함수를 반복해서 생성하고 싶지 않다면 익명함수 대신 선언한 메소드를 전달해주는 방식으로 해결할 수 있다.

constructor(props) {
  super(props);
  this.renderTheCat = this.renderTheCat.bind(this);
}
// ...
renderTheCat(mouse) {
  return <Cat mouse={mouse} />;
}
// ...
return <Mouse render={this.renderTheCat} />;

다른 라이브러리와 함께 사용하기

DOM을 직접 제어하는 플러그인과 함께 사용하려면 ref로 DOM 엘리먼트를 노출하고 직접 제어한다.

componentDidMount() {
  this.$el = $(this.el);
  this.$el.chosen();
}

componentWillUnmount() {
  this.$el.chosen('destroy');
}
render() {
  return (<select ref={el => this.el = el}>{this.props.children}</select>);
}

다른 뷰 라이브러리와 연동하기에서는 리액트로 포팅하는 방법이랑 Backbone.View에 리액트 컴포넌트를 어떻게 넣는지 설명한다.

React state, flux, redux를 권하긴 하지만 모델 레이어와도 통합이 가능하다.

Backbone의 모델을 컴포넌트에서 사용하려면 backbone에서 사용하는 방식대로 사용하면 되고 Backbone의 모델에서 데이터를 가져오는 방식으로는 고차 컴포넌트 형태로 활용할 수 있다.

설명은 Backbone으로 했지만 여기서 사용한 기법 자체는 제한적이지 않다.

접근성

WAI-ARIA 적용, 시멘틱 HTML, 폼 접근성, 포커스 컨트롤 등 접근성 관련된 내용을 설명하는데 읽어봐야 하는 문서를 전부 나열하고 있다. 리액트에 특정한 부분이 아니라서 각 링크는 본문을 확인한다.

// for 대신 htmlFor를 사용
<label htmlFor="namedInput">Name:</label>
<input id="namedInput" type="text" name="name"/>

포커스 제어는 ref로 직접 DOM을 받아서 처리한다.

코드 분할

import() 등 nodejs에서 사용하는 일반적인 코드 분할 기법을 설명한다.

리액트의 Advanced guides 페이지를 따라하면서 노트한 내용이다. 가이드 쪽은 옴니버스 같은 기분이라서 반반으로 나눠 읽기로 했다. 기록하고 싶은 부분만 남겼기 때문에 자세한 내용은 각 페이지를 참고한다.

JSX in Depth

리액트 엘리먼트 타입 정의

JSX는 React.createElement(component, props, ...children)의 편의 문법이다. 그래서 JSX를 사용할 때는 스코프 내에 React가 꼭 필요하다.

다음처럼 점 표기법을 사용할 수 있다. 한번에 여러 컴포넌트 내보낼 때 편리하다.

const MyComponents = {
  DatePicker: function DatePicker(props) {
    return <div>Imagine a {props.color} datepicker here.</div>;
  }
}

function BlueDatePicker() {
  return <MyComponents.DatePicker color="blue" />;
}

사용자 정의 컴포넌트는 꼭 Capitalized 되어야 한다. 소문자로 된 컴포넌트라면 사용하기 전에 Capitalized 하는 방식으로 사용할 수 있다. 동적으로 사용할 때도 이런 방식으로 사용한다.

const components = {
  photo: PhotoStory,
  video: VideoStory
};

function Story(Props) {
  // Wrong
  return <components[props.storyType] story={props.story} />;

  // Correct
  const SpecificStory = components[props.storyType];
  return <SpecificStory story={props.story} />;
}

Props

아래는 각각 동일한 표현이다.

// 문자열 리터럴
<MyComponent message="hello world" />
<MyComponent message={'hello world'} />

// 문자열 리터럴은 HTML-unescaped로 처리됨
<MyComponent message="<3" />
<MyComponent message={'<3'} />

// Prop의 기본 값은 `True`
<MyComponent autocomplete />
<MyComponent autocomplete={true} />

Spread Attribute로 간편하게 표현할 수 있다.

<Greeting firstName="John" lastName="Dorian" nickName="Bambi" />

const props = {firstName: 'John', lastName: 'Dorian', nickName: 'Bambi'};
<Greeting {...props} />

const { nickName, ...other } = props;
const nick = nickName === 'Bambi' ? 'Newbie' : 'Scooter';
<button nickName={nick} {...other} />

자식 노드

문자열은 문자열로 처리되고 개행은 공백으로 처리된다.

render()에서 배열로 반환하면 합쳐서 렌더링한다.

JS 표현식도 자식 노드에 사용할 수 있다. 배열도 렌더링 하기 때문에 다음처럼 쓸 수 있다.

<ul>
  {todos.map((message) => <Item key={message} message={message} />)}
</ul>

children에 함수도 전달할 수 있다. Lifting state up이랑 비슷한 느낌이다. 세부 구현을 사용자에게 위임할 수 있을 것 같다.

function Repeat(props) {
  let items = [];
  for (let i = 0; i < props.numTimes; i++) {
    items.push(props.children(i));
  }
  return <div>{items}</div>;
}

function TodoList() {
  const todos = ['finish doc', 'submit pr', 'review'];
  return (
  <Repeat numTimes={10}>
    {(index) => <div key={index}>This is item {index} in the list</div>}
  </Repeat>
  );
}

Boolean, null, undefined는 화면에 렌더링하지 않는다.

// 조건부 표현
{showHeader && <Header />}

// false가 아닌 falsy한 값을 반환하는 경우에는 렌더링되는 점을 주의, 명확하게 boolean으로 반환할 것
{props.messages.length > 0 && <MessageList messages={props.messages} />}

Boolean, null, undefined를 표시하려면 {String(value)} 식으로 작성한다.

PropTypes로 타입 확인하기

정적 타입을 사용하지 않는다면 사용할 만한 검증 라이브러리다. 원래는 React에 포함되어 있다가 분리된 모양이다. 개발 모드에서만 값을 검사한다. 자세한 사용법은 prop-types 참고한다.

class Greeting extends React.Component {
  // ...
}

Greeting.propTypes = {
  name: PropTypes.string,
  nicknames: PropTypes.arrayOf(PropTypes.string),
  children: PropTypes.element.isRequired
};

Greeting.defaultProps = {
  name: 'Stranger'
};

클래스 프로퍼티 문법으로도 사용할 수 있다.

정적 타입 검사

FlowTypeScript를 설정하고 사용하는 방법을 설명한다.

코틀린도 js를 타겟 플랫폼으로 사용 가능하다고 한다. Kotlin Wrappers, create-react-kotlin-app을 참고한다.

Refs와 DOM

일반적으로 데이터 흐름은 props를 사용하게 되어 있지만 몇몇 경우에는 이런 방식에 적합하지 않다.

  • 커서 위치, 텍스트 선택, 미디어 재생
  • 애니메이션 처리
  • 서드파티 DOM 라이브러리와 연동

선언적으로 해결할 수 있는 부분에서는 ref를 쓰지 않는 것을 권한다. 예를 들면 Dialog 컴포넌트에 open(), close() 메소드를 만드는 것보다 isOpen prop을 넘겨주는 식으로 처리한다. 안되는걸 되게 하려고 ref를 쓸 수는 있지만 쓰기 전에 컴포넌트 위계를 보고 상태를 어디에 위치해야 하는지 잘 고려해야 한다. ref를 쓰는 방식보다 상위 계층에 상태가 위치하는게 더 적절하다면 Lifting State Up 방식을 적용해서 해결한다.

Ref는 DOM 컴포넌트와 클래스 컴포넌트에서만 사용할 수 있다. 컴포넌트 자체를 레퍼런스로 넘기 때문인데 함수형 컴포넌트 내에서 DOM 컴포넌트나 클래스 컴포넌트에는 사용할 수 있다.

마운트 될 때는 인자에 해당 엘리먼트를 전달하고 언마운트에는 null을 전달한다. 이 refcomponentDidMount, componentDidUpdate 전에 호출된다.

일반적으로 DOM 엘리먼트에 접근해야 할 일이 있을 때 많이 쓴다. ref={input => this.textInput = input}

class AutoFocusTextInput extends React.Component {
  componentDidMount() {
    this.textInput.focusTextInput();
  }

  render() {
    return <CustomTextInput
      ref={(input) => { this.textInput = input; }}/>;
  }
}

하위 엘리먼트의 DOM ref를 상위에서 사용하려면 props 체인을 따라서 함수를 내려주면 된다. 여기서 inputRef는 일반 prop을 정의해서 쓴 것이지 ref처럼 특별한 기능이 있는 prop이 아니다.

function CustomTextInput(props) {
  return <div><input ref={props.inputRef} /></div>;
}

function FormLayout(props) {
  return (
    <div>
      Name: <CustomTextInput inputRef={props.inputRef} />
    </div>
  );
}

class AwesomePage extends React.Component {
  render() {
    return <FormLayout inputRef={el => this.inputElement = el} />;
  }
}

가능하면 DOM을 노출해서 사용하지 않는 것이 좋다고 한다. 어쩔 수 없이 필요할 때만 사용하고 극단적으로는 findDOMNode()라는 흑마법도 존재한다고.

ref가 두 차례씩 호출되는 것(마운트 && 언마운트)은 null을 전달해서 기존에 연결된 레퍼런스를 지우는 역할도 겸하고 있기 때문이다. (DOM 레퍼런스를 냅두면 DOM은 해제되어도 GC가 지우지 않고 남겨둔다. 그래서 복잡한거 하지 않도록 간단한 함수 형태로만 소개하는 것 같다.)

Uncontrolled 컴포넌트

폼 데이터를 React 컴포넌트에서 다루는 controlled 컴포넌트와 반대로 DOM 자체에서 다루도록 하는 방식의 컴포넌트를 뜻한다.

DOM 엘리먼트를 사용하면 내장된 동작을 그대로 사용할 수 있는 특징이 있다. Controlled and uncontrolled form inputs in React don’t have to be complicated 글에서 비교 도표를 볼 수 있다.

<input defaultValue="Bob" type="text" ref={(input) => this.input = input} />
// checkbox, radio는 defaultChecked, 그 외는 defaultValue

input[type="file"]은 읽기 전용으로 항상 uncontrolled 컴포넌트다. ref를 사용해서 DOM을 직접 다룬다.

성능 최적화

프로덕션 빌드를 사용한다.

  • Create React App (이래서 다 이거 얘기하는듯)
  • 단일 파일로 빌드
  • Brunch -p 옵션 빌드
  • Browserify의 envify, uglifyify, uglify-js
  • Rollup의 replace, commonjs, uglify
  • webpack

크롬 개발자 도구의 성능 탭에서 컴포넌트를 프로파일링한다. 프로파일링 전에 크롬 확장과 React DevTool 끄는 것 잊지 않는다. 성능 테스트 과정 참조.

긴 목록은 한번에 로드하지 말고 동적으로 처리해야 성능이 좋다. react virtualized 같은 패키지가 있고 Virtualize, windowing가 검색 키워드.

React devtool에서 Highlight updates 기능으로 불필요하게 렌더링이 되는 지점을 찾아서 수정한다.

shouldComponentUpdate()의 반환값으로 렌더링 여부를 수동으로 제어할 수 있다. 이 부분을 직접 작성하는 것보다 React.PureComponent를 상속받는게 낫다. PureComponent는 기존 Component 구현에 prop과 state의 shallow 비교를 포함하고 있다. shouldComponentUpdate() 메소드의 동작 방식과 구현은 본문을 읽는다. 갱신이 필요하면 노드를 타고 올라가서 모두 갱신하게 만든다.

불필요한 갱신은 가변 데이터에서 주로 나타나기 때문에 불변 데이터를 사용하면 이 문제를 쉽게 피할 수 있다. Array.push()로 기존 배열을 조작하는 것보다 Array.concat(), [...words, 'new data']을 사용해서 원 데이터가 변형되지 않도록 한다. Immutable.js을 써도 된다.

ES6 없이 React

ES6 없이 쓸 일이 있을 때 읽는다.

JSX 없이 React

JSX 없이 쓸 일이 있을 때 읽는다.


가이드 나머지 다 보고 나면 몇 가지 먼저 만들려고 한다. 그리고나서 enzyme이랑 상태 관리하는 패키지 redux랑 mobx? 찾아서 볼 생각이다. 많은 분들이 열심히 쓰고 있어서 주워들은 것만 공부해도 좀 걸릴 것 같다.

리액트의 Quick start 페이지를 따라하면서 노트한 내용이다. js의 컨텍스트에서 이해할 수 있는 부분은 적지 않았다. 코드 스니핏도 간단히 알아볼 수 있게만 적어놔서 전체 내용이나 설명이 궁금하다면 본문을 확인하는게 좋겠다.

연습 환경 설치

node를 쓴지 오래되어서 업데이트부터 했다. nvm이 있어야 한다.

$ nvm ls-remote
$ nvm install v9.3.0 && nvm alias default v9.3.0
$ nvm use default
$ npm install -g yarn

npx를 이용해서 playground라는 이름으로 프로젝트를 생성한다. npx는 npm 5.2.0+부터 사용할 수 있다.

$ npx create-react-app playground
$ cd playground
$ yarn start

JSX

리액트는 마크업과 로직을 인위적으로 분리하지 않고 대신에 컴포넌트라는 단위를 만들어 약하게 결합하도록 만들었다. JSX를 꼭 사용할 필요는 없지만 시각적으로 더 편리하다.

리액트 엘리먼트는 ReactDOM.render()로 렌더링한다. root DOM 노드를 지정하면 그 내부의 모든 노드를 리액트가 관리한다.

const element = <h1>Hello, world</h1>;
ReactDOM.render(element, document.getElementById('root'));
function formatName(user) { /* ... * /}

const element = (
  <h1>
    Hello, {formatName(user)}!
  </h1>
);

// 함수형 컴포넌트
function HelloBlock() {
  return <div>Hello</div>;
}

// 클래스 컴포넌트
class GoodbyeBlock extends React.Component {
  render() {
    return <div>Good bye</div>;
  }
}

노드 트리를 직접 조회하지 않고 ReactDOM을 통해 비교한 후, 변경된 사항만 반영하기 때문에 DOM을 직접 읽고 조작하는 방식보다 간결하다.

props

컴포넌트는 자바스크립트 함수와 같아서 인자 입력(props)을 받고 리액트 엘리먼트를 반환한다.

const Greeting = (props) => <h1>Hello, {props.name}!</h1>;

function Greeting(props) {
  return (
    <h1>Hello, {props.name}!</h1>
  );
}

class Greeting extends React.Component {
  render () {
    return <h1>Hello, {this.props.name}!</h1>
  }
}

props는 엘리먼트의 어트리뷰트로 전달한다. expression은 {}로, 문자열은 ""로 보낸다.

const element = <Comment user={user} text="string value blarblar" />;

모든 리액트 컴포넌트는 props를 변경하지 않는 순수 함수처럼 동작해야 한다. 이 규칙을 깨지 않고 출력값을 변경하기 위해 state라는 개념이 있다.

State

State는 props과 비슷하지만 private이고 컴포넌트가 전적으로 제어한다. state는 클래스로 작성한 컴포넌트에서 사용할 수 있다.

이 State를 제어하기 위해 componentDidMount()componentWillUnmount()와 같은 생애주기 훅(hook)을 사용한다. State에 대한 지정은 setState() 메소드를 사용한다.

this.state = {} 형태는 오직 constructor() 내에서만 사용 가능하며 그 외에는 setState()를 사용해야 한다. 그러지 않으면 렌더링에 반영되지 않는다.

한번 갱신하는데 setState() 호출을 여러 차례 한다면 경쟁 상태가 될 수 있다. 대신 함수 형태로 전달하는 것이 가능하다.

this.setState((prevState, props) => ({
  conter: prevState.counter + props.increment
}));

setState로 전달한 개체는 this.state에 병합되는 방식으로 동작한다. 전달하지 않은 프로퍼티는 영향을 받지 않는다.

state는 컴포넌트에 속했기 때문에 외부에서는 어떻게 정의되어 있는지 알 수 없고 알 필요도 없다. 지역적, 캡슐화되어 있다고 이야기하는 이유.

컴포넌트가 stateful, stateless인지는 때마다 다르게 정의해서 사용할 수 있음.

이벤트 제어

return <a href="#" onClick={handleClick}>Click Me</a>;

(이제는 시멘틱웹 얘기 부질 없는 것입니까. 나 너무 오래된 사람인듯.)

handleClicke.preventDefault()를 명시적으로 사용해야 함. (return false 넣는거 싫어하는 사람이라서 이런 방식 좋음.) 이 e는 W3C 스펙에서의 그 SyntheticEvent인데 리액트에 맞게 랩핑되어 있다.

이벤트에 넘겨줄 때는 js 특성 상 컨텍스트를 명시적으로 지정해야 한다. 즉, this 바인딩을 잊지 말아야 한다.

constructor(props) {
  super(props);
  // ...
  this.handleClick = this.handleClick.bind(this);
}

인자를 전달할 때는,

<button onClick={this.deleteRow.bind(this, id)}>Delete This</button>
// 이러면 클릭했을 때 `deleteRow(id, e)`로 호출해준다.

이 귀찮음을 피하기 위한 대안 두 가지로 public class fields 문법을 사용하는 방식과 익명 함수를 사용해 스코프를 전달하는 방식을 제안하는데 전자는 아직 확정된 문법이 아니다. 후자는 그나마 깔끔하지만 렌더링 될 때마다 새 콜백이 생성된다. 대부분 괜찮지만 하위 컴포넌트로 전달되었을 때 불필요한 추가 랜더링이 계속 나타나서 성능에 영향을 줄 수 있다.

// 1. public class fields syntax (experimental)
class Button extends React.Component {
  handleClick = () => {
    console.log('this is', this);
  }
  // ...
}

// 2. arrow funtion as callback
class Button extends React.Component {
  render () {
    return <button onClick={(e) => this.handleClick(e)}>Click me</button>;
  }
}

조건부 렌더링

if 사용하면 된다! 인라인으로 사용하고 싶다면 { expression && <p>Elements</p> } 식으로 사용한다. if-else는 삼항연산자를 사용한다.

null을 반환하면 화면에 렌더링하지 않는다. 예시로 에러 메시지 표시 나왔다. 렌더링 안해도 생애주기 훅은 여전히 호출된다.

리스트와 키

const faces = ['?', '?', '?', '?', '?', '?', '?', '?'];
const listItems = faces.map((face, index) => (
  <li key={index}>
    {face}
  </li>
));

// 안정적인 id를 key로 사용하고 없으면 최후의 수단으로
// 배열 자체의 인덱스를 사용한다. 여기는 예시니까 그냥
// 인덱스를 사용했다.

const FaceList = () => <ul>{listItems}</ul>;

// or inline format
const FaceList = () => (
  <ul>
  {faces.map((face, index) => (
    <li key={index}>
      {face}
    </li>
  ))}
  </ul>
)

변화를 감지하고 반영하기 위해서는 id가 필요한데 key로 지정된 값을 활용한다. 또한 같은 계층에는 키가 유일해야 한다.

키는 리스트에서만 사용하고 각 항목 컴포넌트 정의에서 지정하지 않는다. 위에서 보면 li 역할하는 컴포넌트를 정의할 때 key를 지정하는게 아니라 listItems처럼 배열을 책임지는 컴포넌트에서 key를 지정해야 한다.

key는 prop처럼 작성하지만 실제로 해당 컴포넌트에 전달되진 않는다. 전달하려면 다른 이름의 prop을 명시적으로 지정해야 한다.

폼 Form

그냥 html 쓰듯 작성해도 문제 없다! 하지만 js가 있어야 미려한 기능을 만들 수 있는건 당연하고. 이럴 때는 controlled 컴포넌트를 만들어서 해결한다.

<input> 같은 엘리먼트는 상태를 스스로 관리한다. 리액트는 가변값을 state에 저장하고 setState()를 사용한다. 이 두가지를 하나로 합쳐 State를 “single source of truth”로 사용하고 사용자의 입력을 여기에 반영하는 식으로 만든다. 폼 엘리먼트를 리액트에서 관리하니까 controlled 컴포넌트라고 한다.

// bind 생략
handleChange(event) {
  this.setState({
    address: event.target.value
  });

  // 여러 input을 처리할 때는 computed property name 문법으로
  this.setState({
    [event.target.name]: event.target.value
  });
}
// ...
render() {
  return <input name="address" type="text" value={this.state.value} onChange={this.handleChange} />
}

input[type="file"]은 읽기 전용인데 이런 경우는 uncontrolled 컴포넌트라고 한다.

value에 직접 값을 전달하면 사용자가 값을 변경할 수 없다. 값을 전달한 이후에 다시 값을 변경할 수 있게 하려면 undefinednull을 다시 전달해야 한다.

state 위로 보내기 lifting state up

prop에 state를 조작할 수 있는 함수를 만들어서 전달하면 자식 노드에서도 부모 노드의 state를 간접적으로 조작할 수 있다. 이 방법을 “lifting state up” 라고 말한다.

아래는 NameInput에서 변경된 내용을 WelcomeBoard에서 전달받은 onNameChange를 사용해 갱신하는 방식이다.

class NameInput extends React.Component {
  handleChange(e) {
    this.props.onNameChange(e.target.value);
  }
  render() {
    return (
      <input type="text"
        value={this.props.name}
        onChange={this.handleChange.bind(this)} />
    )
  }
}

class WelcomeBoard extends React.Component {
  constructor(props) {
    super(props);
    this.state = {name: 'stranger'};
  }

  handleNameChange(name) {
    this.setState({
      name: name === '' ? 'stranger' : name,
    });
  }

  render() {
    return (
      <div>
        <p>Hello, {this.state.name}!</p>
        <NameInput
          name={this.state.name}
          onNameChange={this.handleNameChange.bind(this)} />
      </div>
    )
  }
}

구성 vs 상속

자식 노드로 어떤 것이 오게 될지 미리 알 수 없다. 그래서 특별한 prop으로 children이 존재한다.

function Box() {
  return <div>{props.children}</div>;
}

이제 다음과 같이 사용하면 알아서 내부 노드로 처리한다.

<Box>
  <NameInput />
  <NameInput />
  <NameInput />
</Box>

Containment의 예시로 SplitPane를 작성했다.

Specialization의 예시로 Dialog를 작성했다.

상속을 사용하는 경우는 적절한 유즈케이스가 없다고 한다. UI와 관련되지 않은 로직을 공유해야 할 경우라도 상속보다는 별도의 JS 모듈로 작성해서 import 하는 방식을 권장한다.

리액트 식으로 생각하기

리액트를 사용할 때 어떤 방식으로 접근해야 하는지 설명한다.

  1. Mock에서 시작. 프로토타이핑과 mock 데이터를 갖고 시작한다.
  2. UI를 나눠 컴포넌트 계층을 만든다.
    • 단일 책임 원칙
    • 데이터 구조에 맞게
  3. React에서 정적인 페이지로 작성한다.
    • state를 사용하지 않음
    • 데이터는 단방향으로
  4. UI state의 Representation을 모든 경우를 소화할 수 있으면서도 최소한으로 파악한다.
  5. state가 어디에 위치해야 하는지 파악한다.
    • owner 컴포넌트, 또는 상위 컴포넌트
    • 적당한 컴포넌트가 없다면 빈 컴포넌트라도 만들어서
  6. 데이터 흐름이 반대가 되야 하는 경우를 파악한다.
    • lifting state up

색상을 바꿔요

눈에 편한 색상을 골라보세요 :)

Darkreader 플러그인으로 선택한 색상이 제대로 표시되지 않을 수 있습니다.