Thumbnail

8분

Complex Context APIs

최근에 UI 컴포넌트를 만드는 경험을 많이 했습니다. 주로 headless 컴포넌트 라이브러리를 많이 사용했었는데, 리액트 Context API를 사용하는 케이스가 많았습니다.

그러던 와중에 중첩된 다중 리액트 Context API를 사용하는 도중 예기치 않은 문제가 발생했습니다. 문제가 무엇이며, 어떻게 해결 해야될지에 대한 글을 작성하게 되었습니다.

Issue

컴포넌트를 확장한 또 다른 컴포넌트를 만들면서 동일 Context를 사용하게 되었는데, Context가 의도한 대로 동작하지 않았습니다.

다시 말해서, Consumer에서 의도한 Context를 가져오지 못하는 문제가 발생했습니다.

예를 들어, children을 트리거를 통해 자식 컴포넌트를 보여주거나 숨기는 간단한 컴포넌트(<Opener />)가 있습니다.

interface OpenerContextValue {
  open: boolean
  setOpen: React.Dispatch<React.SetStateAction<boolean>>
}
// Opener Context
const OpenerContext = React.createContext<OpenerContextValue | null>(null)
 
// Opener
function Opener({ children }: OpenerProps) {
  const [open, setOpen] = React.useState(false)
 
  return (
    <OpenerContext.Provider value={{ open, setOpen }}>
      <div>{children}</div>
    </OpenerContext.Provider>
  )
}

Compound 컴포넌트 형태로 <OpenerTrigger /><OpenerContent /> 컴포넌트를 만들어 보겠습니다.

<OpenerTrigger /><OpenerContent /> 컴포넌트(consumer)에서는 React.useContext를 이용하여 OpenerContext를 사용할 수 있습니다.

function OpenerTrigger({ children }: OpenerProps) {
  const { setOpen } = React.useContext(OpenerContext)
 
  return <button onClick={() => setOpen((prev) => !prev)}>{children}</button>
}
 
function OpenerContent({ children }: OpenerContentProps) {
  const { open } = React.useContext(OpenerContext)
 
  if (!open) {
    return null
  }
 
  return <div>{children}</div>
}

<Opener /> 컴포넌트는 문제없이 동작합니다.

function App() {
  return (
    <Opener>
      <OpenerTrigger>Open</OpenerTrigger>
      <OpenerContent>Content</OpenerContent>
    </Opener>
  )
}

그러나 만약 여러 중첩된 컴포넌트에서 <Opener />를 사용하게 된다면 어떨까요?

Consumer는 가장 가까운 Provider의 Context를 가져오게 되는데, 중첩되고 복잡한 컴포넌트 트리에서는 의도하지 않은 Context를 가져오게 될 수 있습니다.

좀 더 구체적으로 예를 들어보겠습니다.

<Opener />를 사용하여 <AlertOpener /> 컴포넌트를 만들어 보겠습니다.

interface AlertOpenerProps extends React.ComponentProps<typeof Opener.Root> {}
function AlertOpener(props: AlertOpenerProps) {
  return <Opener.Root {...props} />
}
 
interface AlertOpenerTriggerProps extends React.ComponentProps<typeof Opener.Trigger> {}
function AlertOpenerTrigger({ className, ...props }: AlertOpenerTriggerProps) {
  return <Opener.Trigger {...props} className={cn("bg-red-900", className)} />
}
 
interface AlertOpenerContentProps extends React.ComponentProps<typeof Opener.Content> {}
function AlertOpenerContent({ className, ...props }: AlertOpenerContentProps) {
  return <Opener.Content {...props} className={cn("bg-red-500 text-white", className)} />
}

마찬가지로 <AlertOpener />는 단독으로 사용 시 문제없이 정상 동작합니다.

function App() {
  return (
    <AlertOpener>
      <AlertOpenerTrigger>Open</AlertOpenerTrigger>
      <AlertOpenerContent>Content</AlertOpenerContent>
    </AlertOpener>
  )
}

만약 <AlertOpener /> 컴포넌트 내에서 <Opener /> 컴포넌트를 다음과 같이 사용할 경우는 어떻게 될까요?

