React/타입스크립트로 배우는 리액트(React.js) : 기초부터 최신 기술까지

섹션 13. 전역 상태 관리 - Context API

favor 2026. 6. 9. 02:27

전역 상태 관리가 필요한 이유

CounOutside, CountDisplay, CountButton에서 사용할 상태를 정의

ㄴ 공통 부모 컴포넌트인 App 컴포넌트 내부에 정의

 

Count, CountGroup 컴포넌트는 상태값을 사용하지 않지만 props를 받아서 하위 컴포넌트로 전달해야 함 

props drilling

 

props drilling 해결 방법

  • Context API
  • Redux ToolKit
  • Zustand

 

Context API

먼저 Context 객체를 생성한 후에 해당 객체를 통해 데이터의 공유 범위와 공유할 데이터를 설정

--> Context 객체의 공유 범위에 포함된 다른 컴포넌트들은 공유하는 데이터를 가져와서 사용할 수 있음 

 

Context 객체를 받을 변수는 대문자로 시작 

ㄴ 컴포넌트처럼 사용할 것이기 때문

 

기존 App.tsx 코드 

// App.tsx

import { createContext, useState } from "react";
import Count from "./components/Count";
import CountOutside from "./components/CountOutside";

export default function App() {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount((prevCount) => prevCount + 1);
    console.log(count);
  };

  const decrement = () => {
    setCount((prevCount) => prevCount - 1);
  };
  const reset = () => {
    setCount(0);
  };

  return (
    <>
      <Count
        count={count}
        increment={increment}
        decrement={decrement}
        reset={reset}
      />
      <CountOutside count={count} />
    </>
  );
}

 

변경 App.tsx 코드 

// App.tsx

import { createContext, useState } from "react";
import Count from "./components/Count";
import CountOutside from "./components/CountOutside";

type CounterContextType = {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}
export const CounterContext = createContext<CounterContextType | null>(null);

export default function App() {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount((prevCount) => prevCount + 1);
    console.log(count);
  };

  const decrement = () => {
    setCount((prevCount) => prevCount - 1);
  };
  const reset = () => {
    setCount(0);
  };

  return (
    <>
      <CounterContext value={{ count, increment, decrement, reset }}>
        <Count
          count={count}
          increment={increment}
          decrement={decrement}
          reset={reset}
        />
        <CountOutside count={count} />
      </CounterContext>
    </>
  );
}

^ CounterContext 내부의 value에는 공유할 변수들을 객체 형태로 전달 

 

기존 CountOutside.tsx

// CountOutside.tsx

export default function CountOutside({ count }: { count: number }) {
  return (
    <>
      <h1>CountOutside: {count}</h1>
    </>
  );
}

 

변경 CountOutside.tsx

// CountOutside.tsx

import { useContext } from "react";
import { CounterContext } from "../App";

export default function CountOutside() {

  const { count } = useContext(CounterContext)!;

  return (
    <>
      <h1>CountOutside: {count}</h1>
    </>
  );
}

^ CounterContext는 CounterContextType or null 타입 --> null이 아님을 명시해주기 위해 ! 붙이기

 

기존 CountDisplay.tsx 코드

// CountDisplay.tsx

export default function CountDisplay({
  count,
}: {
  count: number;
}) {
  return (
    <>
      <h1>Count: {count}</h1>
    </>
  );
}

 

변경 CountDisplay.tsx 코드

// CountDisplay.tsx

import { useContext } from "react";
import { CounterContext } from "../App";

export default function CountDisplay() {

  const { count } = useContext(CounterContext)!;
  
  return (
    <>
      <h1>Count: {count}</h1>
    </>
  );
}

 

기존 CountButton.tsx 코드

// CountButton.tsx

export default function CountButton({
  increment,
  decrement,
  reset,
}: {
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}) {
  return (
    <>
      <button onClick={decrement}>감소</button>
      <button onClick={reset}>리셋</button>
      <button onClick={increment}>증가</button>
    </>
  );
}

 

변경 CountButton.tsx

// CountButton.tsx

import { useContext } from "react";
import { CounterContext } from "../App";

export default function CountButton() {

  const { increment, decrement, reset } = useContext(CounterContext)!;

  return (
    <>
      <button onClick={decrement}>감소</button>
      <button onClick={reset}>리셋</button>
      <button onClick={increment}>증가</button>
    </>
  );
}

 

Context API 개선

