[React] useContext 활용하기

useContext를 활용해서 다크모드와 카드 플립 구현하기!
UMC 스터디를 통해 학습한 React useContext 개념과 실제 구현 과정에서 겪은 에러, 그리고 해결 방법을 정리해보자!

 

전역 상태 관리

전역 상태 관리란 특정 컴포넌트에서의 state 변화를 전역에 두고, 필요한 컴포넌트에서 사용할 수 있는 것이다.

React에서는 이를 위해서 Context API를 사용할 수 있다! 

 

useContext란?

React의 useContext는 Context API와 함께 전역 상태를 쉽게 공유할 수 있도록 도와주는 Hook이다.

 

> Props drilling 방지 <

리액트의 일반적인 데이터 흐름은 부모 컴포넌트에서 자식 컴포넌트로 props를 통해 단방향으로 흐른다.

컴포넌트 트리가 있다고 가정했을 때 공통적으로 필요한 전역적인 데이터가 있을 수 있다. 이 것을 일일이 props로 단계별로 전달해야 한다면 비효율적일 것이다. 이 문제를 props drilling이라고 한다. 리액트는 이러한 문제점을 해결해 주는 Context API를 제공한다.

즉 useContext는 컴포넌트마다 props를 내려보내지 않아도, 같은 데이터를 바로 꺼내서 쓸 수 있도록 해서 props drilling의 문제점을 줄여주는 방법이라고 할 수 있다.

 

그럼 왜 굳이 Props를 사용하는거지?

React 공식 홈페이지 에서는 context를 사용하면 컴포넌트를 재사용하기가 어려워진다고 말한다.

여러 레벨에 걸쳐 props를 넘기는 걸 대체하는 데에는 context보다 컴포넌트 합성이 더 간단한 해결책일 수도 있다.

이에 더해 아래와 같은 경우에는 Context 대신 Props를 사용하는 것이 더 효과적일 수 있다!

1. 간결한 데이터 전달 : 명확한 계층 구조에서 데이터를 넘겨줄 때 코드가 더 간결해질 수 있다.

2. 컴포넌트의 재사용성 증대 : Context에 의존하면 컴포넌트가 종속되어 재사용성이 떨어질 수 있다.

3. 데이터 흐름의 명확성 : props는 데이터가 어떻게 흘러가는지 명확하게 알 수 있다.

 


useContext 사용 예시

ThemeProvider.tsx 전체 코드

import { createContext, useContext, useState, type ReactNode } from "react";

export const THEME = {
  LIGHT: "LIGHT",
  DARK: "DARK",
} as const;

export type TTHEME = (typeof THEME)[keyof typeof THEME]; 

interface IThemeContext {
  theme: TTHEME;
  toggleTheme: () => void;
}

export const ThemeContext = createContext<IThemeContext | undefined>(undefined);

export const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const [theme, setTheme] = useState<TTHEME>(THEME.LIGHT);

  const toggleTheme = (): void => {
    setTheme((preTheme) =>
      preTheme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT
    );
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

export const useTheme = () => {
  const context = useContext(ThemeContext);

  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider.");
  }
  return context;
};

 


ThemeProvider.tsx : 단계별로 파헤치기

1) 리터럴 상수 선언

export const THEME = {
  LIGHT: "LIGHT",
  DARK: "DARK",
} as const;
  • as const를 사용해서 "LIGHT"와 "DARK"라는 리터럴 타입으로 고정

 

2) 타입 동기화

export type TTHEME = (typeof THEME)[keyof typeof THEME];
  • "LIGHT" | "DARK라는 유니온 타입을 얻을 수 있다.
  • THEME에서 추가하거나 수정하면 타입도 자동으로 업데이트할 수 있다!
  • 런타임에는 THEME.LIGHT처럼 을 그대로 사용, 타입은 "LIGHT" | "DARK"처럼 타입을 안전하게 사용 가능하다.
  • type of THEME : 객체 값의 타입을 가져온다.
// 예를 들어 아래와 같다면
const THEME = { LIGHT: "LIGHT", DARK: "DARK" } as const;

