From 049fec92c818fbd6319555d620310963c0caf2fe Mon Sep 17 00:00:00 2001 From: KimBoYoung Date: Wed, 1 Jan 2025 15:43:38 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 3 ++- package.json | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d2ad619..5998b58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", "vite": "^5.4.1", - "vitest": "^2.1.2" + "vitest": "^2.1.8" } }, "node_modules/@adobe/css-tools": { @@ -4766,6 +4766,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.8.tgz", "integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==", "dev": true, + "license": "MIT", "dependencies": { "@vitest/expect": "2.1.8", "@vitest/mocker": "2.1.8", diff --git a/package.json b/package.json index b4ad5f8..7655d81 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,10 @@ "@vitejs/plugin-react-swc": "^3.5.0", "@vitest/coverage-v8": "^2.1.2", "eslint": "^9.9.0", - "eslint-plugin-react-hooks": "^5.1.0-rc.0", - "eslint-plugin-react-refresh": "^0.4.9", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react-hooks": "^5.1.0-rc.0", + "eslint-plugin-react-refresh": "^0.4.9", "globals": "^15.9.0", "husky": "^9.1.7", "jsdom": "^25.0.1", @@ -44,6 +44,6 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", "vite": "^5.4.1", - "vitest": "^2.1.2" + "vitest": "^2.1.8" } } From 329a7589a52bf26845c2a0022e4b82c1996c4658 Mon Sep 17 00:00:00 2001 From: KimBoYoung Date: Wed, 1 Jan 2025 15:51:24 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/equalities/deepEquals.ts | 21 ++++++++++++++++++++- src/@lib/equalities/shallowEquals.ts | 16 ++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/@lib/equalities/deepEquals.ts b/src/@lib/equalities/deepEquals.ts index af583d1..6fbaba6 100644 --- a/src/@lib/equalities/deepEquals.ts +++ b/src/@lib/equalities/deepEquals.ts @@ -1,3 +1,22 @@ export function deepEquals(objA: T, objB: T): boolean { - return objA === objB; + if (objA === objB) return true; + + if (objA === null || objB === null || typeof objA !== "object" || typeof objB !== "object") return false; + + const isArrayA = Array.isArray(objA); + const isArrayB = Array.isArray(objB); + if (isArrayA && isArrayB) { + if (objA.length !== objB.length) return false; + return objA.every((item, index) => deepEquals(item, objB[index])); + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + if (keysA.length !== keysB.length) return false; + + for (const key of keysA) + if (!deepEquals(objA[key as keyof T], objB[key as keyof T])) + return false + + return true; } diff --git a/src/@lib/equalities/shallowEquals.ts b/src/@lib/equalities/shallowEquals.ts index 56bf666..7b20ebc 100644 --- a/src/@lib/equalities/shallowEquals.ts +++ b/src/@lib/equalities/shallowEquals.ts @@ -1,3 +1,15 @@ export function shallowEquals(objA: T, objB: T): boolean { - return objA === objB; -} + if (objA === objB) return true; + + if (objA === null || objB === null || typeof objA !== "object" || typeof objB !== "object") return false; + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + if (keysA.length !== keysB.length) return false; + + for (const key of keysA) { + if (objA[key as keyof T] !== objB[key as keyof T]) return false; + } + + return true; +} \ No newline at end of file From b4d8ea4eb9b78206d7d156508dd910925d711c52 Mon Sep 17 00:00:00 2001 From: KimBoYoung Date: Wed, 1 Jan 2025 17:00:37 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20Hoc=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/equalities/shallowEquals.ts | 3 ++- src/@lib/hocs/deepMemo.ts | 1 + src/@lib/hocs/memo.ts | 24 ++++++++++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/@lib/equalities/shallowEquals.ts b/src/@lib/equalities/shallowEquals.ts index 7b20ebc..8fb6e92 100644 --- a/src/@lib/equalities/shallowEquals.ts +++ b/src/@lib/equalities/shallowEquals.ts @@ -1,7 +1,8 @@ export function shallowEquals(objA: T, objB: T): boolean { if (objA === objB) return true; - if (objA === null || objB === null || typeof objA !== "object" || typeof objB !== "object") return false; + if (objA === null || objB === null || + typeof objA !== "object" || typeof objB !== "object") return false; const keysA = Object.keys(objA); const keysB = Object.keys(objB); diff --git a/src/@lib/hocs/deepMemo.ts b/src/@lib/hocs/deepMemo.ts index dae45d2..2cb9dc4 100644 --- a/src/@lib/hocs/deepMemo.ts +++ b/src/@lib/hocs/deepMemo.ts @@ -2,6 +2,7 @@ import { deepEquals } from "../equalities"; import { ComponentType } from "react"; import { memo } from "./memo.ts"; +// deepMemo HOC는 컴포넌트의 props를 깊은 비교하여 불필요한 리렌더링을 방지합니다. export function deepMemo

(Component: ComponentType

) { return memo(Component, deepEquals); } diff --git a/src/@lib/hocs/memo.ts b/src/@lib/hocs/memo.ts index d43559d..76d1dcf 100644 --- a/src/@lib/hocs/memo.ts +++ b/src/@lib/hocs/memo.ts @@ -1,10 +1,30 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { shallowEquals } from "../equalities"; -import { ComponentType } from "react"; +import { ComponentType, createElement, useRef } from "react"; +// memo HOC는 컴포넌트의 props를 얕은 비교하여 불필요한 리렌더링을 방지합니다. export function memo

( Component: ComponentType

, _equals = shallowEquals, ) { - return Component; + // 1. 이전 props를 저장할 ref 생성 + // 2. 메모이제이션된 컴포넌트 생성 + // 3. equals 함수를 사용하여 props 비교 + // 4. props가 변경된 경우에만 새로운 렌더링 수행 + + return function(props: P) { + const prevPropsRef = useRef

(null); + const memoizedResultRef = useRef(null); + + if (prevPropsRef.current === null || !_equals(prevPropsRef.current, props)) { + console.log("Props changed, re-rendering"); + memoizedResultRef.current = createElement(Component, props); + } else { + console.log("Props unchanged, using memoized result"); + } + + prevPropsRef.current = props; + + return memoizedResultRef.current!; + }; } From 3e12afa65b95c75e7e3d661ad8844f0ef562c3a5 Mon Sep 17 00:00:00 2001 From: KimBoYoung Date: Wed, 1 Jan 2025 17:22:22 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20useRef=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/hooks/useRef.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/@lib/hooks/useRef.ts b/src/@lib/hooks/useRef.ts index 2dc9e83..8193734 100644 --- a/src/@lib/hooks/useRef.ts +++ b/src/@lib/hooks/useRef.ts @@ -1,4 +1,8 @@ +import { useState } from "react"; + +// useRef 훅은 렌더링 사이에 값을 유지하는 가변 ref 객체를 생성합니다. export function useRef(initialValue: T): { current: T } { - // React의 useState를 이용해서 만들어보세요. - return { current: initialValue }; + const [ref] = useState(() => ({current: initialValue})); + + return ref; } From 83de3cec406614e94c305f3748bfbe18915de5c7 Mon Sep 17 00:00:00 2001 From: KimBoYoung Date: Wed, 1 Jan 2025 20:35:43 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20useMemo=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/hooks/useMemo.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/@lib/hooks/useMemo.ts b/src/@lib/hooks/useMemo.ts index 95930d6..00aa642 100644 --- a/src/@lib/hooks/useMemo.ts +++ b/src/@lib/hooks/useMemo.ts @@ -1,12 +1,33 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { DependencyList } from "react"; import { shallowEquals } from "../equalities"; +import { useRef } from "./useRef"; +// useMemo 훅은 계산 비용이 높은 값을 메모이제이션합니다. export function useMemo( - factory: () => T, - _deps: DependencyList, + factory: () => T, + _deps: DependencyList, _equals = shallowEquals, ): T { - // 직접 작성한 useRef를 통해서 만들어보세요. - return factory(); + // 1. 이전 의존성과 결과를 저장할 ref 생성 + // 2. 현재 의존성과 이전 의존성 비교 + // 3. 의존성이 변경된 경우 factory 함수 실행 및 결과 저장 + // 4. 메모이제이션된 값 반환 + + const memoized = useRef<{ + deps: DependencyList | null; + value: T | null; + }>({ + deps: null, + value: null, + }); + + const equals = !memoized.current.deps || !_equals(memoized.current.deps, _deps); + + if (equals) { + memoized.current.value = factory(); + memoized.current.deps = _deps; + } + + return memoized.current.value as T; } From e1460c52942ae8935818b3e43c97a68933992732 Mon Sep 17 00:00:00 2001 From: KimBoYoung Date: Wed, 1 Jan 2025 20:50:25 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20useCallback=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/hooks/useCallback.ts | 5 +++-- src/@lib/hooks/useDeepMemo.ts | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/@lib/hooks/useCallback.ts b/src/@lib/hooks/useCallback.ts index e71e647..5b64960 100644 --- a/src/@lib/hooks/useCallback.ts +++ b/src/@lib/hooks/useCallback.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-unsafe-function-type */ import { DependencyList } from "react"; +import { useMemo } from "./useMemo"; export function useCallback( factory: T, _deps: DependencyList, ) { - // 직접 작성한 useMemo를 통해서 만들어보세요. - return factory as T; + const memoizedCallback = useMemo(() => factory, _deps); + return memoizedCallback; } diff --git a/src/@lib/hooks/useDeepMemo.ts b/src/@lib/hooks/useDeepMemo.ts index d06b34d..ac3713b 100644 --- a/src/@lib/hooks/useDeepMemo.ts +++ b/src/@lib/hooks/useDeepMemo.ts @@ -4,6 +4,5 @@ import { useMemo } from "./useMemo"; import { deepEquals } from "../equalities"; export function useDeepMemo(factory: () => T, deps: DependencyList): T { - // 직접 작성한 useMemo를 참고해서 만들어보세요. return useMemo(factory, deps, deepEquals); } From 904b19a2a69a527dabc3c9b4bfe0b050185b6f72 Mon Sep 17 00:00:00 2001 From: KimBoYoung Date: Wed, 1 Jan 2025 23:02:07 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20=ED=9B=85=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 275 +------------------------- src/components/ComplexForm.tsx | 88 +++++++++ src/components/Header.tsx | 48 +++++ src/components/ItemList.tsx | 63 ++++++ src/components/NotificationSystem.tsx | 35 ++++ src/contexts/NotificationContext.tsx | 37 ++++ src/types/type.ts | 21 ++ 7 files changed, 300 insertions(+), 267 deletions(-) create mode 100644 src/components/ComplexForm.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/ItemList.tsx create mode 100644 src/components/NotificationSystem.tsx create mode 100644 src/contexts/NotificationContext.tsx create mode 100644 src/types/type.ts diff --git a/src/App.tsx b/src/App.tsx index debd645..b7d2a82 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,270 +1,11 @@ -import React, { useState, createContext, useContext } from "react"; -import { generateItems, renderLog } from "./utils"; - -// 타입 정의 -interface Item { - id: number; - name: string; - category: string; - price: number; -} - -interface User { - id: number; - name: string; - email: string; -} - -interface Notification { - id: number; - message: string; - type: "info" | "success" | "warning" | "error"; -} - -// AppContext 타입 정의 -interface AppContextType { - theme: string; - toggleTheme: () => void; - user: User | null; - login: (email: string, password: string) => void; - logout: () => void; - notifications: Notification[]; - addNotification: (message: string, type: Notification["type"]) => void; - removeNotification: (id: number) => void; -} - -const AppContext = createContext(undefined); - -// 커스텀 훅: useAppContext -const useAppContext = () => { - const context = useContext(AppContext); - if (context === undefined) { - throw new Error("useAppContext must be used within an AppProvider"); - } - return context; -}; - -// Header 컴포넌트 -export const Header: React.FC = () => { - renderLog("Header rendered"); - const { theme, toggleTheme, user, login, logout } = useAppContext(); - - const handleLogin = () => { - // 실제 애플리케이션에서는 사용자 입력을 받아야 합니다. - login("user@example.com", "password"); - }; - - return ( -