function App() {
  return (
    <AlertOpener>
      <Opener>
        <OpenerTrigger>Opener Open</OpenerTrigger>
        <OpenerContent>
          Opener Content
          <AlertOpenerTrigger>AlertOpener Open</AlertOpenerTrigger>
          <AlertOpenerContent>AlertOpener Content</AlertOpenerContent>
        </OpenerContent>
      </Opener>
    </AlertOpener>
  )
}

wrong-case-1

렌더링은 문제 없이 동작합니다. 그러나 AlertOpenerTrigger를 클릭하면 의도했던 AlertOpenerContent가 렌더링되는 대신 OpenerContent가 닫히게 됩니다.

그 이유는 컴포넌트 트리에서 볼 수 있습니다.

wrong-case-2

AlertOpenerTrigger 위치를 코드에서 보게 되면 가장 가까운 Context Provider는 <AlertOpener />에서 사용된 Provider가 아닌 <Opener />에서 제공되는 Provider입니다.

코드를 풀어서 보면 조금 더 이해하기 쉬울 것 같습니다.

function App() {
  return (
    <OpenerContext.Provider>
      {/* AlertOpener의 Provider */}
      <div>
        <OpenerContext.Provider>
          {/* Opener의 Provider */}
          <div>
            <button>Opener Open</button> {/* Opener의 Trigger */}
            <div>
              {/* Opener의 Content */}
              Opener Content
              <button>AlertOpener Open</button> {/* AlertOpener의 Trigger */}
              <div>AlertOpener Content</div> {/* AlertOpener의 Content */}
            </div>
          </div>
        </OpenerContext.Provider>
      </div>
    </OpenerContext.Provider>
  )
}

wrong-case-diagram

위에서 말한 consumer는 가장 가까운 Provider의 Context를 가져온다는 원칙에 따라 <AlertOpenerTrigger />는 가장 가까운 <Opener />에서 제공되는 Context를 가져오게 됩니다. 따라서 AlertOpenerTrigger를 클릭하면 AlertOpenerContent가 렌더링되는 대신 OpenerContent가 닫히게 됩니다.

예제 사이트는 여기서 확인하실 수 있습니다.

이런 구조를 사용해야 하는 경우라면 어떻게 해결을 해야할까요?

solution

1. brute-force

쉬운 접근법은 <AlertOpener />의 Context를 <Opener />의 Context로 덮어쓰는 것입니다.
즉, <Opener />에 Context를 주입하여 그 Context를 사용하도록 만드는 것입니다.

interface OpenerContextValue {
  open: boolean
  setOpen: React.Dispatch<React.SetStateAction<boolean>>
}
 
const createOpenerContext = () => React.createContext<OpenerContextValue | null>(null)
 
function Opener({ className, DefaultContext = createOpenerContext() }: OpenerProps) {
  const [open, setOpen] = React.useState(false)
 
  return (
    <DefaultContext.Provider value={{ open, setOpen }}>
      <div>{children}</div>
    </DefaultContext.Provider>
  )
}

<Opener />는 props로 DefaultContext를 받습니다.

만약 DefaultContext가 존재한다면 그 Context를 사용하고, 존재하지 않는다면 그 때 OpenerContext를 만들어 사용하도록 했습니다.

<Opener />를 사용하여 확장한 컴포넌트에서는 createOpenerContext를 이용하여 Context를 만들어 DefaultContext로 넘겨주면 됩니다.

good-case-brute-force

위 issue 예시를 해결하기 위해서 <AlertOpener />는 다음과 같은 코드가 나올 것입니다.

const AlertOpenerContext = Opener.createOpenerContext()
 
interface AlertOpenerProps extends React.ComponentProps<typeof Opener.Root> {}
 
function AlertOpener(props: AlertOpenerProps) {
  return <Opener.Root {...props} DefaultContext={AlertOpenerContext} />
}
 
interface AlertOpenerTriggerProps extends React.ComponentProps<typeof Opener.Trigger> {}
 
function AlertOpenerTrigger(props: AlertOpenerTriggerProps) {
  return <Opener.Trigger {...props} DefaultContext={AlertOpenerContext} />
}
 
interface AlertOpenerContentProps extends React.ComponentProps<typeof Opener.Content> {}
 
function AlertOpenerContent(props: AlertOpenerContentProps) {
  return <Opener.Content {...props} DefaultContext={AlertOpenerContext} />
}

이처럼 간단한 Context 문제는 해결할 순 있지만 복잡하게 중첩된 Context를 사용하는 경우에는 코드가 매우 복잡해질 것입니다.