Context는 별도의 파일로 분리해서 작성하는 게 코드의 유지보수 및 가독성 측면에서 좋음 

 

src/context/counter/

  • CounterContext.ts
    • 전역 데이터 슬롯 정의
    • 어떤 데이터와 어떤 함수들이 전역으로 공유될지 규격 제한
  • CounterProvider.tsx
    • 상태 격리 및 캡슐화
    • 실제로 리액트의 useState 메모리를 할당하고, 비즈니스 로직(데이터를 변경하는 함수들)을 모아둔 역할
  • useCounter.ts
    • 캡슐화 보호 및 데이터 접근 추상화
    • 하위 컴포넌트가 Context 내부 데이터에 접근할 때 쓰는 전용 인터페이스(커스텀 훅)이자 예외 처리기

 

// src/context/counter/CounterContext.ts

import { createContext } from "react";

type CounterContextType = {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
};
export const CounterContext = createContext<CounterContextType | null>(null);
// src/context/counter/CounterProvider.tsx

import { useState } from "react";
import { CounterContext } from "./CounterContext";

export default function CounterProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount((prevCount) => prevCount + 1);
    console.log(count);
  };

  const decrement = () => {
    setCount((prevCount) => prevCount - 1);
  };
  const reset = () => {
    setCount(0);
  };

  return (
    <>
      <CounterContext value={{ count, increment, decrement, reset }}>{children}</CounterContext>
    </>
  );
}
// src/context/counter/useCounter.ts

import { useContext } from "react";
import { CounterContext } from "./CounterContext";

export default function useCounter() {
  const context = useContext(CounterContext);

  if (!context) {
    throw new Error("useCounter는 CounterProvider 안에서만 사용 가능합니다"); // null 방지
  } 

  return context;
}

 

변경 App.tsx 코드

// App.tsx

import Count from "./components/Count";
import CountOutside from "./components/CountOutside";
import CounterProvider from "./context/counter/CounterProvider";

export default function App() {
  return (
    <>
      <CounterProvider>
        <Count />
        <CountOutside />
      </CounterProvider>
    </>
  );
}

^ CounterContext를 호출하는 것이 아닌 CounterProvider를 호출

 

변경 CountOutside.tsx 코드

// CountOutside.tsx

import useCounter from "../context/counter/useCounter";

export default function CountOutside() {

  const { count } = useCounter();

  return (
    <>
      <h1>CountOutside: {count}</h1>
    </>
  );
}

 

Context API 리렌더링

불필요한 리렌더링 : 상태 변화와 직접적인 관련이 없거나 화면 인터페이스가 변하지 않는데도 부모 컴포넌트의 영향으로 발생하는 리렌더링 

ㄴ React.memo 함수 또는 useCallback 훅을 사용하여 최적화

 

CounterProvider 내부의 상태값(count)이 변경됨 --> CounterProvider 컴포넌트 자체가 리렌더링 --> value={{ count, increment, ... }} 안의 객체가 매번 새로운 메모리 주소로 다시 생성됨 --> CounterContext를 사용하는 모든 하위 컴포넌트는 새로운 value를 받았다고 판단 --> 전부 강제 리렌더링  --> 숫자(count)를 사용하지 않는 CountButton 컴포넌트까지 렌더링 

 

// src/context/counter/CounterContext.ts

import { createContext } from "react";

type CounterContextType = {
  count: number;
};
type CounterContextActionType = {
  increment: () => void;
  decrement: () => void;
  reset: () => void;
};
export const CounterContext = createContext<CounterContextType | null>(null);
export const CounterContextAction = createContext<CounterContextActionType | null>(null);
// src/context/counter/CounterProvider.tsx

import { useMemo, useState } from "react";
import { CounterContext, CounterContextAction } from "./CounterContext";

export default function CounterProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [count, setCount] = useState(0);
  const increment = () => {
    setCount((count) => count + 1);
  };
  const decrement = () => {
    setCount((count) => count - 1);
  };
  const reset = () => {
    setCount(0);
  };

  const memoization = useMemo(
    () => ({ count, increment, decrement, reset }),
    [],
  );

  return (
    <>
      <CounterContextAction value={memoization}>
        <CounterContext value={{ count }}>{children}</CounterContext>
      </CounterContextAction>
    </>
  );
}

^ useMemo 훅을 사용하여 메모이제이션

// src/context/counter/useCounter.ts

