diff --git a/log/data/kong/method/react/useContext/context.md b/log/data/kong/method/react/useContext/context.md new file mode 100644 index 0000000..ef2ba61 --- /dev/null +++ b/log/data/kong/method/react/useContext/context.md @@ -0,0 +1,238 @@ +# 콘-텍스트 + +> [!Info] +> 연관된 파일로는 `provider.md`가 있으니 시간이 난다면 한 번 보는 것도 좋을 것 같다. + +어떤 라이브러리를 만들기 위해 AI에게 코드 작성과 에러 수정 후 코드 분석을 하게 된 나... 시작부터 난관에 맞닥뜨리는데... + +(대충 놀랍고 엄청나다는 이미지) + +--- + +## 서론 + +코드를 분석하기 위해 `import` 부분부터 봤다. 뭐든 처음부터 보는 게 좋지 않은가... 그래서 코드를 봤는데 바로 모르는 것이 나와버렸다... `createContext`, `useContext`. 이게 대체 뭐지? + +--- + +## Props + +들어가기 전에 Context를 이해하려면 먼저 props를 알면 도움이 된다. + +간단하게 설명하자면, 부모 컴포넌트가 자식 컴포넌트에게 데이터를 전달하는 방식이 props다. props는 간단하게 데이터를 전달할 수 있지만, 자식 컴포넌트 이상으로 데이터를 전달하려면 중간 단계의 컴포넌트에서 불필요하게 데이터를 처리해야 하는 문제가 발생한다. + +image + +이런 상황을 속성이 여러 컴포넌트를 관통하는 것 같다고 해서 **Prop Drilling**이라고 한다. + +image + +--- + +## 그래서 진짜로, Context. 그게 뭔데 + +해당 props drilling을 해결하기 위해 나온 게 바로 Context API다. + +Context API는 앱에서 컴포넌트로 **props를 사용하지 않고 필요한 데이터를 쉽게 공유**할 수 있게 해준다. 특정 컴포넌트에서 제공하는 데이터를 하위 컴포넌트에서 사용할 수 있게 하는 것 — 중간 컴포넌트들을 거치지 않고, 필요한 컴포넌트가 직접 값을 꺼내 사용한다. + +```tsx +import { createContext, useContext } from "react"; + +// 1. createContext 메서드로 context 생성 +const MyContext = createContext(데이터의초기값); + +// 2. Provider로 대상 컴포넌트를 감싸고, value에 전달할 데이터를 넣는다 +{children}; + +// 3. 필요한 컴포넌트에서 useContext로 꺼내 쓴다 (공식 문서 권장 방식) +const 데이터 = useContext(MyContext); + +// 참고: Consumer 컴포넌트 방식도 있지만, useContext 사용을 권장한다 +{(데이터) =>
{데이터}
}
; +``` + +실제 예제로 보면: + +```tsx +import { createContext, useContext } from "react"; + +const ThemeContext = createContext(null); + +export default function MyApp() { + return ( + +
+ + ); +} + +function Panel({ title, children }) { + const theme = useContext(ThemeContext); // "dark"를 받아옴 + return ( +
+

{title}

+ {children} +
+ ); +} + +function Button({ children }) { + const theme = useContext(ThemeContext); // 여기서도 "dark"를 받아옴 + return ; +} +``` + +--- + +## 주로 어디에 사용할까? + +Context가 적합한 데이터 종류가 있다. + +- **라이트 모드 / 다크 모드** 설정 +- **사용자 데이터** (현재 인증된 유저 정보) +- **언어 혹은 지역 데이터** + +공통점이 보이는가? 모두 **자주 업데이트할 필요가 없는 데이터**다. Context에서 `value`가 바뀌면 Provider로 감싼 모든 자식 컴포넌트들이 리렌더링되므로, 자주 바뀌는 값에는 적합하지 않다. + +즉 Context는 *컴포넌트를 위한 전역 변수*의 개념이라고 볼 수 있다. + +--- + +## Context를 사용하기 전에 고려할 것 + +Context는 사용하기에 꽤 유혹적이다. 그러나 이는 또한 남용하기 쉽다는 의미이기도 하다. **어떤 props를 여러 레벨 깊이로 전달해야 한다고 해서 해당 정보를 context에 넣어야 하는 것은 아니다.** + +먼저 이 두 가지를 시도해보자. + +**1. Props 전달하기로 시작하기.** 여러 컴포넌트를 거쳐 props가 흘러가는 것은 그리 이상한 일이 아니다. 힘든 일처럼 느껴질 수 있지만, 어떤 컴포넌트가 어떤 데이터를 사용하는지 매우 명확히 해줘서 유지보수하기 좋다. + +**2. 컴포넌트를 추출하고 `children`으로 전달하기.** 이게 바로 Composition(합성)이라는 기법이다. 데이터를 쓰지도 않는 컴포넌트가 짐꾼 역할을 하고 있다면, 구조를 바꾸는 것이 먼저다. + +```tsx +// Bad: Layout이 user를 쓰지도 않는데 받아야 함 +function App() { + const [user] = useState({ name: "홍길동" }); + return ; +} +function Layout({ user }) { + return ( +
+
+
+ ); // 그냥 전달만... +} + +// Good: App에서 Header를 직접 렌더링해서 넣어버린다 +function App() { + const [user] = useState({ name: "홍길동" }); + return ( + +
{/* Layout은 user가 뭔지 몰라도 됨 */} + + ); +} +function Layout({ children }) { + return
{children}
; // 그냥 구멍(Slot)만 뚫어놓으면 끝 +} +``` + +`App` → `Layout` → `Header` 3단계였던 게, `App` → `Header`로 줄어든다. Layout은 껍데기가 되어 리렌더링 범위에서도 제외된다. + +이래도 해결이 안 될 만큼 깊거나 광범위할 때 비로소 Context를 꺼내 드는 것이 React의 권장 순서다. + +--- + +## Context랑 전역 상태 관리 라이브러리, 같은 거 아냐? + +겉보기에는 Context API도 전역적으로 데이터를 뿌려주니까 Redux나 Zustand 같은 라이브러리의 대체제처럼 보일 수 있다. 허나 다르다. + +**Context API의 핵심 설계 목적은 Dependency Injection(의존성 주입)이다.** 깊게 중첩된 컴포넌트 트리에서 Prop Drilling 없이 데이터를 하단으로 꽂아주는 **통로** 역할. + +반면 상태 관리 도구는 단순히 값을 전달하는 것 외에 **효율적인 업데이트**가 가능해야 한다. Context의 치명적인 약점이 여기서 드러난다. + +``` +전역 상태 객체 { user, theme, posts }가 Context에 있다고 가정. +→ theme만 바뀌어도 user 정보만 쓰는 컴포넌트까지 전부 재렌더링된다. +``` + +Redux, Zustand, Recoil 같은 라이브러리는 내부적으로 최적화되어 있어서, `user`가 바뀌어도 `theme`을 구독 중인 컴포넌트는 눈 하나 깜짝 안 한다. 이게 바로 **전달**과 **관리**의 차이다. + +| 특징 | Context API | 전역 상태 관리 라이브러리 | +| ------------- | ------------------------------ | ------------------------------------ | +| 주 목적 | Props 전달 생략, 결합도 낮추기 | 효율적인 상태 업데이트 및 로직 분리 | +| 렌더링 최적화 | 어려움 (구독자 전체 렌더링) | 뛰어남 (변경된 부분만 선택적 렌더링) | +| 비동기 처리 | 직접 구현해야 함 | Middleware 등 내장/지원 | +| 디버깅 툴 | 기본 DevTools | 전용 DevTools (타임머신 디버깅 등) | + +요약하면: + +- **Context** → 데이터를 어디로 보낼지 결정하는 **전송 수단** +- **Redux·Zustand** → 데이터를 어떻게 관리하고 효율적으로 변화시킬지 결정하는 **시스템** + +--- + +## 그래서 언제 뭘 써야 할까? + +**Context가 적합한 경우:** + +- 값이 자주 바뀌지 않을 때 (테마, 언어, 로그인 유저 정보) +- 앱 전체보다는 특정 범위 내에서만 공유가 필요할 때. + +**상태 관리 라이브러리가 적합한 경우:** + +- 업데이트가 빈번하게 일어나는 복잡한 데이터 +- 컴포넌트 수백 개 중 특정 컴포넌트만 정밀하게 업데이트해야 할 때 +- 상태 관리 로직을 컴포넌트 외부로 완전히 분리하고 싶을 때. + +--- + +## 고민 + +1. context가 props drilling 해결을 위해 사용하는 건 맞는데, 렌더링 측면에서 보면 context가 리렌더링을 많이 일으켜서 그렇게 좋다고는 생각을 안 함… 근데 어차피 자식의 자식의 자식으로 보내서 뭘 해도 리렌더링 일으키는 거, 그냥 편하게 최상단에서 context로 쏴버리자인가? +2. 근데 생각해보니 리렌더링은 값이 변화할 때만 일어나는 것이고… context가 변화하는 값에만 사용되는 게 아니라 그냥 변화하지 않는 일반적인 값을 내려줄 때도 사용되니… 양측에서 생각을… +3. 근데 거기서 더 생각해보니 안에 들어 있는 모든 컴포넌트가 리렌더링 되는 게 아니라 `useContext`를 사용하고 있는 애들만 리렌더링 되니까… + +--- + +## 별록 + +`useContext`는 전달한 Context에 대한 Context Value를 반환한다. Context 값을 결정하기 위해 React는 컴포넌트 트리를 탐색하고, 특정 Context에 대해 상위에서 가장 가까운 Context Provider를 찾는다. + +`value`에 `useState` 값을 넣으면 Context를 업데이트할 수 있다. + +```tsx +const AppContext = createContext(); + +export const AppProvider = ({ children }) => { + const [user, setUser] = useState({ name: "Guest" }); + + return {children}; +}; +``` + ++) 변하는 값에는 전역 상태 관리 라이브러리, 그저 props에 값을 전달할 때는 context도 괜찮을 것 같다. + +--- + +## 추가 + +해당 글을 작성하면서 context는 언제 사용하고 왜 전역 상태 관리 라이브러리로 대체하지 않는지 궁금점들이 생겼다. +왜냐하면 context는 리렌더링 측면에서만 봤을 때는 그리 좋지 않기 때문이다. 그래서 리렌더링 최적화가 잘 되어있는 라이브러리를 사용하면 되지 않을까? 생각했다. +그런데 그게 아니었다. + +라이브러리와 context를 나누는 기준은 범위로 생각을 해야한다. context는 A라는 컴포넌트 트리 안에서만 공유되어야 하는 값일 때 사용한다. +따라서 Provider로만 감싼 해당 트리 안에서만 유효한 범위를 만들 수 있다. + +반면 라이브러리는 A라는 트리 컴포넌트 뿐만이 아니라 B, C같이 여러 곳에서 읽고 쓰는 값에 사용한다. +여기서 또 나처럼 질문이 생길 수도 있다. 엥 그냥 값 관리면 context로 관리하는 것도 라이브러리로 하면 되는 거 아닌감? +모달로 예시를 들어보자. 나는 a라는 모달의 오픈 여부를 담아가고 싶다. 그걸 라이브러리로 관리하면 Modal이 닫혀도 상태가 남아있고, 앱 어디서든 접근 가능해져서 관심사 분리가 깨지는 것이다. +-> 왜? 라이브러리는 storge에 저장이 되는데, 그건 페이지를 닫거나 아예 값을 날려버리기 전까지는 남아있기 때문이다. (앱 생명주기 동안 살아있다는 뜻) +-> 근데 context는 Provider가 unmount되면 같이 사라진다. (모달이 닫히면 사라진다는 뜻) + +props drilling이 일어났을 때 유용한 것은 맞지만 context의 주 목적이 props drilling이 아니라는 것에 초점을 맞춰야 한다. +나처럼 props drilling에 초점을 맞췄다가 어? 그렴 라이브러리가 더 나은 거 아닌가? ㅇㅅㅇa 가 될 수도 있기 때문이다. +props drilling > 부수 효과. 목적이 아님!! + +--- + +\_참고: [heropy.dev](https://heropy.dev), [React 공식 문서](https://ko.react.dev/learn/passing-data-deeply-with-context) diff --git a/log/data/kong/method/react/useContext/provider.md b/log/data/kong/method/react/useContext/provider.md new file mode 100644 index 0000000..94967a9 --- /dev/null +++ b/log/data/kong/method/react/useContext/provider.md @@ -0,0 +1,125 @@ +# 프로-바이더 + +## 서론 + +하,,,, + +분명? 나는 리액트 컴포넌트의 성능을 런타임에도 적은 오버헤드로 telemetry 할 수 있는 프로파일링 라이브러리를 만들고 있었는데, 해당 라이브러리를 그냥 쌩으로 만들기에는 지식이 너무 없었다. + +그래서 claude에게 설계를 보여주며 짜달라고 했고, 해당 코드들을 분석 후 다시 처음부터 내가 만들려고 했다. + +그런데 코드 분석 중 `createContext`과 `useContext`을 맞닥뜨리게 되었다… 그게 뭔데? `useState`나 `useRef`같은 것들만 사용해오던 나에게는…. 너무 어려웠다….n + +공식 문서를 자세하게 읽어봐도 잘 모르겠어서 아는 프론트엔드 개발자분들께 여쭤보니 context가 최상단에서 상태를 저장해두고 상단에서 context provider를 설정해두고 쏴주는거라고,,, 약간 전역 변수처럼 쓰이는 친구인가보다... + +> Redux, recoil, tq의 조상격(?),,,? 전역으로 변수 설정이 가능한 친구... +> useContext with Providers 참고.... +> 전역 라이브러리와 context API 차이... +> provider 컴파운드 컴포넌트도 따로 분리... + +예...? provider가 뭔데요...? + +--- + +## 그래서 provider는 또 뭔데? (context.provider) + +```js + +``` + +리액트로 컴포넌트를 만들 때 상태값 관리는 보통 props나 state로 관리한다. +여기서 context가 나오는데, context는 간단하게 props drilling 문제를 해결하기 위한 React의 내장 기능이라고 보면 될 것 같다. + +context에 포함된 react provider는 context를 구독하는 컴포넌트들에게 context의 변화를 알리는 역할을 한다. +provider 컴포넌트는 value prop을 받아서 이 값을 하위에 있는 컴포넌트에게 전달한다. 값을 전달받을 수 있는 컴포넌트 수에 제한은 없다. +provider 하위에 또 다른 provider를 배치하는 것도 가능하며, 이 경우 하위 provider의 값이 우선시된다. +Redux도 내부적으로 같은 Provider 패턴을 사용한다. + +그러니까 provider는 HOC로 context를 제공하고, react가 제공하는 createContext 메서드를 활용하여 context 객체를 만들어낼 수 있다. +Provider 컴포넌트는 value라는 prop으로 하위 컴포넌트들에게 내려줄 데이터를 받는다. 이 컴포넌트의 모든 자식 컴포넌트들은 해당 provider를 통해 value prop에 접근할 수 있다. + +요약: provider 안에 있는 자식 컴포넌트들은 provider 라인에서 선언한 context를 value와 useContext를 통해 받을 수 있다. + +```js +function App() { + const data = { ... } + + return ( +
+ + + + +
+ ) +} + +function SideBar() { + const { data } = React.useContext(DataContext); + return

{data.text}

; +} + +``` + +--- + +가만히 보면 그냥 context랑 별반 다를 게 없는 친구 아니야? 싶겠지만, 그건 내가 이해를 잘못 해서 그런 것이었다. +provider와 context는 역할이 다르다... provider는 값을 넣는 쪽, context는 값을 꺼내는 쪽이라고 보면 될 것 같다. +Provider 없이 useContext만 쓰면 값이 null이고, useContext 없이 Provider만 쓰면 값을 꺼내지 못한다... +그리고 Provider가 감싼 범위만 전역변수로 사용할 수 있으니 참고할 것. + +```js +const UserContext = createContext(null); +// Provider가 없을 때 사용되는 기본값 +// Provider 안에 있으면 이 값은 무시됨 + +// Provider → 값을 "넣는" 쪽 + + +; + +// useContext → 값을 "꺼내는" 쪽 +function GrandChild() { + const user = useContext(UserContext); // 꺼냄 +} +``` + +--- + +기존에 provider를 사용할 때는 ~context.provider를 했어야 했는데, React 19로 올라오면서 `` 대신 ``를 바로 Provider로 렌더링할 수 있다. + +```js +// React 18 이하 (구버전) + + + + +// React 19 이상 (신버전) — 완전히 동일한 동작 + + + +``` + +## 번외. 훅으로 만들기! + +이런 식으로 각 컴포넌트에서 useContext를 import 하는 대신 필요로 하는 컨텍스트를 직접 반환하는 훅을 구현할 수 있다. +커스텀 훅으로 감싸면 에러 메시지도 넣을 수 있고, 매번 import를 두 번 안 해도 된다...! + +```js +function useThemeContext() { + const theme = useContext(ThemeContext); + if (!theme) { + throw new Error("useThemeContext must be used within ThemeProvider"); + } + return theme; +} +``` + +## 번외2. 검색 + +검색할 때 react provider 라고 하면 나오는 게 많이 없다... provider 패턴이라고 검색해야 잘 나온다. 참고하기! + +## 참고 + +[Provider 패턴](https://patterns-dev-kr.github.io/design-patterns/provider-pattern/) +-> 괜찮은 기술?블로그입니다. 참고하시면 좋을 것 같아요. diff --git a/log/workspace/react/ts/src/pages/context/context.css b/log/workspace/react/ts/src/pages/context/context.css new file mode 100644 index 0000000..1fe86df --- /dev/null +++ b/log/workspace/react/ts/src/pages/context/context.css @@ -0,0 +1,269 @@ +/* context.css */ + +/* ============================================ + Reset & Base + ============================================ */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background-color: #0f0f13; + color: #e2e2e8; + line-height: 1.6; + min-height: 100vh; +} + +/* ============================================ + App + ============================================ */ +.app { + display: flex; + flex-direction: row; + align-items: center; + padding: 40px 24px; + min-height: 100vh; + gap: 50px; +} + +/* ============================================ + Page + ============================================ */ +.page { + width: 100%; + max-width: 720px; + background: #1a1a24; + border: 1px solid #2e2e3e; + border-radius: 16px; + padding: 32px; +} + +.page > h1 { + font-size: 22px; + font-weight: 600; + color: #c4b5fd; + margin-bottom: 24px; + padding-bottom: 12px; + border-bottom: 1px solid #2e2e3e; + letter-spacing: -0.3px; +} + +/* ============================================ + Section + ============================================ */ +.section { + background: #1f1f2e; + border: 1px solid #35354a; + border-radius: 12px; + padding: 24px; +} + +.section > h2 { + font-size: 18px; + font-weight: 500; + color: #a78bfa; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 8px; +} + +.section > h2::before { + content: ""; + display: inline-block; + width: 4px; + height: 18px; + background: #7c3aed; + border-radius: 2px; +} + +/* ============================================ + Panel + ============================================ */ +.panel { + background: #24243a; + border: 1px solid #3d3d56; + border-radius: 10px; + padding: 20px; +} + +.panel > h3 { + font-size: 15px; + font-weight: 500; + color: #fb923c; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 8px; +} + +.panel > h3::before { + content: ""; + display: inline-block; + width: 3px; + height: 15px; + background: #ea580c; + border-radius: 2px; +} + +/* ============================================ + Widget + ============================================ */ +.widget { + background: #2a2040; + border: 1px dashed #4a3a6e; + border-radius: 8px; + padding: 16px; +} + +/* ============================================ + DeepChild — 실제 사용처 + ============================================ */ +.deep-child { + background: #1e1230; + border: 1.5px solid #6d28d9; + border-radius: 10px; + padding: 20px 24px; + position: relative; + overflow: hidden; +} + +.deep-child::before { + content: "5단계 아래"; + position: absolute; + top: 10px; + right: 12px; + font-size: 11px; + color: #7c3aed; + background: #2e1065; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid #4c1d95; + letter-spacing: 0.3px; +} + +/* theme-dark */ +.deep-child.theme-dark { + background: #1a0f2e; + box-shadow: + 0 0 0 1px #6d28d9, + 0 4px 24px rgba(109, 40, 217, 0.15); +} + +/* theme-light */ +.deep-child.theme-light { + background: #f5f3ff; + color: #1e1230; + border-color: #7c3aed; +} + +.deep-child.theme-light p { + color: #3b0764; +} + +.deep-child.theme-light .user-name { + color: #6d28d9; +} + +.deep-child.theme-light .badge { + background: #ede9fe; + color: #5b21b6; + border-color: #c4b5fd; +} + +.deep-child.theme-light .locale-tag { + background: #f3e8ff; + color: #6d28d9; +} + +/* DeepChild 내부 요소들 */ +.deep-child p { + font-size: 15px; + color: #d1d5db; + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 8px; +} + +.deep-child p:last-child { + margin-bottom: 0; +} + +.user-name { + font-weight: 600; + color: #c084fc; + font-size: 16px; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + border: 1px solid; +} + +.badge.admin { + background: #fef3c7; + color: #92400e; + border-color: #fcd34d; +} + +.badge.user { + background: #dbeafe; + color: #1e40af; + border-color: #93c5fd; +} + +.locale-tag { + display: inline-flex; + align-items: center; + gap: 4px; + background: #1e1b4b; + color: #a5b4fc; + border: 1px solid #3730a3; + padding: 2px 10px; + border-radius: 6px; + font-size: 12px; + font-family: "SF Mono", "Fira Code", monospace; +} + +/* ============================================ + Depth Indicator (선택적 시각 요소) + ============================================ */ +.depth-indicator { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.depth-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #3b3b52; + border: 1.5px solid #555570; + transition: background 0.2s; +} + +.depth-dot.active { + background: #7c3aed; + border-color: #a78bfa; + box-shadow: 0 0 6px rgba(124, 58, 237, 0.6); +} + +.depth-arrow { + font-size: 11px; + color: #555570; +} diff --git a/log/workspace/react/ts/src/pages/context/dashboard/dashboard.css b/log/workspace/react/ts/src/pages/context/dashboard/dashboard.css new file mode 100644 index 0000000..d459bff --- /dev/null +++ b/log/workspace/react/ts/src/pages/context/dashboard/dashboard.css @@ -0,0 +1,533 @@ +/* dashboard.css */ + +/* ============================================ + Tokens & Reset + ============================================ */ +:root { + --bg: #0c0c10; + --bg-2: #13131a; + --bg-3: #1a1a24; + --bg-4: #20202e; + --border: #ffffff0f; + --border-2: #ffffff18; + --text: #e8e8f0; + --text-2: #9898b0; + --text-3: #5a5a72; + --accent: #6d6af5; + --accent-2: #4d4abf; + --accent-glow: #6d6af530; + --green: #34d399; + --red: #f87171; + --gold: #fbbf24; + --gold-2: #d78502; + --radius: 10px; + --sidebar-w: 220px; + --font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: + "Pretendard", + "Apple SD Gothic Neo", + -apple-system, + sans-serif; + font-size: 14px; + line-height: 1.6; + min-height: 100vh; +} + +/* ============================================ + Logged-out screen + ============================================ */ +.logged-out { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + gap: 20px; +} + +.logged-out p { + font-size: 20px; + color: var(--text-2); +} + +.logged-out button { + padding: 10px 24px; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 14px; + cursor: pointer; + transition: background 0.15s; +} + +.logged-out button:hover { + background: var(--accent-2); +} + +/* ============================================ + Dashboard Layout + ============================================ */ +.dashboard-page { + min-height: 100vh; +} + +.dashboard-layout { + display: flex; + min-height: 100vh; +} + +/* ============================================ + Sidebar + ============================================ */ +.sidebar { + width: var(--sidebar-w); + background: var(--bg-2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 20px 0; + flex-shrink: 0; + position: fixed; + top: 0; + left: 0; + height: 100vh; +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; + padding: 0 20px 20px; + border-bottom: 1px solid var(--border); + margin-bottom: 12px; +} + +.logo-mark { + font-size: 20px; + color: var(--accent); + line-height: 1; +} + +.logo-text { + font-size: 16px; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--text); +} + +/* ============================================ + Sidebar Menu + ============================================ */ +.sidebar-menu { + display: flex; + flex-direction: column; + flex: 1; +} + +.sidebar-menu ul { + list-style: none; + padding: 0 10px; + flex: 1; +} + +/* ============================================ + Sidebar Menu Item + ============================================ */ +.menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: 8px; + cursor: pointer; + position: relative; + color: var(--text-2); + transition: + background 0.12s, + color 0.12s; + margin-bottom: 2px; + user-select: none; +} + +.menu-item:hover { + background: var(--bg-3); + color: var(--text); +} + +.menu-item.active { + background: var(--accent-glow); + color: var(--text); +} + +.menu-item.locked { + opacity: 0.5; + cursor: not-allowed; +} + +.menu-icon { + font-size: 16px; + width: 20px; + text-align: center; + flex-shrink: 0; +} + +.menu-label { + font-size: 13.5px; + flex: 1; +} + +.menu-badge { + font-size: 10px; + font-weight: 600; + background: var(--gold); + color: #000; + padding: 1px 6px; + border-radius: 4px; + letter-spacing: 0.3px; +} + +.menu-active-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +/* ============================================ + User Panel (드디어 user 실제 사용처!) + ============================================ */ +.user-panel { + position: relative; + padding: 10px; + border-top: 1px solid var(--border); + margin-top: auto; +} + +.user-panel-trigger { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + background: none; + border: none; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s; + color: var(--text); + text-align: left; +} + +.user-panel-trigger:hover { + background: var(--bg-3); +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 8px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: #fff; + flex-shrink: 0; + overflow: hidden; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.user-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.user-name { + font-size: 13px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text); +} + +.user-plan { + font-size: 11px; + color: var(--gold); +} + +.chevron { + font-size: 18px; + color: var(--text-3); + transition: transform 0.2s; + flex-shrink: 0; + line-height: 1; +} + +.chevron.open { + transform: rotate(90deg); +} + +/* User dropdown menu */ +.user-menu { + position: absolute; + bottom: calc(100% + 4px); + left: 10px; + right: 10px; + background: var(--bg-3); + border: 1px solid var(--border-2); + border-radius: 10px; + padding: 6px; + box-shadow: 0 -8px 32px #00000050; + animation: slideUp 0.15s ease; + z-index: 10; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.user-menu-email { + font-size: 11px; + color: var(--text-3); + padding: 4px 8px 8px; + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-menu-divider { + border: none; + border-top: 1px solid var(--border); + margin: 4px 0; +} + +.user-menu-item { + display: block; + width: 100%; + padding: 7px 10px; + background: none; + border: none; + border-radius: 6px; + text-align: left; + font-size: 13px; + color: var(--text-2); + cursor: pointer; + transition: + background 0.1s, + color 0.1s; +} + +.user-menu-item:hover { + background: var(--bg-4); + color: var(--text); +} + +.user-menu-item.danger { + color: var(--red); +} + +.user-menu-item.danger:hover { + background: #f8717115; + color: var(--red); +} + +/* ============================================ + Main Content + ============================================ */ +.main { + flex: 1; + margin-left: var(--sidebar-w); + padding: 32px; + min-height: 100vh; +} + +.main-content { + max-width: 900px; +} + +.content-header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 28px; +} + +.content-header h1 { + font-size: 24px; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--text); +} + +.content-date { + font-size: 13px; + color: var(--text-3); +} + +/* ============================================ + Stats Grid + ============================================ */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 28px; +} + +.stat-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 6px; + transition: border-color 0.2s; +} + +.stat-card:hover { + border-color: var(--border-2); +} + +.stat-label { + font-size: 12px; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.6px; +} + +.stat-value { + font-size: 26px; + font-weight: 700; + letter-spacing: -1px; + color: var(--text); + line-height: 1.1; +} + +.stat-delta { + font-size: 12px; + font-weight: 500; +} + +.stat-delta.up { + color: var(--green); +} +.stat-delta.down { + color: var(--red); +} + +/* ============================================ + Drill Callout + ============================================ */ +.drill-callout { + display: flex; + gap: 14px; + background: #1e1a0e; + border: 1px solid #fbbf2430; + border-left: 3px solid var(--gold); + border-radius: var(--radius); + padding: 16px 20px; + margin-top: 8px; +} + +.callout-icon { + font-size: 18px; + color: var(--gold); + flex-shrink: 0; + margin-top: 1px; +} + +.drill-callout strong { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--gold); + margin-bottom: 6px; +} + +.drill-callout p { + font-size: 13px; + color: var(--text-2); + line-height: 1.7; +} + +.drill-callout code { + font-family: var(--font-mono); + font-size: 12px; + background: #ffffff0d; + padding: 1px 5px; + border-radius: 4px; + color: var(--accent); +} + +.drill-callout em { + color: var(--red); + font-style: normal; + font-weight: 600; +} + +.move-route { + padding: 8px 14px; + background: var(--gold); + color: #000; + border: none; + border-radius: var(--radius); + font-size: 14px; + cursor: pointer; + transition: background 0.15s; + margin-top: 10px; +} + +.move-route:hover { + background: var(--gold-2); +} + +/* ============================================ + Responsive + ============================================ */ +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: 1fr; + } + .main { + padding: 20px; + } +} diff --git a/log/workspace/react/ts/src/pages/context/dashboard/fix/dashboard.css b/log/workspace/react/ts/src/pages/context/dashboard/fix/dashboard.css new file mode 100644 index 0000000..d459bff --- /dev/null +++ b/log/workspace/react/ts/src/pages/context/dashboard/fix/dashboard.css @@ -0,0 +1,533 @@ +/* dashboard.css */ + +/* ============================================ + Tokens & Reset + ============================================ */ +:root { + --bg: #0c0c10; + --bg-2: #13131a; + --bg-3: #1a1a24; + --bg-4: #20202e; + --border: #ffffff0f; + --border-2: #ffffff18; + --text: #e8e8f0; + --text-2: #9898b0; + --text-3: #5a5a72; + --accent: #6d6af5; + --accent-2: #4d4abf; + --accent-glow: #6d6af530; + --green: #34d399; + --red: #f87171; + --gold: #fbbf24; + --gold-2: #d78502; + --radius: 10px; + --sidebar-w: 220px; + --font-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background: var(--bg); + color: var(--text); + font-family: + "Pretendard", + "Apple SD Gothic Neo", + -apple-system, + sans-serif; + font-size: 14px; + line-height: 1.6; + min-height: 100vh; +} + +/* ============================================ + Logged-out screen + ============================================ */ +.logged-out { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + gap: 20px; +} + +.logged-out p { + font-size: 20px; + color: var(--text-2); +} + +.logged-out button { + padding: 10px 24px; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 14px; + cursor: pointer; + transition: background 0.15s; +} + +.logged-out button:hover { + background: var(--accent-2); +} + +/* ============================================ + Dashboard Layout + ============================================ */ +.dashboard-page { + min-height: 100vh; +} + +.dashboard-layout { + display: flex; + min-height: 100vh; +} + +/* ============================================ + Sidebar + ============================================ */ +.sidebar { + width: var(--sidebar-w); + background: var(--bg-2); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + padding: 20px 0; + flex-shrink: 0; + position: fixed; + top: 0; + left: 0; + height: 100vh; +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; + padding: 0 20px 20px; + border-bottom: 1px solid var(--border); + margin-bottom: 12px; +} + +.logo-mark { + font-size: 20px; + color: var(--accent); + line-height: 1; +} + +.logo-text { + font-size: 16px; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--text); +} + +/* ============================================ + Sidebar Menu + ============================================ */ +.sidebar-menu { + display: flex; + flex-direction: column; + flex: 1; +} + +.sidebar-menu ul { + list-style: none; + padding: 0 10px; + flex: 1; +} + +/* ============================================ + Sidebar Menu Item + ============================================ */ +.menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: 8px; + cursor: pointer; + position: relative; + color: var(--text-2); + transition: + background 0.12s, + color 0.12s; + margin-bottom: 2px; + user-select: none; +} + +.menu-item:hover { + background: var(--bg-3); + color: var(--text); +} + +.menu-item.active { + background: var(--accent-glow); + color: var(--text); +} + +.menu-item.locked { + opacity: 0.5; + cursor: not-allowed; +} + +.menu-icon { + font-size: 16px; + width: 20px; + text-align: center; + flex-shrink: 0; +} + +.menu-label { + font-size: 13.5px; + flex: 1; +} + +.menu-badge { + font-size: 10px; + font-weight: 600; + background: var(--gold); + color: #000; + padding: 1px 6px; + border-radius: 4px; + letter-spacing: 0.3px; +} + +.menu-active-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + flex-shrink: 0; +} + +/* ============================================ + User Panel (드디어 user 실제 사용처!) + ============================================ */ +.user-panel { + position: relative; + padding: 10px; + border-top: 1px solid var(--border); + margin-top: auto; +} + +.user-panel-trigger { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + background: none; + border: none; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s; + color: var(--text); + text-align: left; +} + +.user-panel-trigger:hover { + background: var(--bg-3); +} + +.avatar { + width: 32px; + height: 32px; + border-radius: 8px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + color: #fff; + flex-shrink: 0; + overflow: hidden; +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.user-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.user-name { + font-size: 13px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text); +} + +.user-plan { + font-size: 11px; + color: var(--gold); +} + +.chevron { + font-size: 18px; + color: var(--text-3); + transition: transform 0.2s; + flex-shrink: 0; + line-height: 1; +} + +.chevron.open { + transform: rotate(90deg); +} + +/* User dropdown menu */ +.user-menu { + position: absolute; + bottom: calc(100% + 4px); + left: 10px; + right: 10px; + background: var(--bg-3); + border: 1px solid var(--border-2); + border-radius: 10px; + padding: 6px; + box-shadow: 0 -8px 32px #00000050; + animation: slideUp 0.15s ease; + z-index: 10; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.user-menu-email { + font-size: 11px; + color: var(--text-3); + padding: 4px 8px 8px; + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-menu-divider { + border: none; + border-top: 1px solid var(--border); + margin: 4px 0; +} + +.user-menu-item { + display: block; + width: 100%; + padding: 7px 10px; + background: none; + border: none; + border-radius: 6px; + text-align: left; + font-size: 13px; + color: var(--text-2); + cursor: pointer; + transition: + background 0.1s, + color 0.1s; +} + +.user-menu-item:hover { + background: var(--bg-4); + color: var(--text); +} + +.user-menu-item.danger { + color: var(--red); +} + +.user-menu-item.danger:hover { + background: #f8717115; + color: var(--red); +} + +/* ============================================ + Main Content + ============================================ */ +.main { + flex: 1; + margin-left: var(--sidebar-w); + padding: 32px; + min-height: 100vh; +} + +.main-content { + max-width: 900px; +} + +.content-header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 28px; +} + +.content-header h1 { + font-size: 24px; + font-weight: 700; + letter-spacing: -0.5px; + color: var(--text); +} + +.content-date { + font-size: 13px; + color: var(--text-3); +} + +/* ============================================ + Stats Grid + ============================================ */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 14px; + margin-bottom: 28px; +} + +.stat-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 6px; + transition: border-color 0.2s; +} + +.stat-card:hover { + border-color: var(--border-2); +} + +.stat-label { + font-size: 12px; + color: var(--text-3); + text-transform: uppercase; + letter-spacing: 0.6px; +} + +.stat-value { + font-size: 26px; + font-weight: 700; + letter-spacing: -1px; + color: var(--text); + line-height: 1.1; +} + +.stat-delta { + font-size: 12px; + font-weight: 500; +} + +.stat-delta.up { + color: var(--green); +} +.stat-delta.down { + color: var(--red); +} + +/* ============================================ + Drill Callout + ============================================ */ +.drill-callout { + display: flex; + gap: 14px; + background: #1e1a0e; + border: 1px solid #fbbf2430; + border-left: 3px solid var(--gold); + border-radius: var(--radius); + padding: 16px 20px; + margin-top: 8px; +} + +.callout-icon { + font-size: 18px; + color: var(--gold); + flex-shrink: 0; + margin-top: 1px; +} + +.drill-callout strong { + display: block; + font-size: 13px; + font-weight: 600; + color: var(--gold); + margin-bottom: 6px; +} + +.drill-callout p { + font-size: 13px; + color: var(--text-2); + line-height: 1.7; +} + +.drill-callout code { + font-family: var(--font-mono); + font-size: 12px; + background: #ffffff0d; + padding: 1px 5px; + border-radius: 4px; + color: var(--accent); +} + +.drill-callout em { + color: var(--red); + font-style: normal; + font-weight: 600; +} + +.move-route { + padding: 8px 14px; + background: var(--gold); + color: #000; + border: none; + border-radius: var(--radius); + font-size: 14px; + cursor: pointer; + transition: background 0.15s; + margin-top: 10px; +} + +.move-route:hover { + background: var(--gold-2); +} + +/* ============================================ + Responsive + ============================================ */ +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .stats-grid { + grid-template-columns: 1fr; + } + .main { + padding: 20px; + } +} diff --git a/log/workspace/react/ts/src/pages/context/dashboard/fix/index.tsx b/log/workspace/react/ts/src/pages/context/dashboard/fix/index.tsx new file mode 100644 index 0000000..22da5ad --- /dev/null +++ b/log/workspace/react/ts/src/pages/context/dashboard/fix/index.tsx @@ -0,0 +1,304 @@ +// PropDrillingDashboard.tsx +// 실제 SaaS 대시보드에서 흔히 발생하는 prop drilling 지옥 +// user 정보가 App → DashboardPage → DashboardLayout → Sidebar → SidebarMenu → SidebarMenuItem 까지 뚫고 내려감 + +import { createContext, useContext, useState } from "react"; +import "./dashboard.css"; +import { Link } from "react-router-dom"; + +// ============================================ +// Types +// ============================================ +type Plan = "free" | "pro" | "enterprise"; + +interface User { + id: string; + name: string; + email: string; + avatarUrl: string; + plan: Plan; +} + +interface contextFix { + user: User; + onLogout: () => void; +} + +const ContextFix = createContext({ + user: { id: "", name: "", email: "", avatarUrl: "", plan: "free" }, + onLogout: function () {}, +}); +// ============================================ +// App.tsx — 유저 상태 + 로그아웃 로직 보유 +// ============================================ +export default function App() { + const [user, setUser] = useState({ + id: "u_1a2b3c", + name: "김지수", + email: "jisu@company.io", + avatarUrl: + "https://i.pinimg.com/236x/3a/52/69/3a52694b8f231960a2625f838be6ea89.jpg", + plan: "pro", + }); + + const [loggedIn, setLoggedIn] = useState(true); + + function handleLogout() { + setLoggedIn(false); + setUser({ id: "", name: "", email: "", avatarUrl: "", plan: "free" }); + } + + if (!loggedIn) { + return ( +
+

