React Context를 사용할 때 흔히 겪는 문제를 피하고, Provider/Consumer를 더 유지보수하기 좋게 설계하는 패턴(커스텀 Provider, 커스텀 훅, TypeScript 타이핑, 비동기 액션 처리)을 소개한다.
2021년 6월 5일 — 11분 소요
Application State Management with React에서 저는 로컬 상태와 React Context를 섞어 쓰면 어떤 React 애플리케이션에서든 상태를 잘 관리하는 데 도움이 된다는 이야기를 했습니다. 몇 가지 예시를 보여드렸는데, 그 예시들에 대해 몇 가지를 짚어보고, 여러분이 React context consumer를 효과적으로 만들어서 문제를 피하고, 애플리케이션 및/또는 라이브러리를 위해 만드는 context 객체의 개발자 경험과 유지보수성을 개선할 수 있도록 하려 합니다.
Application State Management with React를 꼭 읽어보시고, 눈앞의 모든 상태 공유 문제를 해결하기 위해 context를 만능처럼 꺼내 들면 안 된다는 조언을 따라주세요. 하지만 context가 필요할 때가 분명 있고, 그럴 때 이 글이 context를 효과적으로 사용하는 방법을 아는 데 도움이 되길 바랍니다.
또한 context는 반드시 앱 전체에 대한 전역(global)일 필요가 없고, 트리의 한 부분에만 적용할 수도 있습니다. 그리고 여러분의 앱에는 논리적으로 분리된 여러 개의 context를 둘 수 있으며 (그리고 아마도 그래야만 합니다).
먼저 src/count-context.js 파일을 만들고, 그 안에 context를 만들어봅시다:
jsimport * as React from 'react' const CountContext = React.createContext()
우선 CountContext에 초기값을 주지 않았습니다. 초기값을 주고 싶었다면 React.createContext({count: 0})처럼 호출했을 겁니다. 하지만 저는 기본값(default value)을 넣지 않았고, 그건 의도한 선택입니다. defaultValue는 보통 이런 상황에서만 유용합니다:
jsfunction CountDisplay() { const { count } = React.useContext(CountContext) return <div>{count}</div> } ReactDOM.render(<CountDisplay />, document.getElementById('⚛️'))
CountContext에 default value가 없기 때문에, useContext의 반환값을 구조 분해하는 강조된 라인에서 에러가 나게 됩니다. 기본값이 undefined인데, undefined는 구조 분해할 수 없기 때문입니다.
우리 모두 런타임 에러는 싫어하니, 반사적으로 런타임 에러를 피하기 위해 default value를 추가하고 싶을 수 있습니다. 하지만 실제 값이 없다면 context가 무슨 소용일까요? 제공된 default value만 쓰고 있다면 큰 도움이 되지 않습니다. 애플리케이션에서 context를 만들고 사용하는 경우의 99%는, useContext를 사용하는 consumer가 유의미한 값을 제공하는 provider 안에서 렌더링되길 원합니다.
default value가 유용한 경우도 있지만, 대부분의 경우 필요하지도 유용하지도 않습니다.
React 문서에서는 default value를 제공하는 것이 “컴포넌트를 래핑하지 않고 독립적으로 테스트하는 데 도움이 될 수 있다”고 제안합니다. 실제로 그렇게 할 수 있게 해주긴 하지만, 저는 컴포넌트를 필요한 context로 감싸는 편이 더 낫다고 생각합니다. 테스트에서 애플리케이션에서 하지 않는 일을 할수록, 그 테스트가 줄 수 있는 신뢰도는 낮아집니다. 그렇게 해야 하는 이유가 있기도 하지만, 그게 바로 그 이유는 아닙니다.
TypeScript를 쓰고 있다면 default value를 제공하지 않는 게 React.useContext를 사용하는 사람들에게 꽤 짜증 날 수 있는데, 아래에서 그 문제를 아예 피하는 방법을 보여드리겠습니다. 계속 읽어주세요!
좋습니다, 계속해봅시다. 이 context 모듈이 조금이라도 유용하려면 Provider를 사용해야 하고, 값을 제공하는 컴포넌트를 노출해야 합니다. 컴포넌트는 이렇게 사용될 겁니다:
jsfunction App() { return ( <CountProvider> <CountDisplay /> <Counter /> </CountProvider> ) } ReactDOM.render(<App />, document.getElementById('⚛️'))
그럼 이런 방식으로 쓸 수 있는 컴포넌트를 만들어봅시다:
jsimport * as React from 'react' const CountContext = React.createContext() function countReducer(state, action) { switch (action.type) { case 'increment': { return { count: state.count + 1 } } case 'decrement': { return { count: state.count - 1 } } default: { throw new Error(`Unhandled action type: ${action.type}`) } } } function CountProvider({ children }) { const [state, dispatch] = React.useReducer(countReducer, { count: 0 }) // NOTE: 이 값을 메모이제이션해야 할 수도 있습니다 // 자세히 알아보기: http://kcd.im/optimize-context const value = { state, dispatch } return <CountContext.Provider value={value}>{children}</CountContext.Provider> } export { CountProvider }
이 예시는 실제 상황을 보여주기 위해 일부러 과하게 설계한(오버엔지니어링한) 단순 예제입니다. 그렇다고 매번 이렇게 복잡해야 한다는 뜻은 아닙니다! 상황에 맞다면 useState를 쓰셔도 됩니다. 또한 어떤 provider는 위처럼 짧고 단순할 수 있고, 어떤 것은 훨씬 더 많은 훅과 로직을 갖고 더 복잡할 수도 있습니다.
제가 실제로 많이 본 context 사용 API는 대체로 이런 형태입니다:
jsimport * as React from 'react' import { SomethingContext } from 'some-context-package' function YourComponent() { const something = React.useContext(SomethingContext) }
하지만 저는 이게 더 나은 사용자 경험을 제공할 기회를 놓친 형태라고 생각합니다. 대신 이렇게 되어야 한다고 봅니다:
jsimport * as React from 'react' import { useSomething } from 'some-context-package' function YourComponent() { const something = useSomething() }
이렇게 하면 몇 가지 이점이 있습니다. 구현을 통해 보여드릴게요:
jsimport * as React from 'react' const CountContext = React.createContext() function countReducer(state, action) { switch (action.type) { case 'increment': { return { count: state.count + 1 } } case 'decrement': { return { count: state.count - 1 } } default: { throw new Error(`Unhandled action type: ${action.type}`) } } } function CountProvider({ children }) { const [state, dispatch] = React.useReducer(countReducer, { count: 0 }) // NOTE: 이 값을 메모이제이션해야 할 수도 있습니다 // 자세히 알아보기: http://kcd.im/optimize-context const value = { state, dispatch } return <CountContext.Provider value={value}>{children}</CountContext.Provider> } function useCount() { const context = React.useContext(CountContext) if (context === undefined) { throw new Error('useCount must be used within a CountProvider') } return context } export { CountProvider, useCount }
먼저 useCount 커스텀 훅은 React.useContext를 사용해 가장 가까운 CountProvider가 제공한 context 값을 가져옵니다. 그런데 값이 없다면, 해당 훅이 CountProvider 안에서 렌더링되는 함수 컴포넌트 내부에서 호출되지 않았다는 점을 알려주는 친절한 에러 메시지를 던집니다. 이건 거의 확실히 실수이기 때문에, 이렇게 에러 메시지를 제공하는 건 가치가 있습니다. #FailFast
훅을 사용할 수 있다면 이 섹션은 건너뛰세요. 하지만 React < 16.8.0을 지원해야 하거나, 클래스 컴포넌트에서 Context를 소비해야 한다면 render-prop 기반 API로 비슷한 걸 이렇게 만들 수 있습니다:
jsfunction CountConsumer({ children }) { return ( <CountContext.Consumer> {(context) => { if (context === undefined) { throw new Error('CountConsumer must be used within a CountProvider') } return children(context) }} </CountContext.Consumer> ) }
클래스 컴포넌트에서는 이렇게 사용합니다:
jsclass CounterThing extends React.Component { render() { return ( <CountConsumer> {({ state, dispatch }) => ( <div> <div>{state.count}</div> <button onClick={() => dispatch({ type: 'decrement' })}> Decrement </button> <button onClick={() => dispatch({ type: 'increment' })}> Increment </button> </div> )} </CountConsumer> ) } }
이건 훅이 나오기 전에는 제가 하던 방식이고 그럭저럭 잘 동작했습니다. 하지만 훅을 쓸 수 있다면 굳이 이걸 할 필요는 없다고 권합니다. 훅이 훨씬 낫습니다.
TypeScript에서 defaultValue를 생략할 때 생기는 문제를 피하는 방법을 보여주겠다고 약속했죠. 놀랍게도, 제가 제안한 방식을 따르면 기본적으로 그 문제가 사라집니다! 아예 문제가 아니게 됩니다. 확인해보세요:
tsimport * as React from 'react' type Action = { type: 'increment' } | { type: 'decrement' } type Dispatch = (action: Action) => void type State = { count: number } type CountProviderProps = { children: React.ReactNode } const CountStateContext = React.createContext< { state: State; dispatch: Dispatch } | undefined >(undefined) function countReducer(state: State, action: Action) { switch (action.type) { case 'increment': { return { count: state.count + 1 } } default: { throw new Error(`Unhandled action type: ${action.type}`) } } } function CountProvider({ children }: CountProviderProps) { const [state, dispatch] = React.useReducer(countReducer, { count: 0 }) // NOTE: 이 값을 메모이제이션해야 할 수도 있습니다 // 자세히 알아보기: http://kcd.im/optimize-context const value = { state, dispatch } return ( <CountStateContext.Provider value={value}> {children} </CountStateContext.Provider> ) } function useCount() { const context = React.useContext(CountStateContext) if (context === undefined) { throw new Error('useCount must be used within a CountProvider') } return context } export { CountProvider, useCount }
이렇게 하면 누가 useCount를 사용하든 undefined 체크를 할 필요가 없습니다. 우리가 대신 해주기 때문이죠!
type 오타는 어떻게 하나요?이쯤 되면 redux를 쓰던 분들은 이렇게 소리칠 겁니다: “액션 크리에이터는 어디 있죠?!” 액션 크리에이터를 구현하고 싶다면 저는 괜찮습니다. 하지만 저는 액션 크리에이터가 항상 불필요한 추상화라고 느꼈습니다. 게다가 TypeScript를 쓰고 액션 타입을 잘 정의해두었다면 굳이 필요하지 않습니다. 자동완성과 인라인 타입 에러를 받을 수 있으니까요!


저는 이런 방식으로 dispatch를 전달하는 걸 정말 좋아합니다. 부가적인 장점으로, dispatch는 이를 생성한 컴포넌트의 생명주기 동안 안정적(stable)이기 때문에 useEffect의 의존성 배열에 넣을지 말지 고민할 필요가 없습니다(넣든 빼든 차이가 없습니다).
만약 JavaScript에 타입을 붙이지 않고 있다면(아직 안 하고 있다면 고려해보는 걸 권합니다), 처리되지 않은 action type에 대해 던지는 에러는 안전장치(failsafe) 역할을 합니다. 그리고 다음 섹션을 계속 읽어보세요. 이것도 도움이 될 수 있습니다.
좋은 질문입니다. 비동기 요청을 해야 하고, 그 요청 과정에서 여러 번 dispatch를 해야 하는 상황이라면 어떻게 될까요? 호출하는 컴포넌트에서 처리할 수도 있겠지만, 그런 작업을 해야 하는 컴포넌트마다 그걸 매번 수작업으로 연결하는 건 꽤 성가실 겁니다.
제가 제안하는 방식은 context 모듈 안에 helper 함수를 만들어서 dispatch와 필요한 다른 데이터를 함께 전달받게 하고, 그 helper가 이런 처리를 책임지도록 하는 것입니다. 제가 진행하는 Advanced React Patterns 워크숍의 예시를 보시죠:
jsasync function updateUser(dispatch, user, updates) { dispatch({ type: 'start update', updates }) try { const updatedUser = await userClient.updateUser(user, updates) dispatch({ type: 'finish update', updatedUser }) } catch (error) { dispatch({ type: 'fail update', error }) } } export { UserProvider, useUser, updateUser }
그럼 이렇게 사용할 수 있습니다:
jsimport { useUser, updateUser } from './user-context' function UserSettings() { const [{ user, status, error }, userDispatch] = useUser() function handleSubmit(event) { event.preventDefault() updateUser(userDispatch, user, formState) } // more code... }
저는 이 패턴이 정말 마음에 듭니다. 회사에서 이 내용을 가르쳐줬으면 한다면 연락 주세요(또는 다음 워크숍을 위해 대기자 명단에 등록해 주세요)!
자, 최종 코드는 이렇게 됩니다:
jsimport * as React from 'react' const CountContext = React.createContext() function countReducer(state, action) { switch (action.type) { case 'increment': { return { count: state.count + 1 } } case 'decrement': { return { count: state.count - 1 } } default: { throw new Error(`Unhandled action type: ${action.type}`) } } } function CountProvider({ children }) { const [state, dispatch] = React.useReducer(countReducer, { count: 0 }) // NOTE: 이 값을 메모이제이션해야 할 수도 있습니다 // Learn more in http://kcd.im/optimize-context const value = { state, dispatch } return <CountContext.Provider value={value}>{children}</CountContext.Provider> } function useCount() { const context = React.useContext(CountContext) if (context === undefined) { throw new Error('useCount must be used within a CountProvider') } return context } export { CountProvider, useCount }
여기서 중요한 점은 CountContext를 export하지 않는다는 것입니다. 의도적으로 그렇게 했습니다. 저는 context 값을 제공하는 방법을 딱 하나, 소비하는 방법도 딱 하나만 노출합니다. 이렇게 하면 사람들이 의도한 방식으로 context 값을 사용하게 강제할 수 있고, consumer를 위한 유용한 유틸리티도 제공할 수 있습니다.
이 글이 도움이 되길 바랍니다! 기억하세요:
행운을 빕니다!
React를 정말 잘하게 되기
Kent C. Dodds가 작성
Kent C. Dodds는 JavaScript 소프트웨어 엔지니어이자 강사입니다. Kent는 양질의 소프트웨어 개발 도구와 실천 방법을 통해 세상을 더 나은 곳으로 만드는 방법을 수십만 명에게 가르쳐 왔습니다. 그는 유타에서 아내와 네 아이와 함께 살고 있습니다.