import { useContext } from "react";
import { CounterContext, CounterContextAction } from "./CounterContext";

export function useCounter() {
  const context = useContext(CounterContext);
  if (!context) {
    throw new Error("useCounter는 CounterProvider 안에서만 사용가능합니다.");
  }
  return context;
}

export function useCounterAction() {
  const context = useContext(CounterContextAction);
  if (!context) {
    throw new Error("useCounter는 CounterProvider 안에서만 사용가능합니다.");
  }
  return context;
}

 

Context API reducer

// src/reducer/counterReducer.ts

export default function counterReducer(
  count: number,
  action: { type: string },
) {
  switch (action.type) {
    case "INCREMENT":
      return count + 1;
    case "DECREMENT":
      return count - 1;
    case "RESET":
      return 0;
    default:
      return count;
  }
}

 

// src/context/counter/CounterContext.ts

import { createContext } from "react";

type CounterContextType = {
  count: number;
};
type CounterContextActionType = React.ActionDispatch<[action: { type: string }]>;

export const CounterContext = createContext<CounterContextType | null>(null);
export const CounterContextAction =
  createContext<CounterContextActionType | null>(null);
// src/context/counter/CounterProvider.tsx

import { useReducer } from "react";
import { CounterContext, CounterContextAction } from "./CounterContext";
import counterReducer from "../../reducer/counterReducer";

export default function CounterProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [count, countDispatch] = useReducer(counterReducer, 0);
  
  return (
    <>
      <CounterContextAction value={ countDispatch }>
        <CounterContext value={{ count }}>{ children }</CounterContext>
      </CounterContextAction>
    </>
  );
}
// src/context/counter/useCounter.ts

import { useContext } from "react";
import { CounterContext, CounterContextAction } from "./CounterContext";

export function useCounter() {
  const context = useContext(CounterContext);
  if (!context) {
    throw new Error("useCounter는 CounterProvider 안에서만 사용가능합니다.");
  }
  return context;
}

export function useCounterAction() {
  const context = useContext(CounterContextAction);
  if (!context) {
    throw new Error("useCounter는 CounterProvider 안에서만 사용가능합니다.");
  }
  return context;
}
// CountButtont.sx

import { useCounterAction } from "../context/counter/useCounter";

export default function CountButton() {
  console.log("CountButton");
  const countDispatch = useCounterAction();
  return (
    <>
      <button onClick={() => countDispatch({ type: "DECREMENT" })}>감소</button>
      <button onClick={() => countDispatch({ type: "RESET" })}>리셋</button>
      <button onClick={() => countDispatch({ type: "INCREMENT" })}>증가</button>
    </>
  );
}

 

Context API 2개 적용해보기

// src/context/theme/ThemeContext.ts

import { createContext } from "react";

type ThemeContextType = {
  theme: string;
};

type ThemeContextActionType = {
  changeTheme: () => void;
};

export const ThemeContext = createContext<ThemeContextType | null>(null);
export const ThemeContextAction = createContext<ThemeContextActionType | null>(null);
// src/context/theme/ThemeProvider.tsx

import React, { useMemo, useState } from "react";
import { ThemeContext, ThemeContextAction } from "./ThemeContext";

export default function ThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {

  const [theme, setTheme] = useState("light");
  const changeTheme = () => {
    setTheme((prevTheme) => prevTheme === "light" ? "dark" : "light");
  }
  const memoization = useMemo(() => ({ changeTheme }), []);

  return (
    <>
      <ThemeContextAction value={ memoization }>
        <ThemeContext value={{ theme }} >{children}</ThemeContext>
      </ThemeContextAction>
    </>
  );
}
// src/context/theme/useTheme.ts

import { useContext } from "react";
import { ThemeContext, ThemeContextAction } from "./ThemeContext";

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme는 ThemeProvider 안에서만 사용가능합니다.");
  }
  return context;
}

export function useThemeAction() {
  const context = useContext(ThemeContextAction);
  if (!context) {
    throw new Error("useTheme는 ThemeProvider 안에서만 사용가능합니다.");
  }
  return context;
}

 

// Theme.tsx

import { useTheme } from "../context/theme/useTheme";

export default function Theme () {

  const { theme } = useTheme();

  return (
    <>
      <h1>Theme: { theme }</h1>
    </>
  );
}
// ThemeButton.tsx

import { useThemeAction } from "../context/theme/useTheme";