로그아웃됐습니다 👋

+ +
+ ); + } + + // 😤 user랑 onLogout을 DashboardPage에 넘기기 시작... + return ( + + ; + + ); +} + +// ============================================ +// DashboardPage.tsx +// user, onLogout 받아서 → DashboardLayout에 넘김 +// 자기 자신은 그냥 페이지 타이틀만 씀 +// ============================================ + +function DashboardPage() { + return ( +
+ {/* user는 여기서 안 쓰고, 그대로 Layout에 꽂아줌 */} + + + +
+ ); +} + +// ============================================ +// DashboardLayout.tsx +// user, onLogout 받아서 → Sidebar에 넘김 +// 자기 자신은 레이아웃 구조만 담당 +// ============================================ +interface DashboardLayoutProps { + children: React.ReactNode; +} + +function DashboardLayout({ children }: DashboardLayoutProps) { + return ( +
+ {/* 또 그대로 전달... */} + +
{children}
+
+ ); +} + +// ============================================ +// Sidebar.tsx +// user, onLogout 받아서 → SidebarMenu에 넘김 +// 자기 자신은 사이드바 껍데기만 담당 +// ============================================ + +function Sidebar() { + return ( + + ); +} + +// ============================================ +// SidebarMenu.tsx +// user, onLogout 받아서 → 각 SidebarMenuItem에 넘김 +// 자기 자신은 메뉴 목록 구조만 담당 +// ============================================ +const MENU_ITEMS = [ + { id: "overview", icon: "⊞", label: "Overview", isActive: true }, + { id: "analytics", icon: "↗", label: "Analytics", isActive: false }, + { id: "files", icon: "⊟", label: "Files", isActive: false }, + { id: "settings", icon: "⊙", label: "Settings", isActive: false }, +]; + +function SidebarMenu() { + return ( + + ); +} + +// ============================================ +// SidebarMenuItem.tsx +// user, onLogout 받지만... 아이콘/라벨 메뉴 아이템인데 +// 굳이 user 정보가 왜 여기까지 내려와야 하지? 😑 +// 결국 그냥 isActive 뱃지 표시할 때 plan 체크용으로 씀 +// ============================================ +interface SidebarMenuItemProps { + icon: string; + label: string; + isActive: boolean; +} + +function SidebarMenuItem({ icon, label, isActive }: SidebarMenuItemProps) { + const context = useContext(ContextFix); + const isLocked = label === "Analytics" && context.user.plan === "free"; + + return ( +
  • + {icon} + {label} + {isLocked && Pro} + {isActive && } +
  • + ); +} + +// ============================================ +// SidebarUserPanel.tsx +// 드디어! user + onLogout 실제로 쓰는 컴포넌트 +// ============================================ +const PLAN_LABEL: Record = { + free: "Free ♪", + pro: "Pro ✦", + enterprise: "Enterprise ◍", +}; + +function SidebarUserPanel() { + const context = useContext(ContextFix); + const [showMenu, setShowMenu] = useState(false); + const initials = context.user.name + .split(" ") + .map((n) => n[0]) + .join("") + .slice(0, 2); + + return ( +
    + + + {showMenu && ( +
    +
    {context.user.email}
    +
    + + +
    + +
    + )} +
    + ); +} + +// ============================================ +// MainContent.tsx — props 없음, 그냥 콘텐츠 +// ============================================ +function MainContent() { + return ( +
    +
    +

    Overview

    + 2025년 4월 +
    + +
    + {[ + { label: "총 방문자", value: "24,821", delta: "+12.4%" }, + { label: "전환율", value: "3.6%", delta: "+0.8%" }, + { label: "월 매출", value: "₩4.2M", delta: "+21%" }, + { label: "활성 유저", value: "1,204", delta: "-2.1%" }, + ].map((stat) => ( +
    + {stat.label} + {stat.value} + + {stat.delta} + +
    + ))} +
    + +
    + +
    + Prop Drilling 경고 해결 +

    + useronLogout은{" "} + + App → DashboardPage → DashboardLayout → Sidebar → SidebarMenu → + SidebarMenuItem / SidebarUserPanel + + 까지 6단계를 뚫고 내려갔습니다. +
    + 중간 컴포넌트 4개는 이 props를 전달만 했을 뿐 사용하지 + 않았습니다. +

    + + + +
    +
    +
    + ); +} diff --git a/log/workspace/react/ts/src/pages/context/dashboard/index.tsx b/log/workspace/react/ts/src/pages/context/dashboard/index.tsx new file mode 100644 index 0000000..ad74edf --- /dev/null +++ b/log/workspace/react/ts/src/pages/context/dashboard/index.tsx @@ -0,0 +1,320 @@ +// PropDrillingDashboard.tsx +// 실제 SaaS 대시보드에서 흔히 발생하는 prop drilling 지옥 +// user 정보가 App → DashboardPage → DashboardLayout → Sidebar → SidebarMenu → SidebarMenuItem 까지 뚫고 내려감 + +import { useState } from "react"; +import "./dashboard.css"; +import { Link } from "react-router-dom"; + +// ============================================ +// Types +// ============================================ +type Plan = "free" | "pro" | "enterprise"; + +interface User { + id: string; + name: string; + email: string; + avatarUrl: string; + plan: Plan; +} + +// ============================================ +// App.tsx — 유저 상태 + 로그아웃 로직 보유 +// ============================================ +export default function App() { + const [user, setUser] = useState({ + id: "u_1a2b3c", + name: "김지수", + email: "jisu@company.io", + avatarUrl: + "https://i.pinimg.com/236x/3a/52/69/3a52694b8f231960a2625f838be6ea89.jpg", + plan: "pro", + }); + + const [loggedIn, setLoggedIn] = useState(true); + + function handleLogout() { + setLoggedIn(false); + setUser({ id: "", name: "", email: "", avatarUrl: "", plan: "free" }); + } + + if (!loggedIn) { + return ( +
    +

    로그아웃됐습니다 👋

    + +
    + ); + } + + // 😤 user랑 onLogout을 DashboardPage에 넘기기 시작... + return ; +} + +// ============================================ +// DashboardPage.tsx +// user, onLogout 받아서 → DashboardLayout에 넘김 +// 자기 자신은 그냥 페이지 타이틀만 씀 +// ============================================ +interface DashboardPageProps { + user: User; // ← 직접 안 씀 + onLogout: () => void; // ← 직접 안 씀 +} + +function DashboardPage({ user, onLogout }: DashboardPageProps) { + return ( +
    + {/* user는 여기서 안 쓰고, 그대로 Layout에 꽂아줌 */} + + + +
    + ); +} + +// ============================================ +// DashboardLayout.tsx +// user, onLogout 받아서 → Sidebar에 넘김 +// 자기 자신은 레이아웃 구조만 담당 +// ============================================ +interface DashboardLayoutProps { + user: User; // ← 직접 안 씀 + onLogout: () => void; // ← 직접 안 씀 + children: React.ReactNode; +} + +function DashboardLayout({ user, onLogout, children }: DashboardLayoutProps) { + return ( +
    + {/* 또 그대로 전달... */} + +
    {children}
    +
    + ); +} + +// ============================================ +// Sidebar.tsx +// user, onLogout 받아서 → SidebarMenu에 넘김 +// 자기 자신은 사이드바 껍데기만 담당 +// ============================================ +interface SidebarProps { + user: User; // ← 직접 안 씀 + onLogout: () => void; // ← 직접 안 씀 +} + +function Sidebar({ user, onLogout }: SidebarProps) { + return ( + + ); +} + +// ============================================ +// SidebarMenu.tsx +// user, onLogout 받아서 → 각 SidebarMenuItem에 넘김 +// 자기 자신은 메뉴 목록 구조만 담당 +// ============================================ +interface SidebarMenuProps { + user: User; // ← 직접 안 씀 (아이템에 넘김) + onLogout: () => void; // ← 직접 안 씀 (아이템에 넘김) +} + +const MENU_ITEMS = [ + { id: "overview", icon: "⊞", label: "Overview", isActive: true }, + { id: "analytics", icon: "↗", label: "Analytics", isActive: false }, + { id: "files", icon: "⊟", label: "Files", isActive: false }, + { id: "settings", icon: "⊙", label: "Settings", isActive: false }, +]; + +function SidebarMenu({ user, onLogout }: SidebarMenuProps) { + return ( + + ); +} + +// ============================================ +// SidebarMenuItem.tsx +// user, onLogout 받지만... 아이콘/라벨 메뉴 아이템인데 +// 굳이 user 정보가 왜 여기까지 내려와야 하지? 😑 +// 결국 그냥 isActive 뱃지 표시할 때 plan 체크용으로 씀 +// ============================================ +interface SidebarMenuItemProps { + icon: string; + label: string; + isActive: boolean; + user: User; // ← plan 체크 때문에 받긴 함... + onLogout: () => void; // ← 여기선 안 씀. 근데 받아야 함 (SidebarUserPanel에 안 내려가니까) +} + +function SidebarMenuItem({ + icon, + label, + isActive, + user, + onLogout, // 여기선 쓰지도 않는데 받아야 했던 불쌍한 prop +}: SidebarMenuItemProps) { + const isLocked = label === "Analytics" && user.plan === "free"; + + return ( +
  • + {icon} + {label} + {isLocked && Pro} + {isActive && } +
  • + ); +} + +// ============================================ +// SidebarUserPanel.tsx +// 드디어! user + onLogout 실제로 쓰는 컴포넌트 +// ============================================ +interface SidebarUserPanelProps { + user: User; + onLogout: () => void; +} + +const PLAN_LABEL: Record = { + free: "Free ♪", + pro: "Pro ✦", + enterprise: "Enterprise ◍", +}; + +function SidebarUserPanel({ user, onLogout }: SidebarUserPanelProps) { + const [showMenu, setShowMenu] = useState(false); + const initials = user.name + .split(" ") + .map((n) => n[0]) + .join("") + .slice(0, 2); + + return ( +
    + + + {showMenu && ( +
    +
    {user.email}
    +
    + + +
    + +
    + )} +
    + ); +} + +// ============================================ +// MainContent.tsx — props 없음, 그냥 콘텐츠 +// ============================================ +function MainContent() { + return ( +
    +
    +

    Overview

    + 2025년 4월 +
    + +
    + {[ + { label: "총 방문자", value: "24,821", delta: "+12.4%" }, + { label: "전환율", value: "3.6%", delta: "+0.8%" }, + { label: "월 매출", value: "₩4.2M", delta: "+21%" }, + { label: "활성 유저", value: "1,204", delta: "-2.1%" }, + ].map((stat) => ( +
    + {stat.label} + {stat.value} + + {stat.delta} + +
    + ))} +
    + +
    + +
    + Prop Drilling 경고 +

    + useronLogout은{" "} + + App → DashboardPage → DashboardLayout → Sidebar → SidebarMenu → + SidebarMenuItem / SidebarUserPanel + + 까지 6단계를 뚫고 내려갔습니다. +
    + 중간 컴포넌트 4개는 이 props를 전달만 했을 뿐 사용하지 + 않았습니다. +

    + + + +
    +
    +
    + ); +} diff --git a/log/workspace/react/ts/src/pages/context/index.tsx b/log/workspace/react/ts/src/pages/context/index.tsx new file mode 100644 index 0000000..743d996 --- /dev/null +++ b/log/workspace/react/ts/src/pages/context/index.tsx @@ -0,0 +1,220 @@ +import { createContext, useContext } from "react"; +import "./context.css"; + +// export function index() { +// const ProviderTest = createContext(""); +// return ; +// } + +// types.ts +interface UserInfo { + name: string; + email: string; + role: "admin" | "user"; +} + +interface UserInfoFix { + name: string; + email: string; + role: "admin" | "user"; + theme: string; + locale: string; +} + +// ============================================ +// App.tsx — 데이터 원천 +// ============================================ +const ProviderTest = createContext({ + name: "", + email: "", + role: "admin", + theme: "", + locale: "", +}); + +function index() { + const userInfo: UserInfo = { + name: "김철수", + email: "kim@example.com", + role: "admin", + }; + const theme = "dark"; + const locale = "ko-KR"; + + return ( +
    + + + + +
    + ); +} + +// ============================================ +// Page.tsx — 쓰지도 않으면서 전달만 함 😑 +// ============================================ +interface PageProps { + userInfo: UserInfo; + theme: string; + locale: string; +} + +function Page({ userInfo, theme, locale }: PageProps) { + return ( +
    +

    페이지

    + {/* userInfo, theme, locale 하나도 안 씀 */} +
    +
    + ); +} + +function PageFix() { + return ( +
    +

    페이지 context 사용

    + {/* userInfo, theme, locale 하나도 안 씀 */} + +
    + ); +} + +// ============================================ +// Section.tsx — 마찬가지로 그냥 통과 👆 +// ============================================ +interface SectionProps { + userInfo: UserInfo; + theme: string; + locale: string; +} + +function Section({ userInfo, theme, locale }: SectionProps) { + return ( +
    +

    섹션

    + {/* 이것도 안 씀 ㅎ */} + +
    + ); +} + +function SectionFix() { + return ( +
    +

    섹션

    + {/* 이것도 안 씀 ㅎ */} + +
    + ); +} + +// ============================================ +// Panel.tsx — 역시 패스 🤦 +// ============================================ +interface PanelProps { + userInfo: UserInfo; + theme: string; + locale: string; +} + +function Panel({ userInfo, theme, locale }: PanelProps) { + return ( +
    +

    패널

    + +
    + ); +} + +function PanelFix() { + return ( +
    +

    패널

    + +
    + ); +} + +// ============================================ +// Widget.tsx — 또 패스 😭 +// ============================================ +interface WidgetProps { + userInfo: UserInfo; + theme: string; + locale: string; +} + +function Widget({ userInfo, theme, locale }: WidgetProps) { + return ( +
    + +
    + ); +} + +function WidgetFix() { + return ( +
    + +
    + ); +} + +// ============================================ +// DeepChild.tsx — 드디어!!!! 여기서 씀 🎉 +// ============================================ +interface DeepChildProps { + userInfo: UserInfo; + theme: string; + locale: string; +} + +function DeepChild({ userInfo, theme, locale }: DeepChildProps) { + return ( +
    +

    + 안녕하세요, {userInfo.name}님! +

    +

    + 권한: + + {userInfo.role === "admin" ? "관리자" : "일반 사용자"} + +

    +

    + 언어 설정: {locale} +

    +
    + ); +} + +function DeepChildFix() { + const userInfo = useContext(ProviderTest); + return ( +
    +

    + 안녕하세요, {userInfo.name}님! +

    +

    + 권한: + + {userInfo.role === "admin" ? "관리자" : "일반 사용자"} + +

    +

    + 언어 설정: {userInfo.locale} +

    +
    + ); +} + +export default index;