-
-

샘플 애플리케이션

-
- - {user ? ( -
- {user.name}님 환영합니다! - -
- ) : ( - - )} -
-
-
- ); -}; - -// ItemList 컴포넌트 -export const ItemList: React.FC<{ - items: Item[]; - onAddItemsClick: () => void; -}> = ({ items, onAddItemsClick }) => { - renderLog("ItemList rendered"); - const [filter, setFilter] = useState(""); - const { theme } = useAppContext(); - - const filteredItems = items.filter( - (item) => - item.name.toLowerCase().includes(filter.toLowerCase()) || - item.category.toLowerCase().includes(filter.toLowerCase()), - ); - - const totalPrice = filteredItems.reduce((sum, item) => sum + item.price, 0); - - const averagePrice = Math.round(totalPrice / filteredItems.length) || 0; - - return ( -
-
-

상품 목록

-
- -
-
- setFilter(e.target.value)} - className="w-full p-2 mb-4 border border-gray-300 rounded text-black" - /> -
    -
  • 검색결과: {filteredItems.length.toLocaleString()}개
  • -
  • 전체가격: {totalPrice.toLocaleString()}원
  • -
  • 평균가격: {averagePrice.toLocaleString()}원
  • -
-
    - {filteredItems.map((item, index) => ( -
  • - {item.name} - {item.category} - {item.price.toLocaleString()}원 -
  • - ))} -