export default function ThemeButton() {
  const { changeTheme } = useThemeAction();

  return (
    <>
      <button onClick={changeTheme}>테마 변경</button>
    </>
  );
}

 

// App.tsx

import Count from "./components/Count";
import CountOutside from "./components/CountOutside";
import Theme from "./components/Theme";
import ThemeButton from "./components/ThemeButton";
import CounterProvider from "./context/counter/CounterProvider";
import ThemeProvider from "./context/theme/ThemeProvider";

export default function App() {
  return (
    <>
      <ThemeProvider>
        <CounterProvider>
          <Count />
          <CountOutside />
          <Theme />
          <ThemeButton />
        </CounterProvider>
      </ThemeProvider>
    </>
  );
}

 

UserProfile 컨텍스트 객체 만들기 

1. 애플리케이션에서 사용할 타입 정의 

ㄴ src/types/

// src/types/settings.d.ts

interface UserPreferences { // 서비스 안에서 다루는 데이터의 규격만 정의 == 데이터 모델 
  language: "ko" | "en" | "ja";
  fontSize: "small" | "medium" | "large";
  notifications: {
    email: boolean;
    push: boolean;
    desktop: boolean;
  };
  colorScheme: "system" | "light" | "dark";
}

// 설정을 전역에 공유하기 위함 
interface PreferencesContextType { // 상태(State) 공유용 컨텍스트 타입
  preferences: UserPreferences;
}

interface PreferencesContextActionType { // 기능(Action) 공유용 컨텍스트 타입 
  updateLanguage: (language: UserPreferences["language"]) => void;
  updateFontSize: (size: UserPreferences["fontSize"]) => void;
  updateNotifications: (
    key: keyof UserPreferences["notifications"],
    value: boolean,
  ) => void;
  updateColorScheme: (scheme: UserPreferences["colorScheme"]) => void;
}

 

2. 컨텍스트 객체, 프로바이더 컴포넌트, 커스텀 훅 생성

// src/context/setting/SettingContext.ts

import { createContext } from "react";

export const SettingContext = createContext<PreferencesContextType | null>(
  null,
);

export const SettingContextAction =
  createContext<PreferencesContextActionType | null>(null);
// src/context/setting/SettingProvider.tsx

import React, { useMemo, useState } from "react";
import { SettingContext, SettingContextAction } from "./SettingContext";

const defaultValue: UserPreferences = {
  language: "ko",
  fontSize: "medium",
  notifications: {
    email: false,
    push: false,
    desktop: false,
  },
  colorScheme: "system",
};

export default function SettingProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [preferences, setPreferences] = useState<UserPreferences>(defaultValue);

  const updateLanguage = (language: UserPreferences["language"]) => {
    setPreferences((prevPreferences) => ({ ...prevPreferences, language }));
  };

  const updateFontSize = (fontSize: UserPreferences["fontSize"]) => {
    setPreferences((prevPreferences) => ({ ...prevPreferences, fontSize }));
  };

  const updateNotifications = (
    key: keyof UserPreferences["notifications"],
    value: boolean,
  ) => {
    setPreferences((prevPreferences) => ({
      ...prevPreferences,
      notifications: { ...prevPreferences.notifications, [key]: value },
    }));
  };

  const updateColorScheme = (colorScheme: UserPreferences["colorScheme"]) => {
    setPreferences((prevPreferences) => ({ ...prevPreferences, colorScheme }));
  };

  const memoization = useMemo(
    () => ({
      updateLanguage,
      updateFontSize,
      updateNotifications,
      updateColorScheme,
    }),
    [],
  );

  return (
    <>
      <SettingContextAction value={memoization}>
        <SettingContext value={{ preferences }}>{children}</SettingContext>
      </SettingContextAction>
    </>
  );
}
// src/context/setting/useSetting.ts

import { useContext } from "react";
import { SettingContext, SettingContextAction } from "./SettingContext";

export function useSetting() {
  const context = useContext(SettingContext);
  if (!context) {
    throw new Error(
      "useSetting은 SettingProvider 내부에서만 사용할 수 있습니다.",
    );
  }

  return context;
}

export function useSettingAction() {
  const context = useContext(SettingContextAction);
  if (!context) {
    throw new Error(
      "useSettingAction은 SettingProvider 내부에서만 사용할 수 있습니다.",
    );
  }

  return context;
}

 

UserProfile 글자 크기

// src/components/FontSizeSetting.tsx