// type of THEME는 아래와 같은 타입이 됩니다. 
// as const 덕분에 각 값이 문자열 리터럴로 고정된다.
{
  readonly LIGHT: "LIGHT";
  readonly DARK: "DARK";
}

 

  • keyof typeof THEME : typeof THEME 타입의 키 이름만 뽑아서 유니온으로 묶는다.
type K = keyof typeof THEME; // K는 "LIGHT" | "DARK"

 

  • (typeof THEME)[keyof typeof THEME]

타입[키]의 형태로 객체 타입에서 특정 키의 값 타입을 꺼낼 때 사용하는 형태이다.

유니온 키를 넣으면 모든 값의 타입을 모은 유니온이 나온다. 즉, 객체의 모든 값 타입을 한 번에 빼내어 하나의 타입으로 묶을 수 있는 것이다. 이 패턴은 객체의 값 타입을 한 번에 뽑아내는 공식이다. 객체가 더 복잡해도 동일하게 적용할 수 있다.

// 조금 더 복잡한 예시
const STATUS = { OK: 200, NOT_FOUND: 404, FORBIDDEN: 403 } as const;

type StatusValue = (typeof STATUS)[keyof typeof STATUS];
// 200 | 404 | 403

 

 

3) Context에 담길 값의 형태 정의

interface IThemeContext {
  theme: TTHEME;
  toggleTheme: () => void;
}
  • Context를 사용할 개발자가 기대하는 구조는 theme: "LIGHT" | "DARK" 타입과 테마를 토글하는 함수일 것이다. interface를 통해 정의해준다.

 

4) Context 생성

export const ThemeContext = createContext<IThemeContext | undefined>(undefined);
  • 기본값을 undefined로 넣는 이유는 Provider 없이 사용했을 때 바로 에러를 던져 검사할 수 있도록 하기 위함이다.


5) Provider 컴포넌트

export const ThemeProvider = ({ children }: { children: ReactNode }) => {
  const [theme, setTheme] = useState<TTHEME>(THEME.LIGHT);
  • 전역 상태의 실제 보관소이다.
  • 제네릭을 사용해 테마 값이 둘 중 하나만 되도록 보장한다.
  const toggleTheme = (): void => {
    setTheme((preTheme) =>
      preTheme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT
    );
  };
  • 토글 함수를 사용한다 : 이전 상태 값을 인자로 받아서 다음 상태를 업데이트하도록 한다.
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};
  • Provider가 감싸고 있는 하위 트리에 value를 공급한다. 즉 트리 어디에서든 useContext를 통해 이 값을 바로 사용할 수 있다.

 

6) 커스텀 훅: useTheme

export const useTheme = () => {
  const context = useContext(ThemeContext);

  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider.");
  }
  return context;
};
  • 어디서든 const { theme, toggleTheme } = useTheme()로 사용할 수 있도록 제공해주는 함수이다.
  • Provider 없이 쓰면 useContext가 undefined를 반환하도록 해서 명확한 에러를 던진다.
Q. 왜 useContext(ThemeContext)를 그대로 return하지 않고 const 상수로 담을까?

1. 가독성 : context 라는 이름만 보고도 직관적으로 이해할 수 있다.

2. 재사용성 : if 문에서 처럼 재사용할 때 useContext를 반복 호출하지 않아도 된다.

3. 안전한 검증 : Provider가 없을 때 undefined인 경우를 먼저 확인하고, 명확한 에러를 던질 수 있다.

 

7) Provider 적용하기

// main.tsx
import "./index.css";
import App from "./App.tsx";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ThemeProvider } from "./context/ThemeProvider.tsx";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <ThemeProvider>
        <App />
    </ThemeProvider>
  </StrictMode>
);
  • main.tsx에서 Provider로 감싸주기!

트러블슈팅 과정

 

1. enum 오류

enum을 사용하면 "This syntax is not allowed when 'erasableSyntaxOnly' is enabled."이라는 오류 메시지가 뜨는 것을 볼 수 있다. erasableSyntaxOnly는 TypeScript 5.5부터 도입된 옵션으로, 런타임에 남지 않고 컴파일 시 지워질 수 있는 구문만 허용하겠다는 의미라고 한다!

 

