Justin-book

React context api

2025-10-16

1) Context API 핵심 정리

컴포넌트 트리 전체에 걸쳐 전역적인 데이터를 공유할 수 있게 해주는 기능임 prop drilling 문제를 해소할 수 있음.

  • 무엇? 컴포넌트 트리 전체에 걸쳐 전역 데이터(상태/액션)를 prop drilling 없이 전달하는 메커니즘.

  • 왜? 하위 단계가 깊어질수록 props 전달이 복잡해지는 문제(prop drilling)를 해결.

  • 어떻게?

    1. createContext로 컨텍스트 생성
    2. Provider에서 값(value) 공급
    3. 하위에서 useContext로 소비

prop drilling

React에서 데이터를 여로 컴포넌트 계층을 통해 전달하는 과정을 의미 (하위 컴포넌트가 많아질수록 데이터를 전달하는 코드가 복잡 해져서 복잡성이 증가함)

Flow

Context 생성: createContext 함수를 사용하여 context를 생성. Provider: 생성한 context의 Provider 컴포넌트를 사용하여 하위 컴포넌트들에게 데이터를 전달. Consumer: Consumer 컴포넌트 또는 useContext 훅을 사용하여 context의 데이터를 가져옴.

State Reducer

React에서 상태 관리 로직이 복잡해질 때 유용하게 사용할 수 있는 도구 State를 업데이트 하는 모든 로직을 reducer를 사용하여 컴포넌트 외부 단일 함수로 통합해 관리가 가능하다.

Flow

Reducer 함수: 현재 상태와 액션 객체를 받아 새로운 상태를 반환하는 함수 Action 객체: 상태를 업데이트하기 위한 정보를 담고 있는 객체. Dispatch 함수: 액션 객체를 리듀서 함수에 전달하는 함수.


2) 구현 Data Flow & 폴더 구조

src/
├── datafetchs/                  # ✅ HTTP 통신(API 호출) 모듈
│   └── daemonsets.api.ts
├── store/                       # ✅ 전역/최상위 상태관리
│   ├── contexts/
│   │   ├── monitoring/
│   │   │   ├── daemonsets.context.tsx   # Context + Provider
│   │   │   └── daemonsets.reducer.ts    # Reducer + Action 정의
│   └── hooks/
│       └── useDaemonSets.ts            # Custom Hook (selector/액션 래핑)
├── pages/
│   └── monitoring/
│       ├── DaemonSetsPage.tsx          # 리스트 화면 (Control Props 지원)
│       └── DaemonSetsPodPage.tsx       # 상세/Pod 화면
└── app/
    └── routes.tsx                      # 라우팅(Provider 배치, Outlet)

데이터 흐름(Flux-like 단방향) Component(Event) → dispatch(Action) → reducer(State 변경) → Context value 업데이트 → UI 렌더


3) 구현 예시

3-1. datafetchs (API 호출)

// src/datafetchs/daemonsets.api.ts
import axios from "axios";

export interface DaemonSetItem {
  Name: string;
  Namespace: string;
  // ...필요 필드들
}

export const getAllDaemonSets = async (): Promise<DaemonSetItem[]> => {
  const { data } = await axios.get("/api/daemonsets");
  return data;
};

export const downloadDaemonSet = async (ns: string, name: string) => {
  const { data } = await axios.get(`/api/daemonsets/${ns}/${name}/download`, {
    responseType: "blob",
  });
  return data;
};

export const deleteDaemonSet = async (ns: string, name: string) => {
  await axios.delete(`/api/daemonsets/${ns}/${name}`);
};

3-2. Reducer + Actions

// src/store/contexts/monitoring/daemonsets.reducer.ts
import { DaemonSetItem } from "@/datafetchs/daemonsets.api";

export type DaemonSetsState = {
  list: DaemonSetItem[];
  loading: boolean;
  error?: string | null;
};

export const initialDaemonSetsState: DaemonSetsState = {
  list: [],
  loading: false,
  error: null,
};

export type DaemonSetsAction =
  | { type: "GET_ALL_REQUEST" }
  | { type: "GET_ALL_SUCCESS"; payload: DaemonSetItem[] }
  | { type: "GET_ALL_FAILURE"; payload: string }
  | { type: "DELETE_SUCCESS"; payload: { Namespace: string; Name: string } };