import { Type } from "lucide-react";
import { useSetting, useSettingAction } from "../context/setting/useSetting";
import { twMerge } from "tailwind-merge";

export default function FontSizeSetting() {
  const { preferences } = useSetting();
  const { updateFontSize } = useSettingAction();

  return (
    <>
      <div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm">
        <div className="flex items-center gap-3 mb-4">
          <Type className="text-blue-500" size={24} />
          <h2 className="text-lg font-semibold text-gray-900 dark:text-white">
            글자 크기
          </h2>
        </div>
        <div className="grid grid-cols-3 gap-3">
          {(["small", "medium", "large"] as const).map((size) => (
            <button
              key={size}
              className={twMerge(
                "px-4 py-2 rounded-lg text-sm font-medium transition-colors",
                preferences.fontSize === size
                  ? "bg-blue-500 text-white"
                  : "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
              )}
              onClick={() => updateFontSize(size)}
            >
              {size === "small" ? "작게" : size === "medium" ? "보통" : "크게"}
            </button>
          ))}

          
        </div>
      </div>
    </>
  );
}
// src/context/setting/SettingProvider.tsx

.
.
.

useEffect(() => {
    document.documentElement.style.fontSize = {
      small: "14px",
      medium: "16px",
      large: "18px"
    }[preferences.fontSize];
  }, [preferences]);
  
  .
  .
  .

 

UserProfile 알림 설정

// src/components/AlarmSetting.tsx

import { Bell } from "lucide-react";
import { useSetting, useSettingAction } from "../context/setting/useSetting";
import { twMerge } from "tailwind-merge";

export default function AlarmSetting() {
  const { preferences } = useSetting();
  const { updateNotifications } = useSettingAction();

  return (
    <>
      <div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm">
        <div className="flex items-center gap-3 mb-4">
          <Bell className="text-blue-500" size={24} />
          <h2 className="text-lg font-semibold text-gray-900 dark:text-white">
            알림 설정
          </h2>
        </div>
        <div className="space-y-4">
          {(
            Object.keys(
              preferences.notifications,
            ) as (keyof UserPreferences["notifications"])[]
          ).map((key) => (
            <label key={key} className="flex items-center justify-between">
              <span className="text-gray-700 dark:text-gray-300 capitalize">
                {key === "email"
                  ? "이메일 알림"
                  : key === "push"
                    ? "푸시 알림"
                    : "데스크톱 알림"}
              </span>
              
              <button
                className={twMerge(
                  "relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
                  preferences.notifications[key]
                    ? "bg-blue-500"
                    : "bg-gray-300 dark:bg-gray-600",
                )}
                onClick={() =>
                  updateNotifications(key, !preferences.notifications[key])
                }
              >
                
                <span
                  className={twMerge(
                    "inline-block h-4 w-4 transform rounded-full bg-white transition-transform translate-x-1",
                    preferences.notifications[key]
                      ? "translate-x-6"
                      : "translate-x-1",
                  )}
                />
              </button>
            </label>
          ))}
        </div>
      </div>
    </>
  );
}

 

UserProfile 테마 설정

// src/components/ThemeSetting.tsx

import { Monitor, Moon, Sun } from "lucide-react";
import { useSetting, useSettingAction } from "../context/setting/useSetting";
import { twMerge } from "tailwind-merge";

export default function ThemeSetting() {
  const { preferences } = useSetting();
  const { updateColorScheme } = useSettingAction();

  return (
    <>
      <div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm">
        <div className="flex items-center gap-3 mb-4">
          <Sun className="text-blue-500" size={24} />
          <h2 className="text-lg font-semibold text-gray-900 dark:text-white">
            테마 설정
          </h2>
        </div>
        <div className="grid grid-cols-3 gap-3">
          {(["system", "light", "dark"] as const).map((scheme) => (
            <button
              key={scheme}
              className={twMerge(
                "flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors",
                preferences.colorScheme === scheme
                  ? "bg-blue-500 text-white"
                  : "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
              )}
              onClick={() => updateColorScheme(scheme)}
            >
              {scheme === "system" ? (
                <>
                  <Monitor size={16} />
                  <span>시스템</span>
                </>
              ) : scheme === "light" ? (
                <>
                  <Sun size={16} />
                  <span>라이트</span>
                </>
              ) : (
                <>
                  <Moon size={16} />
                  <span>다크</span>
                </>
              )}
            </button>
          ))}
        </div>
      </div>
    </>
  );
}
// src/context/setting/SettingProvider.tsx