2. Context Scope

@radix-ui/react-context

radix-ui에서는 이러한 중첩 동일 Context내에서 의도한 Context 가져오기의 해결방법은 위 brute-force 방법과는 크게 다르지 않습니다.

다만, 복잡한 context 구조에도 쉽게 만들고 관리할 수 있도록 설계한 createContextScope hook을 만들어 사용합니다.

Scope

createContextScope에서 중요한 컨셉 중 하나는 Scope입니다.

Scope는 scope내에서 함께 사용되는 context의 배열을 나타내는 타입입니다.

Scope의 타입은 다음과 같습니다.

type Scope<C = any> = { [scopeName: string]: React.Context<C>[] };
 
// examples
interface ContextValue {
  ...
}
const scope: Scope<ContextValue> = {
  Opener: [OpenerContext],
};

createContextScope

createContextScope

createContextScope hook의 인자와 반환 값을 살펴보면 다음과 같습니다.

  1. createContextScopescopeNamecreateContextScopeDeps(사용되는 createContextScope 디팬던시 배열)을 인자로 받아 createContextcreateScope를 반환하는 Custom hook입니다.
  2. createContextcreateContextScope의 인자로 받은 scopeName을 기반으로 scope에서 Context를 찾아 ProvideruseContext를 반환하는 함수입니다.
  3. createScopecreateContextScope의 인자로 받은 scopeName을 기반으로 scope에서 Context를 찾아 인자로 받은 createContextScopeDeps와 compose하는 hook을 반환하는 함수입니다.
function createContextScope(scopeName, createContextScopeDeps = []) {
  let defaultContexts = []
 
  function createContext(rootComponentName: string, defaultContext?: ContextValueType) {
    const BaseContext = React.createContext<ContextValueType | undefined>(defaultContext)
 
    const index = defaultContexts.length
    defaultContexts = [...defaultContexts, defaultContext]
 
    function Provider({ scope, children, ...props }) {
      const Context = scope[scopeName][index] || BaseContext
      const value = React.useMemo(() => context, Object.values(context))
 
      return <Context.Provider value={value}>{children}</Context.Provider>
    }
 
    function useContext(consumerName: string, scope) {
      const Context = scope[scopeName][index] || BaseContext
      const context = React.useContext(Context)
 
      if (context) return context
      if (defaultContext !== undefined) return defaultContext
    }
 
    return [Provider, useContext]
  }
 
  function createScope() {
    const scopeContexts = defaultContexts.map((defaultContext) =>
      React.createContext(defaultContext)
    )
    return function useScope(scope) {
      const contexts = scope[scopeName] || scopeContexts
      return React.useMemo(
        () => ({ [`__scope${scopeName}`]: { ...scope, [scopeName]: contexts } }),
        [scope, contexts]
      )
    }
  }
 
  return [createContext, composeContextScopes(createScope, ...createContextScopeDeps)]
}

createContextScope 내부에는 defaultContexts라는 배열 변수를 관리합니다.

이 배열은 createContext를 호출할 때마다 생성된 Context를 추가합니다.

그리고 내부에서는 이 배열을 기반으로 동작하며 createContext와 scope를 반환합니다.

createContextScope - createContext

createContextScope의 인자로 받은 scopeName을 기반으로 scope에서 Context를 찾아 ProvideruseContext를 반환하는 함수입니다.

let defaultContexts: any[] = [];
 
function createContext(defaultContext) {
  const BaseContext = React.createContext<ContextValueType | undefined>(defaultContext);
 
  const index = defaultContexts.length;
  defaultContexts = [...defaultContexts, defaultContext];
 
  function Provider({ scope, ...}) {
    const Context = scope?.[scopeName][index] || BaseContext;
 
    ...
  }
 
  function useContext(scope, ...) {
    const Context = scope?.[scopeName][index] || BaseContext;
 
    ...
  }
}

createContext는 인자로 rootComponentNamedefaultContext를 받습니다.

defaultContext가 있는 경우 해당 defaultContext를 사용합니다.
defaultContext가 없다면 BaseContext(신규 생성)를 사용합니다.

생성된 context(defaultContext 또는 BaseContext)는 defaultContexts(배열)에 추가됩니다.

createContextProvideruseContext를 반환하는데 Provider는 인자로 받은 scope에서 해당 scopeName의 Contexts 중에서 해당하는 Context의 Provider를 생성, 반환합니다.
만약에 해당하는 Context가 없다면 BaseContext(신규 생성)를 사용합니다.