export function daemonSetsReducer(
  state: DaemonSetsState,
  action: DaemonSetsAction
): DaemonSetsState {
  switch (action.type) {
    case "GET_ALL_REQUEST":
      return { ...state, loading: true, error: null };
    case "GET_ALL_SUCCESS":
      return { ...state, loading: false, list: action.payload };
    case "GET_ALL_FAILURE":
      return { ...state, loading: false, error: action.payload };
    case "DELETE_SUCCESS": {
      const { Namespace, Name } = action.payload;
      return {
        ...state,
        list: state.list.filter(
          (d) => !(d.Namespace === Namespace && d.Name === Name)
        ),
      };
    }
    default:
      return state;
  }
}

3-3. Context + Provider (State Reducer + Context API)

// src/store/contexts/monitoring/daemonsets.context.tsx
import {
  createContext,
  useContext,
  useEffect,
  useReducer,
  ReactNode,
  useCallback,
} from "react";
import {
  daemonSetsReducer,
  initialDaemonSetsState,
  DaemonSetsAction,
  DaemonSetsState,
} from "./daemonsets.reducer";
import {
  getAllDaemonSets,
  deleteDaemonSet,
  downloadDaemonSet,
} from "@/datafetchs/daemonsets.api";

type DaemonSetsContextValue = {
  state: DaemonSetsState;
  dispatch: React.Dispatch<DaemonSetsAction>;
  actions: {
    refresh: () => Promise<void>;
    download: (ns: string, name: string) => Promise<void>;
    remove: (ns: string, name: string) => Promise<void>;
  };
};

const DaemonSetsContext = createContext<DaemonSetsContextValue | null>(null);

export const DaemonSetsProvider = ({ children }: { children: ReactNode }) => {
  const [state, dispatch] = useReducer(daemonSetsReducer, initialDaemonSetsState);

  const refresh = useCallback(async () => {
    dispatch({ type: "GET_ALL_REQUEST" });
    try {
      const list = await getAllDaemonSets();
      dispatch({ type: "GET_ALL_SUCCESS", payload: list });
    } catch (e: any) {
      dispatch({ type: "GET_ALL_FAILURE", payload: e?.message ?? "error" });
    }
  }, []);

  const download = useCallback(async (ns: string, name: string) => {
    await downloadDaemonSet(ns, name);
    // 필요시 파일 저장 처리(FileSaver 등)
  }, []);

  const remove = useCallback(async (ns: string, name: string) => {
    await deleteDaemonSet(ns, name);
    dispatch({ type: "DELETE_SUCCESS", payload: { Namespace: ns, Name: name } });
  }, []);

  useEffect(() => {
    // 첫 렌더 시 전체 조회
    refresh();
  }, [refresh]);

  return (
    <DaemonSetsContext.Provider
      value={{ state, dispatch, actions: { refresh, download, remove } }}
    >
      {children}
    </DaemonSetsContext.Provider>
  );
};

export const useDaemonSetsContext = () => {
  const ctx = useContext(DaemonSetsContext);
  if (!ctx) throw new Error("useDaemonSetsContext must be used within Provider");
  return ctx;
};

3-4. Custom Hook (Selector/액션 묶기)

// src/store/hooks/useDaemonSets.ts
import { useMemo } from "react";
import { useDaemonSetsContext } from "../contexts/monitoring/daemonsets.context";

export const useDaemonSets = () => {
  const { state, actions } = useDaemonSetsContext();
  const { list, loading, error } = state;

  const namespaceMap = useMemo(() => {
    const m = new Map<string, number>();
    list.forEach((d) => m.set(d.Namespace, (m.get(d.Namespace) ?? 0) + 1));
    return m;
  }, [list]);

  return { list, loading, error, namespaceMap, ...actions };
};

3-5. Page 컴포넌트 (Control Props + Context 소비)

Control Props: 부모가 namespace, daemonSet 등을 외부에서 제어할 수도 있고, 전달이 없으면 내부 상태/컨텍스트로 동작.

// src/pages/monitoring/DaemonSetsPage.tsx
import { useMemo, useState } from "react";
import { useDaemonSets } from "@/store/hooks/useDaemonSets";

type Props = {
  namespace?: string;              // 🔸 외부 제어(Controlled) 가능
  onSelectChange?: (ns: string) => void; // 🔸 외부 제어 콜백
};