.
.
.

useEffect(() => {
    document.documentElement.style.fontSize = {
      small: "14px",
      medium: "16px",
      large: "18px"
    }[preferences.fontSize];

    if (preferences.colorScheme === "system") {
      document.documentElement.classList.remove("light", "dark");
      if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
        document.documentElement.classList.add("dark");
      } else {
        document.documentElement.classList.add("light");
      }
    } else {
      document.documentElement.classList.remove("light", "dark");
      document.documentElement.classList.add(preferences.colorScheme);
    }

  }, [preferences]);
  
  .
  .
  .

 

UserProfile 언어 설정

// src/libs/i18n.ts

export const translations = {
  ko: {
    profileTitle: "사용자 설정",
    languageSetting: "언어 설정",
    fontSize: {
      label: "글자 크기",
      small: "작게",
      medium: "보통",
      large: "크게",
    },
    notifications: {
      label: "알림 설정",
      email: "이메일 알림",
      push: "푸시 알림",
      desktop: "데스크톱 알림",
    },
    theme: {
      label: "테마 설정",
      system: "시스템",
      light: "라이트",
      dark: "다크",
    },
  },
  en: {
    profileTitle: "User Settings",
    languageSetting: "Language Setting",
    fontSize: {
      label: "Font Size",
      small: "Small",
      medium: "Medium",
      large: "Large",
    },
    notifications: {
      label: "Notification Settings",
      email: "Email Notifications",
      push: "Push Notifications",
      desktop: "Desktop Notifications",
    },
    theme: {
      label: "Theme Setting",
      system: "System",
      light: "Light",
      dark: "Dark",
    },
  },
  ja: {
    profileTitle: "ユーザー設定",
    languageSetting: "言語設定",
    fontSize: {
      label: "文字サイズ",
      small: "小",
      medium: "中",
      large: "大",
    },
    notifications: {
      label: "通知設定",
      email: "メール通知",
      push: "プッシュ通知",
      desktop: "デスクトップ通知",
    },
    theme: {
      label: "テーマ設定",
      system: "システム",
      light: "ライト",
      dark: "ダーク",
    },
  },
} as const;

export type SupportedLanguage = keyof typeof translations;
export type LocaleKey = keyof (typeof translations)["ko"];
// src/libs/useTranslation.ts

import { useSetting } from "../context/setting/useSetting";
import { SupportedLanguage, translations } from "./i18n";

export default function useTranslation() {
  const { preferences } = useSetting();
  const lang = preferences.language as SupportedLanguage;
  const t = translations[lang]; // 객체 변수
  return { t, lang };
}
// src/components/LanguageSetting.tsx

import { Languages } from "lucide-react";
import { useSetting, useSettingAction } from "../context/setting/useSetting";
import { twMerge } from "tailwind-merge";
import useTranslation from "../libs/useTranslation";

export default function LanguageSetting() {
  const { preferences } = useSetting();
  const { updateLanguage } = useSettingAction();
  const { t } = useTranslation();

  return (
    <>
      <div className="bg-white dark:bg-gray-800 rounded-lg p-6 shadow-sm">
        <div className="flex items-center gap-3 mb-4">
          <Languages className="text-blue-500" size={24} />
          <h2 className="text-lg font-semibold text-gray-900 dark:text-white">
            {t.languageSetting}
          </h2>
        </div>
        <div className="grid grid-cols-3 gap-3">
          {(["ko", "en", "ja"] as const).map((lang) => (
            <button
              key={lang}
              className={twMerge(
                "px-4 py-2 rounded-lg text-sm font-medium transition-colors",
                preferences.language === lang
                  ? "bg-blue-500 text-white"
                  : "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600",
              )}
              onClick={() => updateLanguage(lang)}
            >
              {lang === "ko"
                ? "한국어"
                : lang === "en"
                  ? "English"
                  : "日本語"}
            </button>
          ))}

          
        </div>
      </div>
    </>
  );
}

 

UserProfile 값 저장하기

새로고침 해도 이전 값이 유지되도록 설정

// src/context/setting/SettingProvider.tsx

import React, { useEffect, useMemo, useState } from "react";
import { SettingContext, SettingContextAction } from "./SettingContext";