-
- ); -}; - -// ComplexForm 컴포넌트 -export const ComplexForm: React.FC = () => { - renderLog("ComplexForm rendered"); - const { addNotification } = useAppContext(); - const [formData, setFormData] = useState({ - name: "", - email: "", - age: 0, - preferences: [] as string[], - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addNotification("폼이 성공적으로 제출되었습니다", "success"); - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ - ...prev, - [name]: name === "age" ? parseInt(value) || 0 : value, - })); - }; - - const handlePreferenceChange = (preference: string) => { - setFormData((prev) => ({ - ...prev, - preferences: prev.preferences.includes(preference) - ? prev.preferences.filter((p) => p !== preference) - : [...prev.preferences, preference], - })); - }; - - return ( -
-

복잡한 폼

-
- - - -
- {["독서", "운동", "음악", "여행"].map((pref) => ( - - ))} -
- -
-
- ); -}; - -// NotificationSystem 컴포넌트 -export const NotificationSystem: React.FC = () => { - renderLog("NotificationSystem rendered"); - const { notifications, removeNotification } = useAppContext(); - - return ( -
- {notifications.map((notification) => ( -
- {notification.message} - -
- ))} -
- ); -}; +import React, { useState } from "react"; +import { User, Notification } from "./types/type"; +import { generateItems } from "./utils"; +import { Header } from "./components/Header"; +import { ItemList } from "./components/ItemList"; +import { ComplexForm } from "./components/ComplexForm"; +import { NotificationSystem } from "./components/NotificationSystem"; +import { AppContext, AppContextType } from "./contexts/useAppContext"; // 메인 App 컴포넌트 const App: React.FC = () => { diff --git a/src/components/ComplexForm.tsx b/src/components/ComplexForm.tsx new file mode 100644 index 0000000..7fdcd3e --- /dev/null +++ b/src/components/ComplexForm.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { useAppContext } from "../contexts/useAppContext"; +import { renderLog } from "../utils"; + +// ComplexForm 컴포넌트 +export const ComplexForm: React.FC = () => { + renderLog("ComplexForm rendered"); + const { addNotification } = useAppContext(); + const [formData, setFormData] = useState({ + name: "", + email: "", + age: 0, + preferences: [] as string[], + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addNotification("폼이 성공적으로 제출되었습니다", "success"); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: name === "age" ? parseInt(value) || 0 : value, + })); + }; + + const handlePreferenceChange = (preference: string) => { + setFormData((prev) => ({ + ...prev, + preferences: prev.preferences.includes(preference) + ? prev.preferences.filter((p) => p !== preference) + : [...prev.preferences, preference], + })); + }; + + return ( +
+