export default function DaemonSetsPage({ namespace, onSelectChange }: Props) {
  const { list, loading, error, remove, download } = useDaemonSets();

  // 내부 상태(언컨트롤드). 외부 props가 오면 그 값을 우선 사용.
  const [innerNs, setInnerNs] = useState<string>("default");
  const effectiveNs = namespace ?? innerNs;

  const filtered = useMemo(
    () => list.filter((d) => (effectiveNs ? d.Namespace === effectiveNs : true)),
    [list, effectiveNs]
  );

  const handleChangeNs = (ns: string) => {
    if (onSelectChange) onSelectChange(ns);
    else setInnerNs(ns);
  };

  if (loading) return <div>Loading…</div>;
  if (error) return <div style={{ color: "red" }}>{error}</div>;

  return (
    <div>
      <h2>DaemonSets ({effectiveNs || "All"})</h2>

      <select
        value={effectiveNs}
        onChange={(e) => handleChangeNs(e.target.value)}
      >
        <option value="default">default</option>
        <option value="kube-system">kube-system</option>
        {/* 필요시 동적 namespace 목록으로 대체 */}
      </select>

      <ul style={{ marginTop: 12 }}>
        {filtered.map((d) => (
          <li key={`${d.Namespace}:${d.Name}`}>
            {d.Namespace}/{d.Name}
            <button onClick={() => download(d.Namespace, d.Name)}>download</button>
            <button onClick={() => remove(d.Namespace, d.Name)}>delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

3-6. 라우팅 + Provider 배치 (Outlet 패턴)

// src/app/routes.tsx
import { Outlet } from "react-router-dom";
import { DaemonSetsProvider } from "@/store/contexts/monitoring/daemonsets.context";
import AdminLayout from "@/layouts/AdminLayout";

export default function ProtectedAdmin() {
  return (
    <DaemonSetsProvider>
      <AdminLayout>
        <Outlet />
      </AdminLayout>
    </DaemonSetsProvider>
  );
}

3-7. 라우트 파라미터를 Control Props로 주입

// src/pages/monitoring/DaemonSetsPageWithContext.tsx
import { useParams } from "react-router-dom";
import DaemonSetsPage from "./DaemonSetsPage";
import DaemonSetsPodPage from "./DaemonSetsPodPage";

export default function DaemonSetsPageWithContext() {
  const { namespace, daemonSet } = useParams(); // /:namespace?/:daemonSet?

  if (daemonSet) {
    // 🔸 상세 화면 (Pod 페이지)
    return (
      <DaemonSetsPodPage
        namespace={namespace!}
        daemonSet={daemonSet}
      />
    );
  }

  // 🔸 리스트 화면 (namespace를 Control Props로 주입)
  return <DaemonSetsPage namespace={namespace} />;
}

4) 조합 전략: Control Props + (State Reducer + Context API + Custom Hook)

  • State Reducer: 상태 전환/업데이트 로직을 한곳(reducer)으로 모아 예측 가능하고 테스트 가능한 상태 관리
  • Context API: 해당 reducer의 상태와 액션을 트리 전체에 공급
  • Custom Hook: useDaemonSets선택자/액션을 캡슐화 → 컴포넌트는 깔끔해짐
  • Control Props: 라우트/상위 화면에서 namespace, daemonSet외부 제어가 필요할 때 props로 주입, 없으면 내부 상태로 동작(양모드 지원)

결과적으로, 페이지 재사용성(Control Props) + 상태 일관성(Reducer/Context) + 코드 응집도(Custom Hook) 를 동시에 달성합니다.


추가 팁 (현재 코드에 바로 반영하기 좋은 것들)

  • dispatch({ type: "GET_ALL_DAEMONSETS" }) 같은 하드코딩 문자열 → Action 타입 상수/유니온으로 전환 (위 예시처럼)
  • 라우트 분기(DaemonSetsPage vs DaemonSetsPodPage)는 useParams를 Control Props에 연결하는 방식이 가장 깔끔
  • 삭제/다운로드 액션은 Provider에 모아두고 컴포넌트에서는 actions.remove / actions.download만 호출
  • 비동기 중복 호출 방지: refresh 호출 위치(마운트 1회)와 버튼 재요청 플래그(선택)를 구분