const defaultValue: UserPreferences = {
  language: "ko",
  fontSize: "medium",
  notifications: {
    email: false,
    push: false,
    desktop: false,
  },
  colorScheme: "system",
};

export default function SettingProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [preferences, setPreferences] = useState<UserPreferences>(() => {
    const save = localStorage.getItem("preferences");
    return save ? JSON.parse(save) : defaultValue;
  });

  .
  .
  .

  useEffect(() => {

    localStorage.setItem("preferences", JSON.stringify(preferences));

    document.documentElement.style.fontSize = {
      small: "14px",
      medium: "16px",
      large: "18px"
    }[preferences.fontSize];

    if (preferences.colorScheme === "system") {
      document.documentElement.classList.remove("light", "dark");
      if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
        document.documentElement.classList.add("dark");
      } else {
        document.documentElement.classList.add("light");
      }
    } else {
      document.documentElement.classList.remove("light", "dark");
      document.documentElement.classList.add(preferences.colorScheme);
    }

  }, [preferences]);

  return (
    <>
      <SettingContextAction value={memoization}>
        <SettingContext value={{ preferences }}>{children}</SettingContext>
      </SettingContextAction>
    </>
  );
}

 

UserProfile useLayoutEffect

다크 모드를 설정한 상태에서 새로고침을 하면, 아주 잠깐 라이트 모드 디자인이 보여졌다가 다크 모드로 전환되면서 화면이 번쩍거리는 버그 발생 

∵ 다크 모드를 판단하고 DOM을 조작하는 로직이 useEffect 훅에서 처리되기 때문

ㄴ useEffect 훅은 컴포넌트가 화면에 렌더링 되고 난 이후에 실행

--> 컴포넌트가 라이트 모드로 그려진 이후에 다크 모드로 전환됨 

 

useLayoutEffect 훅 사용

ㄴ useLayoutEffect 훅은 useEffect 훅과 완전히 똑같지만, 컴포넌트가 그려지기 전에 동기적으로 함수 로직이 실행된다는 점에서 차이점 O

 

UserProfile useLayoutEffect - 2

// ScrollComparison.tsx

import { useEffect, useLayoutEffect, useRef } from "react";

const items = Array.from({ length: 402 }, (_, i) => `Item ${i + 1}`);

export default function ScrollComparison() {
  const refEffect = useRef<HTMLDivElement>(null);
  const refLayout = useRef<HTMLDivElement>(null);

  useEffect(() => {
    refEffect.current?.scrollTo({top: refEffect.current.scrollHeight});
  }, []);

  useLayoutEffect(() => {
    refLayout.current?.scrollTo({top: refLayout.current.scrollHeight});
  }, []);

  const boxClass =
    "border rounded-lg h-48 w-full overflow-auto bg-gray-100 p-2";

  return (
    <div className="space-y-8 p-8 max-w-2xl mx-auto">
      {/* useEffect 스크롤 박스 */}
      <div>
        <h2 className="text-lg font-semibold mb-2">useEffect 스크롤</h2>
        <div ref={refEffect} className={boxClass}>
          {items.map((text) => (
            <div key={text} className="py-1">
              {text}
            </div>
          ))}
        </div>
        <p className="mt-2 text-sm text-gray-600">
          렌더 후 스크롤 → 처음엔 위에서 시작하다가 아래로 내려가는 걸 볼 수
          있습니다.
        </p>
      </div>

      {/* useLayoutEffect 스크롤 박스 */}
      <div>
        <h2 className="text-lg font-semibold mb-2">useLayoutEffect 스크롤</h2>
        <div ref={refLayout} className={boxClass}>
          {items.map((text) => (
            <div key={text} className="py-1">
              {text}
            </div>
          ))}
        </div>
        <p className="mt-2 text-sm text-gray-600">
          렌더 전 스크롤 → 처음부터 맨 아래에 렌더되어 깜빡임이 없습니다.
        </p>
      </div>
    </div>
  );
}

 

^ useEffect 훅을 사용한 코드는 시각적 깜빡임이 발생하는 반면, useLayoutEffect 훅을 사용한 코드는 깜빡임 없이 자연스럽게 동작

 

* DOM 조작이 필요한 상황에서는 레이아웃 시동 직후, 브라우저가 화면을 실제로 그리기 전에 동기적으로 작업을 처리하는 것이 유리

이 과정에서 발생하는 UI의 시각적 깜빡임을 방지하고 싶을 때 useLayoutEffect 훅을 사용