TypeScript 코드는 결국 브라우저나 Node.js가 이해하는 JavaScript로 변환되어 실행된다.

순수 타입인 type, interface, as const 등은 컴파일 시 완전히 삭제되어 런타임에 아무런 영향이 없지만 enum, namespace 등은 JS 객체나 함수가 생성되어 런타임에 동작하는 코드가 추가된다.

모든 TS 기능을 지원하면 런타임에 추가 변환이 필요해 성능과 일관성이 떨어지기 때문에 erasableSyntaxOnly가 도입된 것이다.

 

해결 방법

따라서 enum을 리터럴 객체 + as const로 변경했다.

동일한 역할을 이렇게 구현하면 각 속성의 타입을 리터럴 타입으로 고정하며 런타임에는 아무 일도 하지 않는다. (그냥 const 객체 그대로)

따라서 런타임 추가 코드가 전혀 없으므로 erasableSyntaxOnly가 안전하게 지워도 된다고 판단해 오류가 없다!

export const THEME = {
  LIGHT: "LIGHT",
  DARK: "DARK",
} as const;

export type TTHEME = (typeof THEME)[keyof typeof THEME];

 

 

2. 유니온 타입 오류

유니온 타입을 한꺼번에 구조분해하려다가 타입 보장 실패한...애초에 타입 설계 자체를 잘못한....

+ 카드 플립 효과를 위한 Context를 추가하는 과정에서 생김

import { createContext, useContext, useState, type ReactNode } from "react";


export const THEME = {
  LIGHT: "LIGHT",
  DARK: "DARK",
} as const;

export const CARD = {
  FRONT: "FRONT",
  BACK: "BACK",
} as const;

export type TTHEME = (typeof THEME)[keyof typeof THEME]; // "LIGHT" | "DARK"
export type TCARD = (typeof CARD)[keyof typeof CARD]; // "FRONT" | "BACK"

interface IThemeContext {
  theme: TTHEME;
  toggleTheme: () => void;
}

interface ICardContext {
  card: TCARD;
  toggleCard: () => void;
}

// context 생성 --------> 잘못된 부분
export const ThemeContext = createContext<
  IThemeContext | ICardContext | undefined
>(undefined);

// context provider 생성
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
  // theme 상태 변경할건데 LIGHT 버전이 초기값
  const [theme, setTheme] = useState<TTHEME>(THEME.LIGHT);

  // 카드 돌릴건데 앞에 초기값
  const [card, setCard] = useState<TCARD>(CARD.FRONT);

  // 상태 토글 함수 지정 = 이전 theme이 light면 dark로 바꾸기
  const toggleTheme = (): void => {
    setTheme((preTheme) =>
      preTheme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT
    );
  };

  // 카드 상태 토글 함수 지정 = preCard가 front면 back으로 돌리기
  const toggleCard = (): void => {
    setCard((preCard) => (preCard === CARD.FRONT ? CARD.BACK : CARD.FRONT));
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme, card, toggleCard }}>
      {children}
    </ThemeContext.Provider>
  );
};

//제공해주는 함수를 만들어야 함!!!
export const useTheme = () => {
  const context = useContext(ThemeContext);

  // 에러처리도 같이 해주자
  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider.");
  }
  return context;
};

 

App.tsx

const { theme, toggleTheme, card, toggleCard } = useTheme();

 

문제점

  • IThemeContext와 ICardContext는 성격이 다른 상태(테마 vs 카드 플립)이다.
  • A | B(유니온 타입)으로 createContext를 만들면 하나의 Context 안에서 실제로 어떤 타입이 들어올지 불명확해지기 때문에 매번 타입 체크를 해야 한다. 
  • 현재 App.tsx가 useTheme()가 유니온 타입(IThemeContext | ICardContext )을 반환하는 걸로 되어 있어서, 디스트럭처링으로 toggleTheme, toggleCard를 동시에 꺼내려 하니까 "지금 값이 Theme일 수도, Card일 수도 있는데 둘 다 있다고 확정 못 한다"하고 막는 것이다! 

유니온 타입(Union Type)은 값이 여러 타입 중 하나일 수 있다는 의미를 갖는다. ( | )기호를 사용해 타입을 합친다.