복잡한 폼

+
+ + + +
+ {["독서", "운동", "음악", "여행"].map((pref) => ( + + ))} +
+ +
+
+ ); + }; \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..d8b49cc --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { renderLog } from "../utils"; +import { useAppContext } from "../contexts/useAppContext"; +import { useCallback } from "../@lib"; + +// Header 컴포넌트 +export const Header: React.FC = () => { + renderLog("Header rendered"); + const { theme, toggleTheme, user, login, logout } = useAppContext(); + + const handleLogin = useCallback(() => { + login("user@example.com", "password"); + }, [login]); + + return ( +
+
+

샘플 애플리케이션

+
+ + {user ? ( +
+ {user.name}님 환영합니다! + +
+ ) : ( + + )} +
+
+
+ ); + }; \ No newline at end of file diff --git a/src/components/ItemList.tsx b/src/components/ItemList.tsx new file mode 100644 index 0000000..ff1f67b --- /dev/null +++ b/src/components/ItemList.tsx @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { Item } from "../types/type"; +import { renderLog } from "../utils"; +import { useAppContext } from "../contexts/useAppContext"; + +// ItemList 컴포넌트 +export const ItemList: React.FC<{ + items: Item[]; + onAddItemsClick: () => void; + }> = ({ items, onAddItemsClick }) => { + renderLog("ItemList rendered"); + const [filter, setFilter] = useState(""); + const { theme } = useAppContext(); + + const filteredItems = items.filter( + (item) => + item.name.toLowerCase().includes(filter.toLowerCase()) || + item.category.toLowerCase().includes(filter.toLowerCase()), + ); + + const totalPrice = filteredItems.reduce((sum, item) => sum + item.price, 0); + + const averagePrice = Math.round(totalPrice / filteredItems.length) || 0; + + return ( +
+
+