useContext는 인자로 받은 scope에서 해당 scopeName의 Contexts 중에서 해당하는 Context에 대해 useContext를 호출하여 반환합니다.
마찬가지로 해당하는 Context가 없다면 BaseContext(신규 생성)를 사용합니다.

createContextScope - createScope

createScopecreateContextScope의 인자로 받은 scopeName을 기반으로 scope의 contexts를 찾아 인자로 받은 createContextScopeDeps와 compose하는 hook을 반환하는 함수입니다.

createScope는 별도의 인자 없이 scope를 인자로 받는 hook을 반환하는 함수입니다.

createScope에서는 defaultContextscreateContextScopeDeps와 합쳐서 { [scopeName: string]: React.Context<C>[] } 형태의 scope를 만들어주는 hook을 반환하는 함수입니다.

createContextScope는 반환할 때 createScope를 반환하는데 만약 createContextScopeDeps가 있다면 createScopecomposeContextScopes를 통해 합쳐서 반환합니다.

composeContextScopes는 인자로 받은 배열을 순회하면서 { [__scope${baseScope.scopeName}]: composedScopes }와 같이 scope를 baseScope 반환합니다.

baseScope는 인자로 받은 배열의 첫 번째 Scope로 호출부를 참고 하면 현재 createContextScopecreateScope를 의미합니다.

function createContextScope(...) {
  ...
 
  return [createContext, composeContextScopes(createScope, ...createContextScopeDeps)] as const;
}

처음 보면 코드 자체로 이해하기 쉽지 않아서 위 예시 <Opener />, <AlertOpener /> 컴포넌트를 이용해서 확인해보겠습니다.

const OPENER_NAME = "Opener"
 
const [createOpenerContext, createOpenerScope] = createContextScope(OPENER_NAME)
 
interface OpenerContextValue {
  open: boolean
  setOpen: React.Dispatch<React.SetStateAction<boolean>>
}
const [OpenerProvider, useOpenerContext] = createOpenerContext<OpenerContextValue>(OPENER_NAME)

defaultContext를 인자로 주지 않았기 때문에 defaultContexts는 비어있는 배열 상태입니다.

defaultContexts = []

<Opener />__scopeOpener scope를 인자로 받아 OpenerProvider에게 전달합니다.

이 때 __scopeOpener 모습은 다음과 같습니다.

__scopeOpener = {
  Opener: [BaseContext],
}
const Opener: React.FC<OpenerProps> = ({
  __scopeOpener,
  className,
  children,
}: ScopedProps<OpenerProps>) => {
  const [open, setOpen] = React.useState(false)
 
  return (
    <OpenerProvider scope={__scopeOpener} open={open} setOpen={setOpen}>
      <div>{children}</div>
    </OpenerProvider>
  )
}

만약 __scopeOpener가 들어온다면 OpenerProvider는 해당 scope에서 scopeName(OPENER_NAME) Context를 찾아서 Provider를 생성합니다.

그렇지 않다면 신규 Context(BaseContext)를 생성하여 Provider를 생성합니다.

defaultContexts에는 아직 아무것도 없기 때문에 신규 Context(BaseContext)를 사용합니다.

const { scope } = props
const Context = scope?.[scopeName][index] || BaseContext

<OpenerTrigger />__scopeOpener scope를 인자로 받아 useOpenerContext를 사용해 context를 가져옵니다.

const OpenerTrigger: React.FC<OpenerProps> = ({
  __scopeOpener,
  className,
  children,
}: ScopedProps<OpenerProps>) => {
  const { setOpen } = useOpenerContext(TRIGGER_NAME, __scopeOpener)
 
  return <button onClick={() => setOpen((prev) => !prev)}>{children}</button>
}

만약 __scopeOpener가 들어온다면 useOpenerContext는 해당 scope에서 scopeName(OPENER_NAME) Context를 찾아서 useContext를 호출하여 context를 반환합니다.

그렇지 않다면 신규 Context(BaseContext)를 생성하여 useContext를 호출하여 context를 반환합니다.

defaultContexts에는 아직 아무것도 없기 때문에 신규 Context(BaseContext)를 사용합니다.

const Context = scope?.[scopeName][index] || BaseContext
const context = React.useContext(Context)