따라서 내가 작성한 코드는 이 Context에는 IThemeContext 타입일 수도 있고 ICardContext 타입일 수도 있다

-> 즉, useTheme()가 반환하는 값은 둘 중 하나다! 라고 작성한 것이다.

근데 App.tsx에서는 toggleTheme와 toggleCard 둘 다 존재한다고 가정했기 때문에 에러가 난 것이었다...

 

해결 방법

유니온 context는 둘 중 하나의 타입만 보장하기 때문에 동시에 디스트럭처링이 불가했다.

따라서 나는 ThemeContext와 CardContext를 분리해서 각각의 훅을 제공했다. 

// ThemeContext.tsx
import { createContext, useContext, useState, type ReactNode } from "react";


export const THEME = {
  LIGHT: "LIGHT",
  DARK: "DARK",
} as const;

export type TTHEME = (typeof THEME)[keyof typeof THEME];

interface IThemeContext {
  theme: TTHEME;
  toggleTheme: () => void;
}


// context 생성
export const ThemeContext = createContext<IThemeContext| undefined>(undefined);

// context provider 생성
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
  
  // theme 상태 변경할건데 LIGHT 버전이 초기값
  const [theme, setTheme] = useState<TTHEME>(THEME.LIGHT);

  // 상태 토글 함수 지정 = 이전 theme이 light면 dark로 바꾸기
  const toggleTheme = (): void => {
    setTheme((preTheme) =>
      preTheme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT
    );
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme, card, toggleCard }}>
      {children}
    </ThemeContext.Provider>
  );
};

//제공 함수
export const useTheme = () => {
  const context = useContext(ThemeContext);

  // 에러처리
  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider.");
  }
  return context;
};
// CardContext.tsx
import { createContext, useContext, useState, type ReactNode } from "react";

export const CARD = { FRONT: "FRONT", BACK: "BACK" } as const;
export type TCARD = (typeof CARD)[keyof typeof CARD];

interface ICardContext {
  card: TCARD;
  toggleCard: () => void;
}

const CardContext = createContext<ICardContext | undefined>(undefined);

export function CardProvider({ children }: { children: ReactNode }) {
  const [card, setCard] = useState<TCARD>(CARD.FRONT);
  
  const toggleCard = () =>
    setCard((c) => (c === CARD.FRONT ? CARD.BACK : CARD.FRONT));
  
  return (
    <CardContext.Provider value={{ card, toggleCard }}>
      {children}
    </CardContext.Provider>
  );
}

export function useCard() {
  const context = useContext(CardContext);
  if (!context) throw new Error("useCard 훅은 CardProvider 안에서 사용하세요!");
  return context;
}

 

 

3. Fast refresh

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components. 

말 그대로 파일이 컴포넌트만 내보낼 때 빠른 새로고침 기능이 제대로 작동하며, 컴포넌트가 아닌 함수나 상수를 함께 내보낼 때는 해당 기능을 사용하려면 따로 파일을 분리해야 한다는 경고문이다.

Fast refresh란 코드 변경 시에 페이지 전체 새로고침 없이 상태를 유지하며 컴포넌트만 재렌더링해 주는 기능이다. 경고문이 떠도 동작에 문제는 없으나 개발 중에 컴포넌트의 상태 유지가 중요할 경우에는 component와 util을 파일로 분리하는 것이 좋다고 한다!

 

 

회고

처음에는 단순한 타입 오류라고만 생각했지만, 원인을 추적하면서 유니온 타입·교차 타입·컨텍스트 분리 여부 등 설계 단계의 선택이 결국 에러를 만든 것임을 깨달았다.
타입 정의가 곧 설계의 핵심이라는 사실을 다시 한 번 확인할 수 있었고 앞으로는 새로운 상태나 Context를 추가할 때 하나의 컨텍스트에 묶을지, 각각 분리할지부터 우선적으로 검토하며 구조를 설계해야겠다고 다짐했다!!

'React' 카테고리의 다른 글

[React] React + Vite + TypeScript 프로젝트 생성하기  (0) 2025.09.09
[React] React가 무엇일까?  (3) 2025.09.08