상품 목록

+
+ +
+
+ setFilter(e.target.value)} + className="w-full p-2 mb-4 border border-gray-300 rounded text-black" + /> +
    +
  • 검색결과: {filteredItems.length.toLocaleString()}개
  • +
  • 전체가격: {totalPrice.toLocaleString()}원
  • +
  • 평균가격: {averagePrice.toLocaleString()}원
  • +
+
    + {filteredItems.map((item, index) => ( +
  • + {item.name} - {item.category} - {item.price.toLocaleString()}원 +
  • + ))} +
+
+ ); + }; \ No newline at end of file diff --git a/src/components/NotificationSystem.tsx b/src/components/NotificationSystem.tsx new file mode 100644 index 0000000..fa2cf15 --- /dev/null +++ b/src/components/NotificationSystem.tsx @@ -0,0 +1,35 @@ +import { useAppContext } from "../contexts/useAppContext"; +import { renderLog } from "../utils"; + +// NotificationSystem 컴포넌트 +export const NotificationSystem: React.FC = () => { + renderLog("NotificationSystem rendered"); + const { notifications, removeNotification } = useAppContext(); + + return ( +
+ {notifications.map((notification) => ( +
+ {notification.message} + +
+ ))} +
+ ); + }; \ No newline at end of file diff --git a/src/contexts/NotificationContext.tsx b/src/contexts/NotificationContext.tsx new file mode 100644 index 0000000..c5e68f4 --- /dev/null +++ b/src/contexts/NotificationContext.tsx @@ -0,0 +1,37 @@ +import React, { createContext, useState, ReactNode } from "react"; +import { useCallback } from "../@lib"; + +interface Notification { + id: number; + message: string; +} + +interface NotificationContextType { + notifications: Notification[]; + addNotification: (message: string) => void; + removeNotification: (id: number) => void; +} + +export const NotificationContext = createContext(undefined); + +interface NotificationProviderProps { + children: ReactNode; +} + +export const NotificationProvider: React.FC = ({ children }) => { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback((message: string) => { + setNotifications((prev) => [...prev, { id: Date.now(), message }]); + }, []); + + const removeNotification = useCallback((id: number) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/types/type.ts b/src/types/type.ts new file mode 100644 index 0000000..d72e1fd --- /dev/null +++ b/src/types/type.ts @@ -0,0 +1,21 @@ +// 타입 정의 +export interface Item { + id: number; + name: string; + category: string; + price: number; + } + +export interface User { + id: number; + name: string; + email: string; +} + +export interface Notification { + id: number; + message: string; + type: "info" | "success" | "warning" | "error"; +} + + \ No newline at end of file From 2ca54eda03303fcc66308176c7febc18a44680bc Mon Sep 17 00:00:00 2001 From: KimBoYoung Date: Thu, 2 Jan 2025 00:34:32 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat:=20=EC=B5=9C=EC=A0=81=ED=99=94=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 97 +++++++------------- src/components/ComplexForm.tsx | 57 +++++++----- src/components/Header.tsx | 13 +-- src/components/ItemList.tsx | 124 ++++++++++++++------------ src/components/NotificationSystem.tsx | 9 +- src/contexts/AuthContext.tsx | 46 ++++++++++ src/contexts/NotificationContext.tsx | 41 ++++++--- src/contexts/ThemeContext.tsx | 37 ++++++++ src/contexts/useAppContext.tsx | 25 ++++++ 9 files changed, 287 insertions(+), 162 deletions(-) create mode 100644 src/contexts/AuthContext.tsx create mode 100644 src/contexts/ThemeContext.tsx create mode 100644 src/contexts/useAppContext.tsx diff --git a/src/App.tsx b/src/App.tsx index b7d2a82..0cf69cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,22 +1,17 @@ import React, { useState } from "react"; -import { User, Notification } from "./types/type"; -import { generateItems } from "./utils"; +import { ComplexForm } from "./components/ComplexForm"; import { Header } from "./components/Header"; import { ItemList } from "./components/ItemList"; -import { ComplexForm } from "./components/ComplexForm"; import { NotificationSystem } from "./components/NotificationSystem"; -import { AppContext, AppContextType } from "./contexts/useAppContext"; +import { AuthProvider } from "./contexts/AuthContext"; +import { NotificationProvider } from "./contexts/NotificationContext"; +import { ThemeProvider, useTheme } from "./contexts/ThemeContext"; +import { generateItems } from "./utils"; // 메인 App 컴포넌트 -const App: React.FC = () => { - const [theme, setTheme] = useState("light"); +const AppContent: React.FC = () => { + const { theme } = useTheme(); const [items, setItems] = useState(generateItems(1000)); - const [user, setUser] = useState(null); - const [notifications, setNotifications] = useState([]); - - const toggleTheme = () => { - setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); - }; const addItems = () => { setItems((prevItems) => [ @@ -25,61 +20,37 @@ const App: React.FC = () => { ]); }; - const login = (email: string) => { - setUser({ id: 1, name: "홍길동", email }); - addNotification("성공적으로 로그인되었습니다", "success"); - }; - - const logout = () => { - setUser(null); - addNotification("로그아웃되었습니다", "info"); - }; - - const addNotification = (message: string, type: Notification["type"]) => { - const newNotification: Notification = { - id: Date.now(), - message, - type, - }; - setNotifications((prev) => [...prev, newNotification]); - }; - - const removeNotification = (id: number) => { - setNotifications((prev) => - prev.filter((notification) => notification.id !== id), - ); - }; - - const contextValue: AppContextType = { - theme, - toggleTheme, - user, - login, - logout, - notifications, - addNotification, - removeNotification, - }; - return ( - -
-
-
-
-
- -
-
- -
+
+
+
+
+
+ +
+
+
-
- + +
+ ); +}; + +const App: React.FC = () => { + return ( + + + + + + + ); }; diff --git a/src/components/ComplexForm.tsx b/src/components/ComplexForm.tsx index 7fdcd3e..8f25845 100644 --- a/src/components/ComplexForm.tsx +++ b/src/components/ComplexForm.tsx @@ -1,11 +1,13 @@ import { useState } from "react"; -import { useAppContext } from "../contexts/useAppContext"; +import { useNotification } from "../contexts/NotificationContext"; import { renderLog } from "../utils"; +import { memo, useCallback } from "../@lib"; // ComplexForm 컴포넌트 -export const ComplexForm: React.FC = () => { +export const ComplexForm: React.FC = memo(() => { renderLog("ComplexForm rendered"); - const { addNotification } = useAppContext(); + const { addNotification } = useNotification(); + const [formData, setFormData] = useState({ name: "", email: "", @@ -13,27 +15,36 @@ export const ComplexForm: React.FC = () => { preferences: [] as string[], }); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addNotification("폼이 성공적으로 제출되었습니다", "success"); - }; + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + addNotification("폼이 성공적으로 제출되었습니다", "success"); + }, + [addNotification] + ); - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData((prev) => ({ - ...prev, - [name]: name === "age" ? parseInt(value) || 0 : value, - })); - }; + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: name === "age" ? parseInt(value) || 0 : value, + })); + }, + [] + ); - const handlePreferenceChange = (preference: string) => { - setFormData((prev) => ({ - ...prev, - preferences: prev.preferences.includes(preference) - ? prev.preferences.filter((p) => p !== preference) - : [...prev.preferences, preference], - })); - }; + const handlePreferenceChange = useCallback( + (preference: string) => { + setFormData((prev) => ({ + ...prev, + preferences: prev.preferences.includes(preference) + ? prev.preferences.filter((p) => p !== preference) + : [...prev.preferences, preference], + })); + }, + [] + ); return (
@@ -85,4 +96,4 @@ export const ComplexForm: React.FC = () => {
); - }; \ No newline at end of file + }); \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx index d8b49cc..499cc4b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,12 +1,15 @@ import React from "react"; +import { memo, useCallback } from "../@lib"; +import { useAuth } from "../contexts/AuthContext"; +import { useTheme } from "../contexts/ThemeContext"; import { renderLog } from "../utils"; -import { useAppContext } from "../contexts/useAppContext"; -import { useCallback } from "../@lib"; // Header 컴포넌트 -export const Header: React.FC = () => { +export const Header: React.FC = memo(() => { renderLog("Header rendered"); - const { theme, toggleTheme, user, login, logout } = useAppContext(); + + const { user, logout, login } = useAuth(); + const { theme, toggleTheme } = useTheme(); const handleLogin = useCallback(() => { login("user@example.com", "password"); @@ -45,4 +48,4 @@ export const Header: React.FC = () => {
); - }; \ No newline at end of file + }); \ No newline at end of file diff --git a/src/components/ItemList.tsx b/src/components/ItemList.tsx index ff1f67b..1d9961a 100644 --- a/src/components/ItemList.tsx +++ b/src/components/ItemList.tsx @@ -1,63 +1,75 @@ import { useState } from "react"; +import { memo, useCallback, useMemo } from "../@lib"; +import { useTheme } from "../contexts/ThemeContext"; import { Item } from "../types/type"; import { renderLog } from "../utils"; -import { useAppContext } from "../contexts/useAppContext"; // ItemList 컴포넌트 export const ItemList: React.FC<{ - items: Item[]; - onAddItemsClick: () => void; - }> = ({ items, onAddItemsClick }) => { - renderLog("ItemList rendered"); - const [filter, setFilter] = useState(""); - const { theme } = useAppContext(); - - const filteredItems = items.filter( - (item) => - item.name.toLowerCase().includes(filter.toLowerCase()) || - item.category.toLowerCase().includes(filter.toLowerCase()), - ); - - const totalPrice = filteredItems.reduce((sum, item) => sum + item.price, 0); - - const averagePrice = Math.round(totalPrice / filteredItems.length) || 0; - - return ( -
-
-