<AlertOpener /><Opener />를 확장하여 만들어보겠습니다.

<Opener />에서는 createOpenerScope를 export합니다.

// Opener.tsx
const [createOpenerContext, createOpenerScope] = createContextScope(OPENER_NAME)
 
export { createOpenerScope }

<AlertOpener />에서는 createOpenerScope를 인자로 넣어 createContextScope를 호출합니다.

그리고 createOpenerScope를 호출하여 <Opener />의 scope({ __scopeOpener: { Opener: [...contexts] } })를 가져올 수 있는 useOpenerScope를 만듭니다.

const [createAlertOpenerContext, createAlertOpenerScope] = createContextScope(ALERTOPENER_NAME, [
  createOpenerScope,
]);
 
const useOpenerScope = createOpenerScope();
 
const AlertOpener: React.FC<AlertOpenerProps> = ({
  __scopeAlertOpener,
  ...props
}: ScopedProps<AlertOpenerProps>) => {
  const openerScope = useOpenerScope(__scopeAlertOpener);
  return <Opener.Root {...openerScope} {...props} />;
};

<AlertOpener />도 마찬가지로 확장되어 사용하는 것을 염두해 두고 __scopeAlertOpener scope를 prop으로 받아 useOpenerScope를 호출합니다.

이 때 __scopeAlertOpener를 인자로 받은 useOpenerScope는 baseScope(Opener)를 기준으로 scope를 compose하여 반환합니다.

openerScope = {
  __scopeOpener: {
    ...{ Opener: [...] },
    ...{ AlertOpener: [...] }
  }
}

이를 통해 <AlertOpener />에서 <Opener />와 함께 사용 의도한 context를 가져와서 사용할 수 있습니다.

const AlertOpenerTrigger: React.FC<AlertOpenerTriggerProps> = ({
  __scopeAlertOpener,
  ...props
}: ScopedProps<AlertOpenerTriggerProps>) => {
  const openerScope = useOpenerScope(__scopeAlertOpener)
  return <Opener.Trigger {...openerScope} {...props} />
}

<AlertOpenerTrigger />에서 useOpenerScope를 사용하여 scope를 <Opener.Trigger /> prop으로 전달합니다.

<Opener.Trigger />에서는 위에도 작성되어 있지만 scope 인자를 받아 useOpenerContext를 사용하여 context를 가져옵니다.

scope가 존재하고 scope내에 Opener가 있기 때문에 해당 scope의 context를 사용합니다.

good-case-create-context-scope

사실 consumer가 scope를 기반으로 context를 선택하고 가져오기 때문에 consumer는 가장 가까운 Provider의 Context를 가져온다라는 원칙을 따르지 않고 있습니다.

하지만 compound component로 구성된 컴포넌트의 경우 위와 같은 case가 더 복잡한 형태로 많이 발생합니다. 그런 경우 이 방법이 좋은 해답이 될 수 있을 것 같단 생각이 들게 됩니다.

마무리하며

radix-ui의 createContextScope는 리액트 Context API의 consumer는 가장 가까운 Provider의 Context를 가져온다라는 원칙을 따르지 않고, consumer는 scope를 기반으로 context를 선택하고 가져온다라는 원칙을 사용했다는 것입니다. 해결 방법을 찾아가는 과정에서 해결법이 크게 다르진 않았지만 좀 더 확장성 있고 안전한 해결법이었습니다.

중첩된 다중 Context를 사용할 때 발생할 수 있는 문제를 경험하고 이를 해결하기 위해 여러가지 방법을 찾은 결과를 정리해 보았습니다. 정확하게 문제를 파악했는지, 문제 해결방법을 잘 이해했는지는 잘 모르겠지만 몇 가지라도 도움이 되었으면 좋겠습니다. 저는 주로 이런 라이브러리를 가져다 쓸 줄만 알았지만 어떤 원리로 동작하고 해결하는지 알아보진 못했습니다. 이번 기회에 시간이 좀 걸렸지만 좋은 해결 방법을 확인할 수 있어서 좋았습니다.

사용된 예제 코드는 여기서 확인할 수 있습니다.

마지막 업데이트

3/11/2023


Avatar

JHSeo

배우는 것을 좋아하고 관심이 많은 웹 엔지니어 입니다. 느리더라도 꾸준하게 성장하려고 노력하는 개발자입니다.