상품 목록

-
- -
+items: Item[]; +onAddItemsClick: () => void; +}> = memo(({ items, onAddItemsClick }) => { +renderLog("ItemList rendered"); + +const { theme } = useTheme(); +const [filter, setFilter] = useState(""); + +const filteredItems = useMemo(() => { +return items.filter( + (item) => + item.name.toLowerCase().includes(filter.toLowerCase()) || + item.category.toLowerCase().includes(filter.toLowerCase()) +); +}, [items, filter]); + +const totalPrice = useMemo(() => { +return filteredItems.reduce((sum, item) => sum + item.price, 0); +}, [filteredItems]); + +const averagePrice = useMemo(() => { +return Math.round(totalPrice / filteredItems.length) || 0; +}, [totalPrice, filteredItems.length]); + +const handleAddItemsClick = useCallback(() => { + onAddItemsClick(); +}, [onAddItemsClick]); + +return ( +
+
+

상품 목록

+
+
- setFilter(e.target.value)} - className="w-full p-2 mb-4 border border-gray-300 rounded text-black" - /> -
    -
  • 검색결과: {filteredItems.length.toLocaleString()}개
  • -
  • 전체가격: {totalPrice.toLocaleString()}원
  • -
  • 평균가격: {averagePrice.toLocaleString()}원
  • -
-
    - {filteredItems.map((item, index) => ( -
  • - {item.name} - {item.category} - {item.price.toLocaleString()}원 -
  • - ))} -
-
- ); - }; \ No newline at end of file +
+ setFilter(e.target.value)} + className="w-full p-2 mb-4 border border-gray-300 rounded text-black" + /> +
    +
  • 검색결과: {filteredItems.length.toLocaleString()}개
  • +
  • 전체가격: {totalPrice.toLocaleString()}원
  • +
  • 평균가격: {averagePrice.toLocaleString()}원
  • +
+
    + {filteredItems.map((item, index) => ( +
  • + {item.name} - {item.category} - {item.price.toLocaleString()}원 +
  • + ))} +
+
+); +}); \ No newline at end of file diff --git a/src/components/NotificationSystem.tsx b/src/components/NotificationSystem.tsx index fa2cf15..bda30cb 100644 --- a/src/components/NotificationSystem.tsx +++ b/src/components/NotificationSystem.tsx @@ -1,10 +1,11 @@ -import { useAppContext } from "../contexts/useAppContext"; +import { memo } from "../@lib"; +import { useNotification } from "../contexts/NotificationContext"; import { renderLog } from "../utils"; // NotificationSystem 컴포넌트 -export const NotificationSystem: React.FC = () => { +export const NotificationSystem: React.FC = memo(() => { renderLog("NotificationSystem rendered"); - const { notifications, removeNotification } = useAppContext(); + const { notifications, removeNotification } = useNotification(); return (
@@ -32,4 +33,4 @@ export const NotificationSystem: React.FC = () => { ))}
); - }; \ No newline at end of file + }); \ No newline at end of file diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..5dd6bb2 --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useState, ReactNode, useContext } from "react"; +import { useCallback, useMemo } from "../@lib"; +import { User } from "../types/type"; +import { useNotification } from "./NotificationContext"; + +interface AuthContextType { + user: User | null; + login: (email: string, password: string) => void; + logout: () => void; +} + +export const AuthContext = createContext(undefined); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuthContext must be used within an AuthProvider"); + } + return context; +}; + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [user, setUser] = useState(null); + const { addNotification } = useNotification(); + + const login = useCallback((email: string) => { + setUser({ id: 1, name: "홍길동", email }); + addNotification("성공적으로 로그인되었습니다", "success") + }, []); + const logout = useCallback(() => { + setUser(null); + addNotification("로그아웃되었습니다", "info"); + }, []); + + const value = useMemo(() => ({ user, login, logout }), [user, login, logout]); + + return ( + + {children} + + ); +}; diff --git a/src/contexts/NotificationContext.tsx b/src/contexts/NotificationContext.tsx index c5e68f4..61007a0 100644 --- a/src/contexts/NotificationContext.tsx +++ b/src/contexts/NotificationContext.tsx @@ -1,19 +1,26 @@ -import React, { createContext, useState, ReactNode } from "react"; -import { useCallback } from "../@lib"; +import React, { createContext, useState, ReactNode, useContext } from "react"; +import { useCallback, useMemo } from "../@lib"; +import { Notification } from "../types/type"; -interface Notification { - id: number; - message: string; -} interface NotificationContextType { notifications: Notification[]; - addNotification: (message: string) => void; + addNotification: (message: string, type: Notification["type"]) => void; removeNotification: (id: number) => void; } export const NotificationContext = createContext(undefined); +export const useNotification = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error( + "useNotification must be used within a NotificationProvider", + ); + } + return context; + }; + interface NotificationProviderProps { children: ReactNode; } @@ -21,16 +28,28 @@ interface NotificationProviderProps { export const NotificationProvider: React.FC = ({ children }) => { const [notifications, setNotifications] = useState([]); - const addNotification = useCallback((message: string) => { - setNotifications((prev) => [...prev, { id: Date.now(), message }]); + const addNotification = useCallback((message: string, type: Notification["type"]) => { + const newNotification: Notification = { + id: Date.now(), + message, + type, + }; + setNotifications((prev) => [...prev, newNotification]); }, []); const removeNotification = useCallback((id: number) => { - setNotifications((prev) => prev.filter((n) => n.id !== id)); + setNotifications((prev) => + prev.filter((notification) => notification.id !== id), + ); }, []); + const value = useMemo( + () => ({ notifications, addNotification, removeNotification }), + [notifications, addNotification, removeNotification], + ); + return ( - + {children} ); diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..0f19f6e --- /dev/null +++ b/src/contexts/ThemeContext.tsx @@ -0,0 +1,37 @@ +import React, { createContext, useState, ReactNode, useContext } from "react"; +import { useCallback, useMemo } from "../@lib"; + +interface ThemeContextType { + theme: string; + toggleTheme: () => void; +} + +export const ThemeContext = createContext(undefined); + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error("useThemeContext must be used within an ThemeProvider"); + } + return context; +}; + +interface ThemeProviderProps { + children: ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + const [theme, setTheme] = useState("light"); + + const toggleTheme = useCallback(() => { + setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); + }, []); + + const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]); + + return ( + + {children} + + ); +}; diff --git a/src/contexts/useAppContext.tsx b/src/contexts/useAppContext.tsx new file mode 100644 index 0000000..1085fae --- /dev/null +++ b/src/contexts/useAppContext.tsx @@ -0,0 +1,25 @@ +import { createContext, useContext } from "react"; +import { Notification, User } from "../types/type"; + +// AppContext 타입 정의 +export interface AppContextType { + theme: string; + toggleTheme: () => void; + user: User | null; + login: (email: string, password: string) => void; + logout: () => void; + notifications: Notification[]; + addNotification: (message: string, type: Notification["type"]) => void; + removeNotification: (id: number) => void; +} + +export const AppContext = createContext(undefined); + +// 커스텀 훅: useAppContext +export const useAppContext = () => { + const context = useContext(AppContext); + if (context === undefined) { + throw new Error("useAppContext must be used within an AppProvider"); + } + return context; +}; \ No newline at end of file From bc4d6faa48d0a2dc57d24a1baa1c1d3271da5a72 Mon Sep 17 00:00:00 2001 From: KimBoYoung Date: Thu, 2 Jan 2025 22:59:45 +0900 Subject: [PATCH 9/9] =?UTF-8?q?perf:=20generateItems=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 0cf69cf..c2a7911 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { generateItems } from "./utils"; // 메인 App 컴포넌트 const AppContent: React.FC = () => { const { theme } = useTheme(); - const [items, setItems] = useState(generateItems(1000)); + const [items, setItems] = useState(() => generateItems(1000)); const addItems = () => { setItems((prevItems) => [