From faf1325755d3e66122aa3e8e52c60e99fb815001 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 19:44:52 +0900 Subject: [PATCH 01/29] =?UTF-8?q?feat:=20shallowEquals=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/equalities/shallowEquals.ts | 32 +++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/@lib/equalities/shallowEquals.ts b/src/@lib/equalities/shallowEquals.ts index 56bf666..ede993b 100644 --- a/src/@lib/equalities/shallowEquals.ts +++ b/src/@lib/equalities/shallowEquals.ts @@ -1,3 +1,33 @@ export function shallowEquals(objA: T, objB: T): boolean { - return objA === objB; + // 원시타입의 값이 동일하거나 기존 객체의 주소값이 바뀌지 않은 경우 + if (objA === objB) { + return true; + } + + // null인 경우 + if (objA === null || objB === null) { + return objA === objB; + } + + // 나머지 원시값들 처리 + if (typeof objA !== "object" || typeof objB !== "object") { + return objA === objB; + } + + const objAKeys = Object.keys(objA); + const objBKeys = Object.keys(objB); + + if (objAKeys.length !== objBKeys.length) { + return false; + } + + for (const key of objAKeys) { + const valueA = Reflect.get(objA, key); + const valueB = Reflect.get(objB, key); + if (valueA !== valueB) { + return false; + } + } + + return true; } From c5d403e80cad0492e71b9827d835ed3c3501b017 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 19:45:01 +0900 Subject: [PATCH 02/29] =?UTF-8?q?feat:=20deepEquals=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/equalities/deepEquals.ts | 32 ++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/@lib/equalities/deepEquals.ts b/src/@lib/equalities/deepEquals.ts index af583d1..9618676 100644 --- a/src/@lib/equalities/deepEquals.ts +++ b/src/@lib/equalities/deepEquals.ts @@ -1,3 +1,33 @@ export function deepEquals(objA: T, objB: T): boolean { - return objA === objB; + // 원시타입의 값이 동일하거나 기존 객체의 주소값이 바뀌지 않은 경우 + if (objA === objB) { + return true; + } + + // null인 경우 + if (objA === null || objB === null) { + return objA === objB; + } + + // 나머지 원시값들 처리 + if (typeof objA !== "object" || typeof objB !== "object") { + return objA === objB; + } + + const objAKeys = Object.keys(objA); + const objBKeys = Object.keys(objB); + + if (objAKeys.length !== objBKeys.length) { + return false; + } + + for (const key of objAKeys) { + const valueA = Reflect.get(objA, key); + const valueB = Reflect.get(objB, key); + if (!deepEquals(valueA, valueB)) { + return false; + } + } + + return true; } From 02baec7e24e347d0053192c9817d045ec6520b26 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 21:30:04 +0900 Subject: [PATCH 03/29] =?UTF-8?q?feat:=20useRef=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/hooks/useRef.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/@lib/hooks/useRef.ts b/src/@lib/hooks/useRef.ts index 2dc9e83..e8db078 100644 --- a/src/@lib/hooks/useRef.ts +++ b/src/@lib/hooks/useRef.ts @@ -1,4 +1,6 @@ +import { useState } from "react"; + export function useRef(initialValue: T): { current: T } { - // React의 useState를 이용해서 만들어보세요. - return { current: initialValue }; + const [ref] = useState(() => ({ current: initialValue })); + return ref; } From e8a248fd61c4d6f712b9c3507ceb5748626326be Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 21:30:28 +0900 Subject: [PATCH 04/29] =?UTF-8?q?feat:=20useCallback=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/hooks/useCallback.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/@lib/hooks/useCallback.ts b/src/@lib/hooks/useCallback.ts index e71e647..495311c 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"; +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ +import { DependencyList, useMemo } from "react"; export function useCallback( factory: T, _deps: DependencyList, ) { - // 직접 작성한 useMemo를 통해서 만들어보세요. - return factory as T; + // eslint-disable-next-line react-hooks/exhaustive-deps + const memoizedCallback = useMemo(() => factory, _deps); + return memoizedCallback; } From 75c781b8b28e8d03615ef823a5b6d9f28980fbdb Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 21:30:52 +0900 Subject: [PATCH 05/29] =?UTF-8?q?feat:=20useMemo=20&=20useDeepMemo=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/hooks/useDeepMemo.ts | 1 - src/@lib/hooks/useMemo.ts | 13 ++++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) 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); } diff --git a/src/@lib/hooks/useMemo.ts b/src/@lib/hooks/useMemo.ts index 95930d6..6206e53 100644 --- a/src/@lib/hooks/useMemo.ts +++ b/src/@lib/hooks/useMemo.ts @@ -1,12 +1,19 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { DependencyList } from "react"; import { shallowEquals } from "../equalities"; +import { useRef } from "./useRef"; export function useMemo( factory: () => T, _deps: DependencyList, _equals = shallowEquals, ): T { - // 직접 작성한 useRef를 통해서 만들어보세요. - return factory(); + const valueRef = useRef(null); + const depsRef = useRef(_deps); + + if (valueRef.current === null || !_equals(depsRef.current, _deps)) { + valueRef.current = factory(); + depsRef.current = _deps; + } + + return valueRef.current; } From 4761392f2f262a284c090f7482b41d9c8179b4fc Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 21:31:16 +0900 Subject: [PATCH 06/29] =?UTF-8?q?feat:=20momo=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/hocs/memo.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/@lib/hocs/memo.ts b/src/@lib/hocs/memo.ts index d43559d..34dbd69 100644 --- a/src/@lib/hocs/memo.ts +++ b/src/@lib/hocs/memo.ts @@ -1,10 +1,17 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import { shallowEquals } from "../equalities"; -import { ComponentType } from "react"; +import React, { ComponentType, ReactNode } from "react"; export function memo

( Component: ComponentType

, _equals = shallowEquals, ) { - return Component; + let memoizedComponent: ReactNode | null = null; + let memoizedProps: P | null = null; + return (props: P) => { + if (!_equals(memoizedProps, props)) { + memoizedProps = props; + memoizedComponent = React.createElement(Component, props); + } + return memoizedComponent; + }; } From 6f3cf348b14c6ba8a086aeecbec2afb741b1c997 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 21:32:46 +0900 Subject: [PATCH 07/29] =?UTF-8?q?feat:=20ThemeContext=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/contexts/ThemeContext.tsx | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/contexts/ThemeContext.tsx diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..c45da4b --- /dev/null +++ b/src/contexts/ThemeContext.tsx @@ -0,0 +1,42 @@ +import { createContext, PropsWithChildren, useContext, useState } from "react"; +import { useCallback, useMemo } from "../@lib"; + +interface ThemeContextType { + theme: string; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +const ThemeProvider = ({ children }: PropsWithChildren) => { + const [theme, setTheme] = useState("light"); + + const toggleTheme = useCallback(() => { + setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); + }, []); + + const contextValue = useMemo( + () => ({ + theme, + toggleTheme, + }), + [theme, toggleTheme], + ); + + return ( + + {children} + + ); +}; + +const useThemeContext = () => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error("useThemeContext must be used within an ThemeProvider"); + } + return context; +}; + +const ThemeConsumer = ThemeContext.Consumer; +export { ThemeConsumer, ThemeProvider, useThemeContext }; From 2a341a5b60a3bd2ac4575dc185c71ec226832b57 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 21:33:01 +0900 Subject: [PATCH 08/29] =?UTF-8?q?feat:=20NotificationContext=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/contexts/NotificationContext.tsx | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/contexts/NotificationContext.tsx diff --git a/src/contexts/NotificationContext.tsx b/src/contexts/NotificationContext.tsx new file mode 100644 index 0000000..6028f36 --- /dev/null +++ b/src/contexts/NotificationContext.tsx @@ -0,0 +1,67 @@ +import { createContext, PropsWithChildren, useContext, useState } from "react"; +import { useCallback, useMemo } from "../@lib"; + +interface Notification { + id: number; + message: string; + type: "info" | "success" | "warning" | "error"; +} + +interface NotificationContextType { + notifications: Notification[]; + addNotification: (message: string, type: Notification["type"]) => void; + removeNotification: (id: number) => void; +} + +const NotificationContext = createContext( + undefined, +); + +const NotificationProvider = ({ children }: PropsWithChildren) => { + const [notifications, setNotifications] = useState([]); + + 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((notification) => notification.id !== id), + ); + }, []); + + const contextValue = useMemo( + () => ({ + notifications, + addNotification, + removeNotification, + }), + [notifications, addNotification, removeNotification], + ); + + return ( + + {children} + + ); +}; + +const useNotificationContext = () => { + const context = useContext(NotificationContext); + if (context === undefined) { + throw new Error( + "useNotificationContext must be used within an NotificationProvider", + ); + } + return context; +}; + +export { NotificationProvider, useNotificationContext }; From fdf79bd006c34e0284693b6f220953c8c72b2981 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 21:33:15 +0900 Subject: [PATCH 09/29] =?UTF-8?q?feat:=20UserContext=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/contexts/UserContext.tsx | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/contexts/UserContext.tsx diff --git a/src/contexts/UserContext.tsx b/src/contexts/UserContext.tsx new file mode 100644 index 0000000..4242fdc --- /dev/null +++ b/src/contexts/UserContext.tsx @@ -0,0 +1,58 @@ +import { createContext, PropsWithChildren, useContext, useState } from "react"; +import { useNotificationContext } from "./NotificationContext"; +import { useCallback, useMemo } from "../@lib"; + +interface User { + id: number; + name: string; + email: string; +} + +interface UserContextType { + user: User | null; + login: (email: string, password: string) => void; + logout: () => void; +} + +const UserContext = createContext(undefined); + +const UserProvider = ({ children }: PropsWithChildren) => { + const [user, setUser] = useState(null); + const { addNotification } = useNotificationContext(); + + const login = useCallback( + (email: string) => { + setUser({ id: 1, name: "홍길동", email }); + addNotification("성공적으로 로그인되었습니다", "success"); + }, + [addNotification], + ); + + const logout = useCallback(() => { + setUser(null); + addNotification("로그아웃되었습니다", "info"); + }, [addNotification]); + + const contextValue = useMemo( + () => ({ + user, + login, + logout, + }), + [user, login, logout], + ); + + return ( + {children} + ); +}; + +const useUserContext = () => { + const context = useContext(UserContext); + if (context === undefined) { + throw new Error("useUserContext must be used within an UserProvider"); + } + return context; +}; + +export { UserProvider, useUserContext }; From e0d074095fa637e97d35ef87a4c46ff1c2cad12e Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 21:33:48 +0900 Subject: [PATCH 10/29] =?UTF-8?q?feat:=20context=20App=20=EC=97=90=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 160 +++++++++++++++++----------------------------------- 1 file changed, 51 insertions(+), 109 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index debd645..9762fa0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,16 @@ -import React, { useState, createContext, useContext } from "react"; +import React, { useState } from "react"; import { generateItems, renderLog } from "./utils"; +import { + ThemeConsumer, + ThemeProvider, + useThemeContext, +} from "./contexts/ThemeContext"; +import { UserProvider, useUserContext } from "./contexts/UserContext"; +import { + NotificationProvider, + useNotificationContext, +} from "./contexts/NotificationContext"; +import { memo } from "./@lib"; // 타입 정의 interface Item { @@ -9,45 +20,11 @@ interface Item { 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 = () => { +export const Header: React.FC = memo(() => { renderLog("Header rendered"); - const { theme, toggleTheme, user, login, logout } = useAppContext(); + const { theme, toggleTheme } = useThemeContext(); + const { user, login, logout } = useUserContext(); const handleLogin = () => { // 실제 애플리케이션에서는 사용자 입력을 받아야 합니다. @@ -87,16 +64,16 @@ export const Header: React.FC = () => { ); -}; +}); // ItemList 컴포넌트 export const ItemList: React.FC<{ items: Item[]; onAddItemsClick: () => void; -}> = ({ items, onAddItemsClick }) => { +}> = memo(({ items, onAddItemsClick }) => { renderLog("ItemList rendered"); const [filter, setFilter] = useState(""); - const { theme } = useAppContext(); + const { theme } = useThemeContext(); const filteredItems = items.filter( (item) => @@ -146,12 +123,12 @@ export const ItemList: React.FC<{ ); -}; +}); // ComplexForm 컴포넌트 -export const ComplexForm: React.FC = () => { +export const ComplexForm: React.FC = memo(() => { renderLog("ComplexForm rendered"); - const { addNotification } = useAppContext(); + const { addNotification } = useNotificationContext(); const [formData, setFormData] = useState({ name: "", email: "", @@ -231,12 +208,12 @@ export const ComplexForm: React.FC = () => { ); -}; +}); // NotificationSystem 컴포넌트 -export const NotificationSystem: React.FC = () => { +export const NotificationSystem: React.FC = memo(() => { renderLog("NotificationSystem rendered"); - const { notifications, removeNotification } = useAppContext(); + const { notifications, removeNotification } = useNotificationContext(); return (

@@ -264,18 +241,11 @@ export const NotificationSystem: React.FC = () => { ))}
); -}; +}); // 메인 App 컴포넌트 const App: React.FC = () => { - const [theme, setTheme] = useState("light"); 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) => [ @@ -284,61 +254,33 @@ 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 ( - -
-
-
-
-
- -
-
- -
-
-
- -
-
+ + + + + {(context) => ( +
+
+
+
+
+ +
+
+ +
+
+
+ +
+ )} +
+
+
+
); }; From c17538f66a098faa49030fbcc82e3e9dd11163fe Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Sun, 29 Dec 2024 22:04:32 +0900 Subject: [PATCH 11/29] =?UTF-8?q?refactor:=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=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 | 253 ++------------------------ src/components/ComplexForm.tsx | 88 +++++++++ src/components/Header.tsx | 49 +++++ src/components/ItemList.tsx | 71 ++++++++ src/components/NotificationSystem.tsx | 35 ++++ src/components/index.ts | 4 + src/contexts/index.ts | 3 + 7 files changed, 261 insertions(+), 242 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/components/index.ts create mode 100644 src/contexts/index.ts diff --git a/src/App.tsx b/src/App.tsx index 9762fa0..50b371e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,250 +1,19 @@ -import React, { useState } from "react"; -import { generateItems, renderLog } from "./utils"; +import { useState } from "react"; +import { generateItems } from "./utils"; import { + NotificationProvider, ThemeConsumer, ThemeProvider, - useThemeContext, -} from "./contexts/ThemeContext"; -import { UserProvider, useUserContext } from "./contexts/UserContext"; + UserProvider, +} from "./contexts"; import { - NotificationProvider, - useNotificationContext, -} from "./contexts/NotificationContext"; -import { memo } from "./@lib"; - -// 타입 정의 -interface Item { - id: number; - name: string; - category: string; - price: number; -} - -// Header 컴포넌트 -export const Header: React.FC = memo(() => { - renderLog("Header rendered"); - const { theme, toggleTheme } = useThemeContext(); - const { user, login, logout } = useUserContext(); - - const handleLogin = () => { - // 실제 애플리케이션에서는 사용자 입력을 받아야 합니다. - login("user@example.com", "password"); - }; - - return ( -
-
-

샘플 애플리케이션

-
- - {user ? ( -
- {user.name}님 환영합니다! - -
- ) : ( - - )} -
-
-
- ); -}); - -// ItemList 컴포넌트 -export const ItemList: React.FC<{ - items: Item[]; - onAddItemsClick: () => void; -}> = memo(({ items, onAddItemsClick }) => { - renderLog("ItemList rendered"); - const [filter, setFilter] = useState(""); - const { theme } = useThemeContext(); - - 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 = memo(() => { - renderLog("ComplexForm rendered"); - const { addNotification } = useNotificationContext(); - 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 = memo(() => { - renderLog("NotificationSystem rendered"); - const { notifications, removeNotification } = useNotificationContext(); - - return ( -
- {notifications.map((notification) => ( -
- {notification.message} - -
- ))} -
- ); -}); + ComplexForm, + Header, + ItemList, + NotificationSystem, +} from "./components"; -// 메인 App 컴포넌트 -const App: React.FC = () => { +const App = () => { const [items, setItems] = useState(generateItems(1000)); const addItems = () => { diff --git a/src/components/ComplexForm.tsx b/src/components/ComplexForm.tsx new file mode 100644 index 0000000..b90532e --- /dev/null +++ b/src/components/ComplexForm.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { memo } from "../@lib"; +import { useNotificationContext } from "../contexts/NotificationContext"; +import { renderLog } from "../utils"; + +export const ComplexForm: React.FC = memo(() => { + renderLog("ComplexForm rendered"); + const { addNotification } = useNotificationContext(); + 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) => ( + + ))} +
+ +
+
+ ); +}); diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..5bf009f --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,49 @@ +import { memo } from "../@lib"; +import { useThemeContext } from "../contexts/ThemeContext"; +import { useUserContext } from "../contexts/UserContext"; +import { renderLog } from "../utils"; + +export const Header = memo(() => { + renderLog("Header rendered"); + const { theme, toggleTheme } = useThemeContext(); + const { user, login, logout } = useUserContext(); + + const handleLogin = () => { + // 실제 애플리케이션에서는 사용자 입력을 받아야 합니다. + login("user@example.com", "password"); + }; + + return ( +
+
+

샘플 애플리케이션

+
+ + {user ? ( +
+ {user.name}님 환영합니다! + +
+ ) : ( + + )} +
+
+
+ ); +}); diff --git a/src/components/ItemList.tsx b/src/components/ItemList.tsx new file mode 100644 index 0000000..8104cda --- /dev/null +++ b/src/components/ItemList.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { memo } from "../@lib"; +import { renderLog } from "../utils"; +import { useThemeContext } from "../contexts/ThemeContext"; + +interface Item { + id: number; + name: string; + category: string; + price: number; +} + +interface ItemListProps { + items: Item[]; + onAddItemsClick: () => void; +} + +export const ItemList = memo(({ items, onAddItemsClick }: ItemListProps) => { + renderLog("ItemList rendered"); + const [filter, setFilter] = useState(""); + const { theme } = useThemeContext(); + + 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()}원 +
  • + ))} +
+
+ ); +}); diff --git a/src/components/NotificationSystem.tsx b/src/components/NotificationSystem.tsx new file mode 100644 index 0000000..22d5e85 --- /dev/null +++ b/src/components/NotificationSystem.tsx @@ -0,0 +1,35 @@ +import { memo } from "../@lib"; +import { useNotificationContext } from "../contexts/NotificationContext"; +import { renderLog } from "../utils"; + +export const NotificationSystem = memo(() => { + renderLog("NotificationSystem rendered"); + const { notifications, removeNotification } = useNotificationContext(); + + return ( +
+ {notifications.map((notification) => ( +
+ {notification.message} + +
+ ))} +
+ ); +}); diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..268c788 --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,4 @@ +export * from "./ComplexForm"; +export * from "./Header"; +export * from "./ItemList"; +export * from "./NotificationSystem"; diff --git a/src/contexts/index.ts b/src/contexts/index.ts new file mode 100644 index 0000000..484c384 --- /dev/null +++ b/src/contexts/index.ts @@ -0,0 +1,3 @@ +export * from "./NotificationContext"; +export * from "./ThemeContext"; +export * from "./UserContext"; From 7bc7ebf1ed1c07eb3d080ba90aa35f61e8d8c4d1 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Mon, 30 Dec 2024 22:25:23 +0900 Subject: [PATCH 12/29] =?UTF-8?q?fix:=20husky=20tsc=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 9cb22ed..e20b8e3 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ - "tsc --noEmit", + "bash -c 'tsc --noEmit'", "prettier --write", "eslint --fix" ] @@ -39,10 +39,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", From 048e25edce14bed5cdf4869d86991114f8e27103 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Mon, 30 Dec 2024 22:30:47 +0900 Subject: [PATCH 13/29] =?UTF-8?q?refactor:=20=EA=B3=BC=EC=A0=9C=20?= =?UTF-8?q?=EC=B2=AB=EB=B2=88=EC=A7=B8=20=EA=B2=B0=EA=B3=BC=20app=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 345 ------------------ src/__tests__/advanced.test.tsx | 2 +- src/app/App.tsx | 56 +++ src/app/components/ComplexForm.tsx | 88 +++++ src/app/components/Header.tsx | 49 +++ src/app/components/ItemList.tsx | 71 ++++ src/app/components/NotificationSystem.tsx | 35 ++ src/app/components/index.ts | 4 + .../contexts/NotificationContext.tsx | 13 +- src/{ => app}/contexts/ThemeContext.tsx | 13 +- src/{ => app}/contexts/UserContext.tsx | 13 +- src/app/contexts/index.ts | 3 + src/main.tsx | 2 +- 13 files changed, 335 insertions(+), 359 deletions(-) delete mode 100644 src/App.tsx create mode 100644 src/app/App.tsx create mode 100644 src/app/components/ComplexForm.tsx create mode 100644 src/app/components/Header.tsx create mode 100644 src/app/components/ItemList.tsx create mode 100644 src/app/components/NotificationSystem.tsx create mode 100644 src/app/components/index.ts rename src/{ => app}/contexts/NotificationContext.tsx (83%) rename src/{ => app}/contexts/ThemeContext.tsx (77%) rename src/{ => app}/contexts/UserContext.tsx (83%) create mode 100644 src/app/contexts/index.ts diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index debd645..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,345 +0,0 @@ -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} - -
- ))} -
- ); -}; - -// 메인 App 컴포넌트 -const App: React.FC = () => { - const [theme, setTheme] = useState("light"); - 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) => [ - ...prevItems, - ...generateItems(1000, prevItems.length), - ]); - }; - - 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 ( - -
-
-
-
-
- -
-
- -
-
-
- -
-
- ); -}; - -export default App; diff --git a/src/__tests__/advanced.test.tsx b/src/__tests__/advanced.test.tsx index a8e027d..661c646 100644 --- a/src/__tests__/advanced.test.tsx +++ b/src/__tests__/advanced.test.tsx @@ -1,8 +1,8 @@ import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import App from "../App"; import * as utils from "../utils"; +import App from "../app/App"; const renderLogMock = vi.spyOn(utils, "renderLog"); const generateItemsSpy = vi.spyOn(utils, "generateItems"); diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..56fb790 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { + NotificationProvider, + ThemeConsumer, + ThemeProvider, + UserProvider, +} from "./contexts"; +import { + ComplexForm, + Header, + ItemList, + NotificationSystem, +} from "./components"; +import { generateItems } from "../utils"; + +const App = () => { + const [items, setItems] = useState(generateItems(1000)); + + const addItems = () => { + setItems((prevItems) => [ + ...prevItems, + ...generateItems(1000, prevItems.length), + ]); + }; + + return ( + + + + + {(context) => ( +
+
+
+
+
+ +
+
+ +
+
+
+ +
+ )} +
+
+
+
+ ); +}; + +export default App; diff --git a/src/app/components/ComplexForm.tsx b/src/app/components/ComplexForm.tsx new file mode 100644 index 0000000..bd7ee0e --- /dev/null +++ b/src/app/components/ComplexForm.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { renderLog } from "../../utils"; +import { memo } from "../../@lib"; +import { useNotificationContext } from "../contexts"; + +export const ComplexForm: React.FC = memo(() => { + renderLog("ComplexForm rendered"); + const { addNotification } = useNotificationContext(); + 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) => ( + + ))} +
+ +
+
+ ); +}); diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx new file mode 100644 index 0000000..a0ac339 --- /dev/null +++ b/src/app/components/Header.tsx @@ -0,0 +1,49 @@ +import { useThemeContext } from "../contexts/ThemeContext"; +import { useUserContext } from "../contexts/UserContext"; +import { renderLog } from "../../utils"; +import { memo } from "../../@lib"; + +export const Header = memo(() => { + renderLog("Header rendered"); + const { theme, toggleTheme } = useThemeContext(); + const { user, login, logout } = useUserContext(); + + const handleLogin = () => { + // 실제 애플리케이션에서는 사용자 입력을 받아야 합니다. + login("user@example.com", "password"); + }; + + return ( +
+
+

샘플 애플리케이션

+
+ + {user ? ( +
+ {user.name}님 환영합니다! + +
+ ) : ( + + )} +
+
+
+ ); +}); diff --git a/src/app/components/ItemList.tsx b/src/app/components/ItemList.tsx new file mode 100644 index 0000000..9608f1d --- /dev/null +++ b/src/app/components/ItemList.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { useThemeContext } from "../contexts/ThemeContext"; +import { renderLog } from "../../utils"; +import { memo } from "../../@lib"; + +interface Item { + id: number; + name: string; + category: string; + price: number; +} + +interface ItemListProps { + items: Item[]; + onAddItemsClick: () => void; +} + +export const ItemList = memo(({ items, onAddItemsClick }: ItemListProps) => { + renderLog("ItemList rendered"); + const [filter, setFilter] = useState(""); + const { theme } = useThemeContext(); + + 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()}원 +
  • + ))} +
+
+ ); +}); diff --git a/src/app/components/NotificationSystem.tsx b/src/app/components/NotificationSystem.tsx new file mode 100644 index 0000000..2e1dd84 --- /dev/null +++ b/src/app/components/NotificationSystem.tsx @@ -0,0 +1,35 @@ +import { memo } from "../../@lib"; +import { renderLog } from "../../utils"; +import { useNotificationContext } from "../contexts/NotificationContext"; + +export const NotificationSystem = memo(() => { + renderLog("NotificationSystem rendered"); + const { notifications, removeNotification } = useNotificationContext(); + + return ( +
+ {notifications.map((notification) => ( +
+ {notification.message} + +
+ ))} +
+ ); +}); diff --git a/src/app/components/index.ts b/src/app/components/index.ts new file mode 100644 index 0000000..268c788 --- /dev/null +++ b/src/app/components/index.ts @@ -0,0 +1,4 @@ +export * from "./ComplexForm"; +export * from "./Header"; +export * from "./ItemList"; +export * from "./NotificationSystem"; diff --git a/src/contexts/NotificationContext.tsx b/src/app/contexts/NotificationContext.tsx similarity index 83% rename from src/contexts/NotificationContext.tsx rename to src/app/contexts/NotificationContext.tsx index 6028f36..0baeba3 100644 --- a/src/contexts/NotificationContext.tsx +++ b/src/app/contexts/NotificationContext.tsx @@ -1,5 +1,5 @@ import { createContext, PropsWithChildren, useContext, useState } from "react"; -import { useCallback, useMemo } from "../@lib"; +import { useCallback, useMemo } from "../../@lib"; interface Notification { id: number; @@ -7,13 +7,18 @@ interface Notification { type: "info" | "success" | "warning" | "error"; } -interface NotificationContextType { +interface NotificationState { notifications: Notification[]; +} + +interface NotificationActions { addNotification: (message: string, type: Notification["type"]) => void; removeNotification: (id: number) => void; } -const NotificationContext = createContext( +type NotificationType = NotificationState & NotificationActions; + +const NotificationContext = createContext( undefined, ); @@ -38,7 +43,7 @@ const NotificationProvider = ({ children }: PropsWithChildren) => { ); }, []); - const contextValue = useMemo( + const contextValue = useMemo( () => ({ notifications, addNotification, diff --git a/src/contexts/ThemeContext.tsx b/src/app/contexts/ThemeContext.tsx similarity index 77% rename from src/contexts/ThemeContext.tsx rename to src/app/contexts/ThemeContext.tsx index c45da4b..e56273c 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/app/contexts/ThemeContext.tsx @@ -1,12 +1,17 @@ import { createContext, PropsWithChildren, useContext, useState } from "react"; -import { useCallback, useMemo } from "../@lib"; +import { useCallback, useMemo } from "../../@lib"; -interface ThemeContextType { +interface ThemeState { theme: string; +} + +interface ThemeActions { toggleTheme: () => void; } -const ThemeContext = createContext(undefined); +type ThemeType = ThemeState & ThemeActions; + +const ThemeContext = createContext(undefined); const ThemeProvider = ({ children }: PropsWithChildren) => { const [theme, setTheme] = useState("light"); @@ -15,7 +20,7 @@ const ThemeProvider = ({ children }: PropsWithChildren) => { setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); }, []); - const contextValue = useMemo( + const contextValue = useMemo( () => ({ theme, toggleTheme, diff --git a/src/contexts/UserContext.tsx b/src/app/contexts/UserContext.tsx similarity index 83% rename from src/contexts/UserContext.tsx rename to src/app/contexts/UserContext.tsx index 4242fdc..06a885b 100644 --- a/src/contexts/UserContext.tsx +++ b/src/app/contexts/UserContext.tsx @@ -1,6 +1,6 @@ import { createContext, PropsWithChildren, useContext, useState } from "react"; import { useNotificationContext } from "./NotificationContext"; -import { useCallback, useMemo } from "../@lib"; +import { useCallback, useMemo } from "../../@lib"; interface User { id: number; @@ -8,13 +8,18 @@ interface User { email: string; } -interface UserContextType { +interface UserState { user: User | null; +} + +interface UserActions { login: (email: string, password: string) => void; logout: () => void; } -const UserContext = createContext(undefined); +type UserType = UserState & UserActions; + +const UserContext = createContext(undefined); const UserProvider = ({ children }: PropsWithChildren) => { const [user, setUser] = useState(null); @@ -33,7 +38,7 @@ const UserProvider = ({ children }: PropsWithChildren) => { addNotification("로그아웃되었습니다", "info"); }, [addNotification]); - const contextValue = useMemo( + const contextValue = useMemo( () => ({ user, login, diff --git a/src/app/contexts/index.ts b/src/app/contexts/index.ts new file mode 100644 index 0000000..484c384 --- /dev/null +++ b/src/app/contexts/index.ts @@ -0,0 +1,3 @@ +export * from "./NotificationContext"; +export * from "./ThemeContext"; +export * from "./UserContext"; diff --git a/src/main.tsx b/src/main.tsx index f8fc6f5..8395abf 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import App from "./App"; +import App from "./app/App"; createRoot(document.getElementById("root")!).render( From 9af873a320295bddc1821407b36622fdad7a8390 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Mon, 30 Dec 2024 22:32:03 +0900 Subject: [PATCH 14/29] =?UTF-8?q?fix:=20useCallback=20=EB=A6=AC=EC=95=A1?= =?UTF-8?q?=ED=8A=B8=EC=9D=98=20useMemo=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EB=8D=98=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/hooks/useCallback.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/@lib/hooks/useCallback.ts b/src/@lib/hooks/useCallback.ts index 495311c..dc3395e 100644 --- a/src/@lib/hooks/useCallback.ts +++ b/src/@lib/hooks/useCallback.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-function-type */ -import { DependencyList, useMemo } from "react"; +import { DependencyList } from "react"; +import { useMemo } from "./useMemo"; export function useCallback( factory: T, From a5af0f0796c6818fb269646c857389afa23559f4 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Mon, 30 Dec 2024 22:32:33 +0900 Subject: [PATCH 15/29] =?UTF-8?q?feat:=20useStore=20&=20createStore=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/@lib/hooks/index.ts | 1 + src/@lib/hooks/useStore.ts | 16 +++++++++++++++ src/storeUtils.ts | 40 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 src/@lib/hooks/useStore.ts create mode 100644 src/storeUtils.ts diff --git a/src/@lib/hooks/index.ts b/src/@lib/hooks/index.ts index 220d6fe..9634fc6 100644 --- a/src/@lib/hooks/index.ts +++ b/src/@lib/hooks/index.ts @@ -2,3 +2,4 @@ export * from "./useDeepMemo"; export * from "./useMemo"; export * from "./useCallback"; export * from "./useRef"; +export * from "./useStore"; diff --git a/src/@lib/hooks/useStore.ts b/src/@lib/hooks/useStore.ts new file mode 100644 index 0000000..fe257be --- /dev/null +++ b/src/@lib/hooks/useStore.ts @@ -0,0 +1,16 @@ +import { useRef } from "./useRef"; +import { useSyncExternalStore } from "react"; +import { shallowEquals } from "../equalities"; +import { Store } from "../../storeUtils"; + +export const useStore = (store: Store, selector: (store: T) => S) => { + const prevRef = useRef(null); + + return useSyncExternalStore(store.subscribe, () => { + const next = selector(store.getState()); + if (prevRef.current === null || !shallowEquals(prevRef.current!, next)) { + prevRef.current = next; + } + return prevRef.current; + }); +}; diff --git a/src/storeUtils.ts b/src/storeUtils.ts new file mode 100644 index 0000000..81c6a92 --- /dev/null +++ b/src/storeUtils.ts @@ -0,0 +1,40 @@ +import { shallowEquals } from "./@lib"; + +type SetState = (newState: T | ((state: T) => T)) => void; +type GetState = () => T; +type CreateState = (set: SetState, get: GetState) => T; + +export type Store = ReturnType>; + +export const createStore = (createState: CreateState) => { + let state: T; + const listeners = new Set<() => void>(); + const getState: GetState = () => state; + + const setState: SetState = (newState) => { + const nextState = + typeof newState === "function" + ? (newState as (state: T) => T)(state) + : newState; + + if (!shallowEquals(nextState, state)) { + state = nextState; + listeners.forEach((listener) => listener()); + } + }; + + const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + state = createState(setState, getState); + + return { + subscribe, + getState, + setState, + }; +}; From d59adc01438508b8e27ec3ab287382c6a19332d2 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Mon, 30 Dec 2024 22:33:34 +0900 Subject: [PATCH 16/29] =?UTF-8?q?feat:=20=EC=99=B8=EB=B6=80=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EC=96=B4=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=9C=20con?= =?UTF-8?q?text=20api=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20app-plus=20=ED=8F=B4=EB=8D=94=EC=97=90=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/externalStore.test.tsx | 161 ++++++++++++++++++ src/app-plus/App.tsx | 25 +++ src/app-plus/components/ComplexForm.tsx | 91 ++++++++++ src/app-plus/components/Header.tsx | 62 +++++++ src/app-plus/components/ItemList.tsx | 76 +++++++++ src/app-plus/components/MainLayout.tsx | 13 ++ src/app-plus/components/MainSection.tsx | 29 ++++ .../components/NotificationSystem.tsx | 39 +++++ src/app-plus/components/index.ts | 6 + src/app-plus/contexts/NotificationContext.tsx | 77 +++++++++ src/app-plus/contexts/ThemeContext.tsx | 50 ++++++ src/app-plus/contexts/UserContext.tsx | 61 +++++++ src/app-plus/contexts/index.ts | 3 + 13 files changed, 693 insertions(+) create mode 100644 src/__tests__/externalStore.test.tsx create mode 100644 src/app-plus/App.tsx create mode 100644 src/app-plus/components/ComplexForm.tsx create mode 100644 src/app-plus/components/Header.tsx create mode 100644 src/app-plus/components/ItemList.tsx create mode 100644 src/app-plus/components/MainLayout.tsx create mode 100644 src/app-plus/components/MainSection.tsx create mode 100644 src/app-plus/components/NotificationSystem.tsx create mode 100644 src/app-plus/components/index.ts create mode 100644 src/app-plus/contexts/NotificationContext.tsx create mode 100644 src/app-plus/contexts/ThemeContext.tsx create mode 100644 src/app-plus/contexts/UserContext.tsx create mode 100644 src/app-plus/contexts/index.ts diff --git a/src/__tests__/externalStore.test.tsx b/src/__tests__/externalStore.test.tsx new file mode 100644 index 0000000..18ccd66 --- /dev/null +++ b/src/__tests__/externalStore.test.tsx @@ -0,0 +1,161 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as utils from "../utils"; +import App from "../app-plus/App"; + +const renderLogMock = vi.spyOn(utils, "renderLog"); +const generateItemsSpy = vi.spyOn(utils, "generateItems"); + +describe("최적화된 App 컴포넌트 테스트", () => { + beforeEach(() => { + renderLogMock.mockClear(); + generateItemsSpy.mockClear(); + }); + + afterEach(() => { + try { + screen.getAllByText("닫기").forEach((button) => fireEvent.click(button)); + } catch { + console.log("닫기 버튼 없음"); + } + }); + + it("초기 렌더링 시 모든 컴포넌트가 한 번씩 렌더링되어야 한다", () => { + render(); + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("ItemList rendered"); + expect(renderLogMock).toHaveBeenCalledWith("ComplexForm rendered"); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(4); + }); + + it("테마 변경 시 Header, ItemList만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + const themeButton = await screen.findByText(/다크 모드|라이트 모드/); + await fireEvent.click(themeButton); + + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("ItemList rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(2); + }); + + it("로그인/로그아웃 시 Header, ComplexForm, NotificationSystem만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + const loginButton = await screen.findByText("로그인"); + await fireEvent.click(loginButton); + + // Header가 변경 되면 알림이 발생하고, 알림 정보를 CompleteForm과 NotificationSystem이 가져다 사용 중 + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(2); + renderLogMock.mockClear(); + + const logoutButton = await screen.findByText("로그아웃"); + await fireEvent.click(logoutButton); + + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(2); + }); + + it("아이템 검색 시 ItemList만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + const searchInput = await screen.findByPlaceholderText("상품 검색..."); + await fireEvent.change(searchInput, { target: { value: "검색어" } }); + + expect(renderLogMock).toHaveBeenCalledWith("ItemList rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + }); + + it("폼 입력 시 ComplexForm만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + const nameInput = await screen.findByPlaceholderText("이름"); + await fireEvent.change(nameInput, { target: { value: "홍길동" } }); + + expect(renderLogMock).toHaveBeenCalledWith("ComplexForm rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + }); + + it("알림 추가 및 닫기시 ComplexForm, NotificationSystem만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + const submitButton = await screen.findByText("제출"); + fireEvent.click(submitButton); + + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + renderLogMock.mockClear(); + + // 알림 닫기 버튼 찾기 및 클릭 + const closeButton = await screen.findByText("닫기"); + await fireEvent.click(closeButton); + + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + }); + + it("여러 작업을 연속으로 수행해도 각 컴포넌트는 필요한 경우에만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + // 테마 변경 + const themeButton = await screen.findByText(/다크 모드|라이트 모드/); + await fireEvent.click(themeButton); + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("ItemList rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(2); + renderLogMock.mockClear(); + + // 로그인 + const loginButton = await screen.findByText("로그인"); + await fireEvent.click(loginButton); + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(2); + renderLogMock.mockClear(); + + // 알림 닫기 버튼 찾기 및 클릭 + await fireEvent.click(await screen.findByText("닫기")); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + renderLogMock.mockClear(); + + // 아이템 검색 + const searchInput = await screen.findByPlaceholderText("상품 검색..."); + await userEvent.type(searchInput, "검색어입력"); + expect(renderLogMock).toHaveBeenCalledWith("ItemList rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(5); + renderLogMock.mockClear(); + + // 폼 입력 + const nameInput = await screen.findByPlaceholderText("이름"); + await userEvent.type(nameInput, "홍길동"); + expect(renderLogMock).toHaveBeenCalledWith("ComplexForm rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(3); + renderLogMock.mockClear(); + + // 폼 제출 + const submitButton = await screen.findByText("제출"); + await fireEvent.click(submitButton); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + renderLogMock.mockClear(); + + // 알림 닫기 버튼 찾기 및 클릭 + await fireEvent.click(await screen.findByText("닫기")); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + + expect(generateItemsSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app-plus/App.tsx b/src/app-plus/App.tsx new file mode 100644 index 0000000..773762c --- /dev/null +++ b/src/app-plus/App.tsx @@ -0,0 +1,25 @@ +import { + Header, + MainLayout, + MainSection, + NotificationSystem, +} from "./components"; +import { NotificationProvider, ThemeProvider, UserProvider } from "./contexts"; + +const App = () => { + return ( + + + + +
+ + + + + + + ); +}; + +export default App; diff --git a/src/app-plus/components/ComplexForm.tsx b/src/app-plus/components/ComplexForm.tsx new file mode 100644 index 0000000..c4e9cb1 --- /dev/null +++ b/src/app-plus/components/ComplexForm.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { renderLog } from "../../utils"; +import { useNotificationStore } from "../contexts"; + +export const ComplexForm = () => { + renderLog("ComplexForm rendered"); + + const addNotification = useNotificationStore( + (state) => state.addNotification, + ); + + 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) => ( + + ))} +
+ +
+
+ ); +}; diff --git a/src/app-plus/components/Header.tsx b/src/app-plus/components/Header.tsx new file mode 100644 index 0000000..030e7c8 --- /dev/null +++ b/src/app-plus/components/Header.tsx @@ -0,0 +1,62 @@ +import { useNotificationStore, useThemeStore, useUserStore } from "../contexts"; +import { renderLog } from "../../utils"; + +export const Header = () => { + renderLog("Header rendered"); + + const theme = useThemeStore((state) => state.theme); + const toggleTheme = useThemeStore((state) => state.toggleTheme); + + const user = useUserStore((state) => state.user); + const login = useUserStore((state) => state.login); + const logout = useUserStore((state) => state.logout); + + const addNotification = useNotificationStore( + (state) => state.addNotification, + ); + + const handleLogin = () => { + // 실제 애플리케이션에서는 사용자 입력을 받아야 합니다. + login("user@example.com", "password"); + addNotification("성공적으로 로그인되었습니다", "success"); + }; + + const handleLogout = () => { + logout(); + addNotification("로그아웃되었습니다", "info"); + }; + + return ( +
+
+

샘플 애플리케이션

+
+ + {user ? ( +
+ {user.name}님 환영합니다! + +
+ ) : ( + + )} +
+
+
+ ); +}; diff --git a/src/app-plus/components/ItemList.tsx b/src/app-plus/components/ItemList.tsx new file mode 100644 index 0000000..6049163 --- /dev/null +++ b/src/app-plus/components/ItemList.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { renderLog } from "../../utils"; +import { useThemeStore } from "../contexts"; +import { useMemo } from "../../@lib"; + +interface Item { + id: number; + name: string; + category: string; + price: number; +} + +interface ItemListProps { + items: Item[]; + onAddItemsClick: () => void; +} + +export const ItemList = ({ items, onAddItemsClick }: ItemListProps) => { + renderLog("ItemList rendered"); + + const [filter, setFilter] = useState(""); + const theme = useThemeStore((state) => state.theme); + + const filteredItems = useMemo( + () => + items.filter( + (item) => + item.name.toLowerCase().includes(filter.toLowerCase()) || + item.category.toLowerCase().includes(filter.toLowerCase()), + ), + [items, filter], + ); + + 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()}원 +
  • + ))} +
+
+ ); +}; diff --git a/src/app-plus/components/MainLayout.tsx b/src/app-plus/components/MainLayout.tsx new file mode 100644 index 0000000..bb47f8e --- /dev/null +++ b/src/app-plus/components/MainLayout.tsx @@ -0,0 +1,13 @@ +import { PropsWithChildren } from "react"; +import { useThemeStore } from "../contexts"; + +export const MainLayout = ({ children }: PropsWithChildren) => { + const theme = useThemeStore((state) => state.theme); + return ( +
+ {children} +
+ ); +}; diff --git a/src/app-plus/components/MainSection.tsx b/src/app-plus/components/MainSection.tsx new file mode 100644 index 0000000..657806d --- /dev/null +++ b/src/app-plus/components/MainSection.tsx @@ -0,0 +1,29 @@ +import { useState } from "react"; +import { ItemList } from "./ItemList"; +import { generateItems } from "../../utils"; +import { useCallback } from "../../@lib"; +import { ComplexForm } from "./ComplexForm"; + +export const MainSection = () => { + const [items, setItems] = useState(() => generateItems(1000)); + + const addItems = useCallback(() => { + setItems((prevItems) => [ + ...prevItems, + ...generateItems(1000, prevItems.length), + ]); + }, []); + + return ( +
+
+
+ +
+
+ +
+
+
+ ); +}; diff --git a/src/app-plus/components/NotificationSystem.tsx b/src/app-plus/components/NotificationSystem.tsx new file mode 100644 index 0000000..0aba995 --- /dev/null +++ b/src/app-plus/components/NotificationSystem.tsx @@ -0,0 +1,39 @@ +import { useNotificationStore } from "../contexts"; +import { renderLog } from "../../utils"; + +export const NotificationSystem = () => { + const notifications = useNotificationStore((state) => state.notifications); + + const removeNotification = useNotificationStore( + (state) => state.removeNotification, + ); + + renderLog("NotificationSystem rendered"); + + return ( +
+ {notifications.map((notification) => ( +
+ {notification.message} + +
+ ))} +
+ ); +}; diff --git a/src/app-plus/components/index.ts b/src/app-plus/components/index.ts new file mode 100644 index 0000000..ae29d71 --- /dev/null +++ b/src/app-plus/components/index.ts @@ -0,0 +1,6 @@ +export * from "./ComplexForm"; +export * from "./Header"; +export * from "./ItemList"; +export * from "./MainLayout"; +export * from "./MainSection"; +export * from "./NotificationSystem"; diff --git a/src/app-plus/contexts/NotificationContext.tsx b/src/app-plus/contexts/NotificationContext.tsx new file mode 100644 index 0000000..658bf33 --- /dev/null +++ b/src/app-plus/contexts/NotificationContext.tsx @@ -0,0 +1,77 @@ +import { createContext, PropsWithChildren, useContext } from "react"; +import { useRef, useStore } from "../../@lib"; +import { createStore, Store } from "../../storeUtils"; + +interface Notification { + id: number; + message: string; + type: "info" | "success" | "warning" | "error"; +} + +interface NotificationState { + notifications: Notification[]; +} + +interface NotificationActions { + addNotification: (message: string, type: Notification["type"]) => void; + removeNotification: (id: number) => void; +} + +type NotificationType = NotificationState & NotificationActions; +type NotificationStore = Store; + +const notificationStore: NotificationStore = createStore( + (set) => ({ + notifications: [], + addNotification: (message, type) => { + const newNotification: Notification = { + id: Date.now(), + message, + type, + }; + + set((prev) => ({ + ...prev, + notifications: [...prev.notifications, newNotification], + })); + }, + removeNotification: (id) => { + set((prev) => ({ + ...prev, + notifications: prev.notifications.filter( + (notification) => notification.id !== id, + ), + })); + }, + }), +); + +const NotificationContext = createContext( + undefined, +); + +const NotificationProvider = ({ children }: PropsWithChildren) => { + const store = useRef(null); + + if (store.current === null) { + store.current = notificationStore; + } + + return ( + + {children} + + ); +}; + +const useNotificationStore = (selector: (store: NotificationType) => S) => { + const store = useContext(NotificationContext); + if (store === undefined) { + throw new Error( + "useNotificationContext must be used within an NotificationProvider", + ); + } + return useStore(store, selector); +}; + +export { NotificationProvider, useNotificationStore }; diff --git a/src/app-plus/contexts/ThemeContext.tsx b/src/app-plus/contexts/ThemeContext.tsx new file mode 100644 index 0000000..9f2e01c --- /dev/null +++ b/src/app-plus/contexts/ThemeContext.tsx @@ -0,0 +1,50 @@ +import { createContext, PropsWithChildren, useContext } from "react"; +import { useRef, useStore } from "../../@lib"; +import { createStore, Store } from "../../storeUtils"; + +interface ThemeState { + theme: string; +} + +interface ThemeActions { + toggleTheme: () => void; +} + +type ThemeType = ThemeState & ThemeActions; +type ThemeStore = Store; + +const themeStore: ThemeStore = createStore((set) => ({ + theme: "light", + toggleTheme: () => { + set((prev) => ({ + ...prev, + theme: prev.theme === "light" ? "dark" : "light", + })); + }, +})); + +const ThemeContext = createContext(undefined); + +const ThemeProvider = ({ children }: PropsWithChildren) => { + const store = useRef(null); + + if (store.current === null) { + store.current = themeStore; + } + + return ( + + {children} + + ); +}; + +const useThemeStore = (selector: (context: ThemeType) => S) => { + const store = useContext(ThemeContext); + if (store === undefined) { + throw new Error("useThemeContext must be used within an ThemeProvider"); + } + return useStore(store, selector); +}; + +export { ThemeProvider, useThemeStore }; diff --git a/src/app-plus/contexts/UserContext.tsx b/src/app-plus/contexts/UserContext.tsx new file mode 100644 index 0000000..f470fa4 --- /dev/null +++ b/src/app-plus/contexts/UserContext.tsx @@ -0,0 +1,61 @@ +import { createContext, PropsWithChildren, useContext } from "react"; +import { useRef, useStore } from "../../@lib"; +import { createStore, Store } from "../../storeUtils"; + +interface User { + id: number; + name: string; + email: string; +} + +interface UserState { + user: User | null; +} + +interface UserActions { + login: (email: string, password: string) => void; + logout: () => void; +} + +type UserType = UserState & UserActions; +type UserStore = Store; + +const userStore: UserStore = createStore((set) => ({ + user: null, + login: (email) => { + set((prev) => { + return { ...prev, user: { id: 1, name: "홍길동", email } }; + }); + }, + logout: () => { + set((prev) => { + return { ...prev, user: null }; + }); + }, +})); + +const UserContext = createContext(undefined); + +const UserProvider = ({ children }: PropsWithChildren) => { + const store = useRef(null); + + if (store.current === null) { + store.current = userStore; + } + + return ( + + {children} + + ); +}; + +const useUserStore = (selector: (context: UserType) => S) => { + const store = useContext(UserContext); + if (store === undefined) { + throw new Error("useUserContext must be used within an UserProvider"); + } + return useStore(store, selector); +}; + +export { UserProvider, useUserStore }; diff --git a/src/app-plus/contexts/index.ts b/src/app-plus/contexts/index.ts new file mode 100644 index 0000000..484c384 --- /dev/null +++ b/src/app-plus/contexts/index.ts @@ -0,0 +1,3 @@ +export * from "./NotificationContext"; +export * from "./ThemeContext"; +export * from "./UserContext"; From d7a7a49f24c43d8e5a16e878177215d1f9d8731d Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Mon, 30 Dec 2024 22:54:24 +0900 Subject: [PATCH 17/29] =?UTF-8?q?chore:=20external=20test=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e20b8e3..4610bd9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test": "vitest", "test:basic": "vitest basic", "test:advanced": "vitest advanced", + "test:external": "vitest external", "test:ui": "vitest --ui", "prepare": "husky" }, @@ -53,4 +54,4 @@ "vite": "^5.4.1", "vitest": "^2.1.2" } -} +} \ No newline at end of file From 2366a476032e9c7ec0f6cad1eee1c4d17875a6d7 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Mon, 30 Dec 2024 22:54:34 +0900 Subject: [PATCH 18/29] =?UTF-8?q?chore:=20external=20test=20ci=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c00a60..e22fe78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,3 +85,14 @@ jobs: run: | npm install npm run test:advanced + external: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - name: advanced-test + run: | + npm install + npm run test:external From 0b59347285a66ac13266b3123e398f634b49d4b7 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Mon, 30 Dec 2024 22:54:52 +0900 Subject: [PATCH 19/29] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8F=B4=EB=8D=94=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ComplexForm.tsx | 88 --------------------------- src/components/Header.tsx | 49 --------------- src/components/ItemList.tsx | 71 --------------------- src/components/NotificationSystem.tsx | 35 ----------- src/components/index.ts | 4 -- 5 files changed, 247 deletions(-) delete mode 100644 src/components/ComplexForm.tsx delete mode 100644 src/components/Header.tsx delete mode 100644 src/components/ItemList.tsx delete mode 100644 src/components/NotificationSystem.tsx delete mode 100644 src/components/index.ts diff --git a/src/components/ComplexForm.tsx b/src/components/ComplexForm.tsx deleted file mode 100644 index b90532e..0000000 --- a/src/components/ComplexForm.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useState } from "react"; -import { memo } from "../@lib"; -import { useNotificationContext } from "../contexts/NotificationContext"; -import { renderLog } from "../utils"; - -export const ComplexForm: React.FC = memo(() => { - renderLog("ComplexForm rendered"); - const { addNotification } = useNotificationContext(); - 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) => ( - - ))} -
- -
-
- ); -}); diff --git a/src/components/Header.tsx b/src/components/Header.tsx deleted file mode 100644 index 5bf009f..0000000 --- a/src/components/Header.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { memo } from "../@lib"; -import { useThemeContext } from "../contexts/ThemeContext"; -import { useUserContext } from "../contexts/UserContext"; -import { renderLog } from "../utils"; - -export const Header = memo(() => { - renderLog("Header rendered"); - const { theme, toggleTheme } = useThemeContext(); - const { user, login, logout } = useUserContext(); - - const handleLogin = () => { - // 실제 애플리케이션에서는 사용자 입력을 받아야 합니다. - login("user@example.com", "password"); - }; - - return ( -
-
-

샘플 애플리케이션

-
- - {user ? ( -
- {user.name}님 환영합니다! - -
- ) : ( - - )} -
-
-
- ); -}); diff --git a/src/components/ItemList.tsx b/src/components/ItemList.tsx deleted file mode 100644 index 8104cda..0000000 --- a/src/components/ItemList.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { useState } from "react"; -import { memo } from "../@lib"; -import { renderLog } from "../utils"; -import { useThemeContext } from "../contexts/ThemeContext"; - -interface Item { - id: number; - name: string; - category: string; - price: number; -} - -interface ItemListProps { - items: Item[]; - onAddItemsClick: () => void; -} - -export const ItemList = memo(({ items, onAddItemsClick }: ItemListProps) => { - renderLog("ItemList rendered"); - const [filter, setFilter] = useState(""); - const { theme } = useThemeContext(); - - 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()}원 -
  • - ))} -
-
- ); -}); diff --git a/src/components/NotificationSystem.tsx b/src/components/NotificationSystem.tsx deleted file mode 100644 index 22d5e85..0000000 --- a/src/components/NotificationSystem.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { memo } from "../@lib"; -import { useNotificationContext } from "../contexts/NotificationContext"; -import { renderLog } from "../utils"; - -export const NotificationSystem = memo(() => { - renderLog("NotificationSystem rendered"); - const { notifications, removeNotification } = useNotificationContext(); - - return ( -
- {notifications.map((notification) => ( -
- {notification.message} - -
- ))} -
- ); -}); diff --git a/src/components/index.ts b/src/components/index.ts deleted file mode 100644 index 268c788..0000000 --- a/src/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./ComplexForm"; -export * from "./Header"; -export * from "./ItemList"; -export * from "./NotificationSystem"; From 6954f6026bb5938d60a7787ef9146e4ad9c983fd Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Mon, 30 Dec 2024 22:56:33 +0900 Subject: [PATCH 20/29] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4610bd9..03bc1ef 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test": "vitest", "test:basic": "vitest basic", "test:advanced": "vitest advanced", - "test:external": "vitest external", + "test:external": "vitest externalStore", "test:ui": "vitest --ui", "prepare": "husky" }, From 6d0319f87f1139be9b5b122f18e60ffa99f92160 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Mon, 30 Dec 2024 22:57:01 +0900 Subject: [PATCH 21/29] =?UTF-8?q?fix:=20ci=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e22fe78..a9637d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: with: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} - - name: advanced-test + - name: external-test run: | npm install npm run test:external From 847b898cdd7ceeb3e003b97e925e44e0063acbf4 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Wed, 1 Jan 2025 21:59:13 +0900 Subject: [PATCH 22/29] =?UTF-8?q?fix:=20git=20action=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 12 +----------- .github/workflows/ci2.yml | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ci2.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9637d6..e09387c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,14 +85,4 @@ jobs: run: | npm install npm run test:advanced - external: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - name: external-test - run: | - npm install - npm run test:external + diff --git a/.github/workflows/ci2.yml b/.github/workflows/ci2.yml new file mode 100644 index 0000000..5cbba21 --- /dev/null +++ b/.github/workflows/ci2.yml @@ -0,0 +1,23 @@ +name: External Test + +on: + pull_request: + types: + - synchronize + - opened + - reopened + + workflow_dispatch: + +jobs: + external: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - name: external-test + run: | + npm install + npm run test:external From 735bf347e4f8fed814aadc689897840143ea3328 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Thu, 2 Jan 2025 18:33:43 +0900 Subject: [PATCH 23/29] =?UTF-8?q?chore:=20import=20alias=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tsconfig.app.json | 12 ++++++++---- vite.config.ts | 10 ++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tsconfig.app.json b/tsconfig.app.json index 0790727..804edd3 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -18,9 +18,13 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@lib/*": ["src/@lib/*"] + } }, - "include": [ - "src" - ] + "include": ["src"] } diff --git a/vite.config.ts b/vite.config.ts index bf8bb80..5b71833 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,12 @@ import react from "@vitejs/plugin-react-swc"; export default mergeConfig( defineConfig({ plugins: [react()], + resolve: { + alias: [ + { find: "@", replacement: "/src" }, + { find: "@lib", replacement: "/src/@lib" }, + ], + }, }), defineTestConfig({ test: { @@ -15,6 +21,10 @@ export default mergeConfig( reportsDirectory: "./.coverage", reporter: ["lcov", "json", "json-summary"], }, + alias: [ + { find: "@", replacement: "/src" }, + { find: "@lib", replacement: "/src/@lib" }, + ], }, }), ); From 43c5590b63311c4d4e7c91af41eb218626b39d7f Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Thu, 2 Jan 2025 18:34:06 +0900 Subject: [PATCH 24/29] =?UTF-8?q?feat:=20types=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/types.ts diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5c2a653 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,11 @@ +export interface User { + id: number; + name: string; + email: string; +} + +export interface Notification { + id: number; + message: string; + type: "info" | "success" | "warning" | "error"; +} From e8b9ff21858cc25c01c83563ea42bc2ee7be1d40 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Thu, 2 Jan 2025 18:42:02 +0900 Subject: [PATCH 25/29] =?UTF-8?q?feat:=20enhanced=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20action=20=EA=B3=BC=20state=20=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=ED=95=9C=20=EB=B2=84=EC=A0=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app-enhanced/App.tsx | 18 ++++ src/app-enhanced/components/ComplexForm.tsx | 88 +++++++++++++++++++ src/app-enhanced/components/Header.tsx | 51 +++++++++++ src/app-enhanced/components/ItemList.tsx | 82 +++++++++++++++++ src/app-enhanced/components/MainSection.tsx | 39 ++++++++ .../components/NotificationSystem.tsx | 39 ++++++++ src/app-enhanced/components/index.ts | 5 ++ .../notification/NotificationContext.ts | 19 ++++ .../notification/NotificationProvider.tsx | 54 ++++++++++++ .../contexts/notification/index.ts | 2 + .../contexts/notification/useNotification.ts | 25 ++++++ .../contexts/theme/ThemeContext.ts | 17 ++++ .../contexts/theme/ThemeProvider.tsx | 38 ++++++++ src/app-enhanced/contexts/theme/index.ts | 2 + src/app-enhanced/contexts/theme/useTheme.ts | 18 ++++ src/app-enhanced/contexts/user/UserContext.ts | 16 ++++ .../contexts/user/UserProvider.tsx | 51 +++++++++++ src/app-enhanced/contexts/user/index.ts | 2 + src/app-enhanced/contexts/user/useUser.ts | 18 ++++ 19 files changed, 584 insertions(+) create mode 100644 src/app-enhanced/App.tsx create mode 100644 src/app-enhanced/components/ComplexForm.tsx create mode 100644 src/app-enhanced/components/Header.tsx create mode 100644 src/app-enhanced/components/ItemList.tsx create mode 100644 src/app-enhanced/components/MainSection.tsx create mode 100644 src/app-enhanced/components/NotificationSystem.tsx create mode 100644 src/app-enhanced/components/index.ts create mode 100644 src/app-enhanced/contexts/notification/NotificationContext.ts create mode 100644 src/app-enhanced/contexts/notification/NotificationProvider.tsx create mode 100644 src/app-enhanced/contexts/notification/index.ts create mode 100644 src/app-enhanced/contexts/notification/useNotification.ts create mode 100644 src/app-enhanced/contexts/theme/ThemeContext.ts create mode 100644 src/app-enhanced/contexts/theme/ThemeProvider.tsx create mode 100644 src/app-enhanced/contexts/theme/index.ts create mode 100644 src/app-enhanced/contexts/theme/useTheme.ts create mode 100644 src/app-enhanced/contexts/user/UserContext.ts create mode 100644 src/app-enhanced/contexts/user/UserProvider.tsx create mode 100644 src/app-enhanced/contexts/user/index.ts create mode 100644 src/app-enhanced/contexts/user/useUser.ts diff --git a/src/app-enhanced/App.tsx b/src/app-enhanced/App.tsx new file mode 100644 index 0000000..4944e03 --- /dev/null +++ b/src/app-enhanced/App.tsx @@ -0,0 +1,18 @@ +import { MainSection } from "./components"; +import { NotificationProvider } from "./contexts/notification"; +import { ThemeProvider } from "./contexts/theme"; +import { UserProvider } from "./contexts/user/UserProvider"; + +const App = () => { + return ( + + + + + + + + ); +}; + +export default App; diff --git a/src/app-enhanced/components/ComplexForm.tsx b/src/app-enhanced/components/ComplexForm.tsx new file mode 100644 index 0000000..7a47c89 --- /dev/null +++ b/src/app-enhanced/components/ComplexForm.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { renderLog } from "@/utils"; +import { memo } from "@lib/hocs"; +import { useNotificationAction } from "../contexts/notification"; + +export const ComplexForm = memo(() => { + renderLog("ComplexForm rendered"); + const { addNotification } = useNotificationAction(); + 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) => ( + + ))} +
+ +
+
+ ); +}); diff --git a/src/app-enhanced/components/Header.tsx b/src/app-enhanced/components/Header.tsx new file mode 100644 index 0000000..5523afa --- /dev/null +++ b/src/app-enhanced/components/Header.tsx @@ -0,0 +1,51 @@ +import { memo } from "@lib/hocs"; +import { useThemeAction, useThemeState } from "../contexts/theme"; +import { useUserAction, useUserState } from "../contexts/user"; +import { renderLog } from "@/utils"; + +export const Header = memo(() => { + renderLog("Header rendered"); + const { theme } = useThemeState(); + const { toggleTheme } = useThemeAction(); + const { user } = useUserState(); + const { login, logout } = useUserAction(); + + const handleLogin = () => { + // 실제 애플리케이션에서는 사용자 입력을 받아야 합니다. + login("user@example.com", "password"); + }; + + return ( +
+
+

샘플 애플리케이션

+
+ + {user ? ( +
+ {user.name}님 환영합니다! + +
+ ) : ( + + )} +
+
+
+ ); +}); diff --git a/src/app-enhanced/components/ItemList.tsx b/src/app-enhanced/components/ItemList.tsx new file mode 100644 index 0000000..0a88d38 --- /dev/null +++ b/src/app-enhanced/components/ItemList.tsx @@ -0,0 +1,82 @@ +import { renderLog } from "@/utils"; +import { memo } from "@lib/hocs"; +import { useState } from "react"; +import { useThemeState } from "../contexts/theme"; +import { useMemo } from "@lib/hooks"; + +interface Item { + id: number; + name: string; + category: string; + price: number; +} + +interface ItemListProps { + items: Item[]; + onAddItemsClick: () => void; +} + +export const ItemList = memo(({ items, onAddItemsClick }: ItemListProps) => { + renderLog("ItemList rendered"); + const [filter, setFilter] = useState(""); + const { theme } = useThemeState(); + + const filteredItems = useMemo( + () => + items.filter( + (item) => + item.name.toLowerCase().includes(filter.toLowerCase()) || + item.category.toLowerCase().includes(filter.toLowerCase()), + ), + [items, filter], + ); + + const totalPrice = useMemo( + () => filteredItems.reduce((sum, item) => sum + item.price, 0), + [filteredItems], + ); + + const averagePrice = useMemo( + () => Math.round(totalPrice / filteredItems.length) || 0, + [totalPrice, filteredItems], + ); + + 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()}원 +
  • + ))} +
+
+ ); +}); diff --git a/src/app-enhanced/components/MainSection.tsx b/src/app-enhanced/components/MainSection.tsx new file mode 100644 index 0000000..37eb146 --- /dev/null +++ b/src/app-enhanced/components/MainSection.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; +import { ComplexForm } from "./ComplexForm"; +import { ItemList } from "./ItemList"; +import { Header } from "./Header"; +import { NotificationSystem } from "./NotificationSystem"; +import { useThemeState } from "../contexts/theme"; +import { generateItems } from "@/utils"; +import { useCallback } from "@lib/hooks"; + +export const MainSection = () => { + const [items, setItems] = useState(() => generateItems(1000)); + const { theme } = useThemeState(); + + const addItems = useCallback(() => { + setItems((prevItems) => [ + ...prevItems, + ...generateItems(1000, prevItems.length), + ]); + }, []); + + return ( +
+
+
+
+
+ +
+
+ +
+
+
+ +
+ ); +}; diff --git a/src/app-enhanced/components/NotificationSystem.tsx b/src/app-enhanced/components/NotificationSystem.tsx new file mode 100644 index 0000000..cf3db85 --- /dev/null +++ b/src/app-enhanced/components/NotificationSystem.tsx @@ -0,0 +1,39 @@ +import { memo } from "@lib/hocs"; +import { renderLog } from "@/utils"; +import { + useNotificationAction, + useNotificationState, +} from "../contexts/notification"; + +export const NotificationSystem = memo(() => { + renderLog("NotificationSystem rendered"); + const { notifications } = useNotificationState(); + const { removeNotification } = useNotificationAction(); + + return ( +
+ {notifications.map((notification) => ( +
+ {notification.message} + +
+ ))} +
+ ); +}); diff --git a/src/app-enhanced/components/index.ts b/src/app-enhanced/components/index.ts new file mode 100644 index 0000000..7378403 --- /dev/null +++ b/src/app-enhanced/components/index.ts @@ -0,0 +1,5 @@ +export * from "./ComplexForm"; +export * from "./Header"; +export * from "./ItemList"; +export * from "./MainSection"; +export * from "./NotificationSystem"; diff --git a/src/app-enhanced/contexts/notification/NotificationContext.ts b/src/app-enhanced/contexts/notification/NotificationContext.ts new file mode 100644 index 0000000..d784dce --- /dev/null +++ b/src/app-enhanced/contexts/notification/NotificationContext.ts @@ -0,0 +1,19 @@ +import { Notification } from "@/types"; +import { createContext } from "react"; + +export interface NotificationState { + notifications: Notification[]; +} + +export interface NotificationAction { + addNotification: (message: string, type: Notification["type"]) => void; + removeNotification: (id: number) => void; +} + +export const NotificationStateContext = createContext< + NotificationState | undefined +>(undefined); + +export const NotificationActionContext = createContext< + NotificationAction | undefined +>(undefined); diff --git a/src/app-enhanced/contexts/notification/NotificationProvider.tsx b/src/app-enhanced/contexts/notification/NotificationProvider.tsx new file mode 100644 index 0000000..7c96138 --- /dev/null +++ b/src/app-enhanced/contexts/notification/NotificationProvider.tsx @@ -0,0 +1,54 @@ +import { Notification } from "@/types"; +import { useCallback, useMemo } from "@lib/hooks"; +import { PropsWithChildren, useState } from "react"; +import { + NotificationAction, + NotificationActionContext, + NotificationState, + NotificationStateContext, +} from "./NotificationContext"; + +export const NotificationProvider = ({ children }: PropsWithChildren) => { + const [notifications, setNotifications] = useState([]); + + 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((notification) => notification.id !== id), + ); + }, []); + + const notificationState = useMemo( + () => ({ + notifications, + }), + [notifications], + ); + + const notificationAction = useMemo( + () => ({ + addNotification, + removeNotification, + }), + [addNotification, removeNotification], + ); + + return ( + + + {children} + + + ); +}; diff --git a/src/app-enhanced/contexts/notification/index.ts b/src/app-enhanced/contexts/notification/index.ts new file mode 100644 index 0000000..e8e6522 --- /dev/null +++ b/src/app-enhanced/contexts/notification/index.ts @@ -0,0 +1,2 @@ +export * from "./NotificationProvider"; +export * from "./useNotification"; diff --git a/src/app-enhanced/contexts/notification/useNotification.ts b/src/app-enhanced/contexts/notification/useNotification.ts new file mode 100644 index 0000000..2976f23 --- /dev/null +++ b/src/app-enhanced/contexts/notification/useNotification.ts @@ -0,0 +1,25 @@ +import { useContext } from "react"; +import { + NotificationActionContext, + NotificationStateContext, +} from "./NotificationContext"; + +export const useNotificationState = () => { + const context = useContext(NotificationStateContext); + if (context === undefined) { + throw new Error( + "useNotificationState must be used within an NotificationProvider", + ); + } + return context; +}; + +export const useNotificationAction = () => { + const context = useContext(NotificationActionContext); + if (context === undefined) { + throw new Error( + "useNotificationAction must be used within an NotificationProvider", + ); + } + return context; +}; diff --git a/src/app-enhanced/contexts/theme/ThemeContext.ts b/src/app-enhanced/contexts/theme/ThemeContext.ts new file mode 100644 index 0000000..5424105 --- /dev/null +++ b/src/app-enhanced/contexts/theme/ThemeContext.ts @@ -0,0 +1,17 @@ +import { createContext } from "react"; + +export interface ThemeState { + theme: string; +} + +export interface ThemeAction { + toggleTheme: () => void; +} + +export const ThemeStateContext = createContext( + undefined, +); + +export const ThemeActionContext = createContext( + undefined, +); diff --git a/src/app-enhanced/contexts/theme/ThemeProvider.tsx b/src/app-enhanced/contexts/theme/ThemeProvider.tsx new file mode 100644 index 0000000..613b7ff --- /dev/null +++ b/src/app-enhanced/contexts/theme/ThemeProvider.tsx @@ -0,0 +1,38 @@ +import { useCallback, useMemo } from "@lib/hooks"; +import { PropsWithChildren, useState } from "react"; +import { + ThemeAction, + ThemeActionContext, + ThemeState, + ThemeStateContext, +} from "./ThemeContext"; + +export const ThemeProvider = ({ children }: PropsWithChildren) => { + const [theme, setTheme] = useState("light"); + + const toggleTheme = useCallback(() => { + setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); + }, []); + + const themeState = useMemo( + () => ({ + theme, + }), + [theme], + ); + + const themeAction = useMemo( + () => ({ + toggleTheme, + }), + [toggleTheme], + ); + + return ( + + + {children} + + + ); +}; diff --git a/src/app-enhanced/contexts/theme/index.ts b/src/app-enhanced/contexts/theme/index.ts new file mode 100644 index 0000000..9cac196 --- /dev/null +++ b/src/app-enhanced/contexts/theme/index.ts @@ -0,0 +1,2 @@ +export * from "./ThemeProvider"; +export * from "./useTheme"; diff --git a/src/app-enhanced/contexts/theme/useTheme.ts b/src/app-enhanced/contexts/theme/useTheme.ts new file mode 100644 index 0000000..e376feb --- /dev/null +++ b/src/app-enhanced/contexts/theme/useTheme.ts @@ -0,0 +1,18 @@ +import { useContext } from "react"; +import { ThemeActionContext, ThemeStateContext } from "./ThemeContext"; + +export const useThemeState = () => { + const context = useContext(ThemeStateContext); + if (context === undefined) { + throw new Error("useThemeState must be used within an ThemeProvider"); + } + return context; +}; + +export const useThemeAction = () => { + const context = useContext(ThemeActionContext); + if (context === undefined) { + throw new Error("useThemeAction must be used within an ThemeProvider"); + } + return context; +}; diff --git a/src/app-enhanced/contexts/user/UserContext.ts b/src/app-enhanced/contexts/user/UserContext.ts new file mode 100644 index 0000000..99b5d52 --- /dev/null +++ b/src/app-enhanced/contexts/user/UserContext.ts @@ -0,0 +1,16 @@ +import { User } from "@/types"; +import { createContext } from "react"; + +export interface UserState { + user: User | null; +} + +export interface UserAction { + login: (email: string, password: string) => void; + logout: () => void; +} + +export const UserStateContext = createContext(undefined); +export const UserActionContext = createContext( + undefined, +); diff --git a/src/app-enhanced/contexts/user/UserProvider.tsx b/src/app-enhanced/contexts/user/UserProvider.tsx new file mode 100644 index 0000000..af9ff58 --- /dev/null +++ b/src/app-enhanced/contexts/user/UserProvider.tsx @@ -0,0 +1,51 @@ +import { useCallback, useMemo } from "@lib/hooks"; +import { User } from "@/types"; +import { PropsWithChildren, useState } from "react"; +import { useNotificationAction } from "../notification"; +import { + UserAction, + UserActionContext, + UserState, + UserStateContext, +} from "./UserContext"; + +export const UserProvider = ({ children }: PropsWithChildren) => { + const [user, setUser] = useState(null); + const { addNotification } = useNotificationAction(); + + const login = useCallback( + (email: string) => { + setUser({ id: 1, name: "홍길동", email }); + addNotification("성공적으로 로그인되었습니다", "success"); + }, + [addNotification], + ); + + const logout = useCallback(() => { + setUser(null); + addNotification("로그아웃되었습니다", "info"); + }, [addNotification]); + + const userState = useMemo( + () => ({ + user, + }), + [user], + ); + + const userAction = useMemo( + () => ({ + login, + logout, + }), + [login, logout], + ); + + return ( + + + {children} + + + ); +}; diff --git a/src/app-enhanced/contexts/user/index.ts b/src/app-enhanced/contexts/user/index.ts new file mode 100644 index 0000000..311416e --- /dev/null +++ b/src/app-enhanced/contexts/user/index.ts @@ -0,0 +1,2 @@ +export * from "./UserProvider"; +export * from "./useUser"; diff --git a/src/app-enhanced/contexts/user/useUser.ts b/src/app-enhanced/contexts/user/useUser.ts new file mode 100644 index 0000000..d1bc580 --- /dev/null +++ b/src/app-enhanced/contexts/user/useUser.ts @@ -0,0 +1,18 @@ +import { useContext } from "react"; +import { UserActionContext, UserStateContext } from "./UserContext"; + +export const useUserState = () => { + const context = useContext(UserStateContext); + if (context === undefined) { + throw new Error("useUserState must be used within an UserProvider"); + } + return context; +}; + +export const useUserAction = () => { + const context = useContext(UserActionContext); + if (context === undefined) { + throw new Error("useUserAction must be used within an UserProvider"); + } + return context; +}; From 25c11fe2a1a9d4247fcf7c4ca92809e25c130483 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Thu, 2 Jan 2025 18:43:38 +0900 Subject: [PATCH 26/29] =?UTF-8?q?feat:=20enhancd=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20ci=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci2.yml | 11 +++ package.json | 1 + src/__tests__/enhanced.test.tsx | 161 ++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 src/__tests__/enhanced.test.tsx diff --git a/.github/workflows/ci2.yml b/.github/workflows/ci2.yml index 5cbba21..361f669 100644 --- a/.github/workflows/ci2.yml +++ b/.github/workflows/ci2.yml @@ -21,3 +21,14 @@ jobs: run: | npm install npm run test:external + enhanced: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - name: enhanced-test + run: | + npm install + npm run test:enhanced diff --git a/package.json b/package.json index 03bc1ef..a4abbbb 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "test": "vitest", "test:basic": "vitest basic", "test:advanced": "vitest advanced", + "test:enhanced": "vitest enhanced", "test:external": "vitest externalStore", "test:ui": "vitest --ui", "prepare": "husky" diff --git a/src/__tests__/enhanced.test.tsx b/src/__tests__/enhanced.test.tsx new file mode 100644 index 0000000..00cd21a --- /dev/null +++ b/src/__tests__/enhanced.test.tsx @@ -0,0 +1,161 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as utils from "../utils"; +import App from "../app-enhanced/App"; + +const renderLogMock = vi.spyOn(utils, "renderLog"); +const generateItemsSpy = vi.spyOn(utils, "generateItems"); + +describe("최적화된 App 컴포넌트 테스트", () => { + beforeEach(() => { + renderLogMock.mockClear(); + generateItemsSpy.mockClear(); + }); + + afterEach(() => { + try { + screen.getAllByText("닫기").forEach((button) => fireEvent.click(button)); + } catch { + console.log("닫기 버튼 없음"); + } + }); + + it("초기 렌더링 시 모든 컴포넌트가 한 번씩 렌더링되어야 한다", () => { + render(); + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("ItemList rendered"); + expect(renderLogMock).toHaveBeenCalledWith("ComplexForm rendered"); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(4); + }); + + it("테마 변경 시 Header, ItemList만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + const themeButton = await screen.findByText(/다크 모드|라이트 모드/); + await fireEvent.click(themeButton); + + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("ItemList rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(2); + }); + + it("로그인/로그아웃 시 Header, ComplexForm, NotificationSystem만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + const loginButton = await screen.findByText("로그인"); + await fireEvent.click(loginButton); + + // Header가 변경 되면 알림이 발생하고, 알림 정보를 CompleteForm과 NotificationSystem이 가져다 사용 중 + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(2); + renderLogMock.mockClear(); + + const logoutButton = await screen.findByText("로그아웃"); + await fireEvent.click(logoutButton); + + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(2); + }); + + it("아이템 검색 시 ItemList만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + const searchInput = await screen.findByPlaceholderText("상품 검색..."); + await fireEvent.change(searchInput, { target: { value: "검색어" } }); + + expect(renderLogMock).toHaveBeenCalledWith("ItemList rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + }); + + it("폼 입력 시 ComplexForm만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + const nameInput = await screen.findByPlaceholderText("이름"); + await fireEvent.change(nameInput, { target: { value: "홍길동" } }); + + expect(renderLogMock).toHaveBeenCalledWith("ComplexForm rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + }); + + it("알림 추가 및 닫기시 ComplexForm, NotificationSystem만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + const submitButton = await screen.findByText("제출"); + fireEvent.click(submitButton); + + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + renderLogMock.mockClear(); + + // 알림 닫기 버튼 찾기 및 클릭 + const closeButton = await screen.findByText("닫기"); + await fireEvent.click(closeButton); + + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + }); + + it("여러 작업을 연속으로 수행해도 각 컴포넌트는 필요한 경우에만 리렌더링되어야 한다", async () => { + render(); + renderLogMock.mockClear(); + + // 테마 변경 + const themeButton = await screen.findByText(/다크 모드|라이트 모드/); + await fireEvent.click(themeButton); + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("ItemList rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(2); + renderLogMock.mockClear(); + + // 로그인 + const loginButton = await screen.findByText("로그인"); + await fireEvent.click(loginButton); + expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(2); + renderLogMock.mockClear(); + + // 알림 닫기 버튼 찾기 및 클릭 + await fireEvent.click(await screen.findByText("닫기")); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + renderLogMock.mockClear(); + + // 아이템 검색 + const searchInput = await screen.findByPlaceholderText("상품 검색..."); + await userEvent.type(searchInput, "검색어입력"); + expect(renderLogMock).toHaveBeenCalledWith("ItemList rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(5); + renderLogMock.mockClear(); + + // 폼 입력 + const nameInput = await screen.findByPlaceholderText("이름"); + await userEvent.type(nameInput, "홍길동"); + expect(renderLogMock).toHaveBeenCalledWith("ComplexForm rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(3); + renderLogMock.mockClear(); + + // 폼 제출 + const submitButton = await screen.findByText("제출"); + await fireEvent.click(submitButton); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + renderLogMock.mockClear(); + + // 알림 닫기 버튼 찾기 및 클릭 + await fireEvent.click(await screen.findByText("닫기")); + expect(renderLogMock).toHaveBeenCalledWith("NotificationSystem rendered"); + expect(renderLogMock).toHaveBeenCalledTimes(1); + + expect(generateItemsSpy).toHaveBeenCalledTimes(1); + }); +}); From 5fedabfba291e9b31559a271a50ba38092ca0cfc Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Thu, 2 Jan 2025 19:57:32 +0900 Subject: [PATCH 27/29] refactor: app --- src/app/App.tsx | 48 ++----------- src/app/components/ComplexForm.tsx | 8 +-- src/app/components/Header.tsx | 8 +-- src/app/components/ItemList.tsx | 14 ++-- src/app/components/MainSection.tsx | 38 ++++++++++ src/app/components/NotificationSystem.tsx | 6 +- src/app/components/index.ts | 1 + src/app/contexts/NotificationContext.tsx | 72 ------------------- src/app/contexts/ThemeContext.tsx | 47 ------------ src/app/contexts/UserContext.tsx | 63 ---------------- src/app/contexts/index.ts | 3 - .../notification/NotificationContext.ts | 17 +++++ .../notification/NotificationProvider.tsx | 44 ++++++++++++ src/app/contexts/notification/index.ts | 2 + .../contexts/notification/useNotification.ts | 12 ++++ src/app/contexts/theme/ThemeContext.ts | 15 ++++ src/app/contexts/theme/ThemeProvider.tsx | 25 +++++++ src/app/contexts/theme/index.ts | 2 + src/app/contexts/theme/useTheme.ts | 10 +++ src/app/contexts/user/UserContext.ts | 17 +++++ src/app/contexts/user/UserProvider.tsx | 36 ++++++++++ src/app/contexts/user/index.ts | 2 + src/app/contexts/user/useUser.ts | 10 +++ src/types.ts | 7 ++ 24 files changed, 258 insertions(+), 249 deletions(-) create mode 100644 src/app/components/MainSection.tsx delete mode 100644 src/app/contexts/NotificationContext.tsx delete mode 100644 src/app/contexts/ThemeContext.tsx delete mode 100644 src/app/contexts/UserContext.tsx delete mode 100644 src/app/contexts/index.ts create mode 100644 src/app/contexts/notification/NotificationContext.ts create mode 100644 src/app/contexts/notification/NotificationProvider.tsx create mode 100644 src/app/contexts/notification/index.ts create mode 100644 src/app/contexts/notification/useNotification.ts create mode 100644 src/app/contexts/theme/ThemeContext.ts create mode 100644 src/app/contexts/theme/ThemeProvider.tsx create mode 100644 src/app/contexts/theme/index.ts create mode 100644 src/app/contexts/theme/useTheme.ts create mode 100644 src/app/contexts/user/UserContext.ts create mode 100644 src/app/contexts/user/UserProvider.tsx create mode 100644 src/app/contexts/user/index.ts create mode 100644 src/app/contexts/user/useUser.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 56fb790..eea8480 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,52 +1,14 @@ -import { useState } from "react"; -import { - NotificationProvider, - ThemeConsumer, - ThemeProvider, - UserProvider, -} from "./contexts"; -import { - ComplexForm, - Header, - ItemList, - NotificationSystem, -} from "./components"; -import { generateItems } from "../utils"; +import { MainSection } from "./components"; +import { ThemeProvider } from "./contexts/theme"; +import { NotificationProvider } from "./contexts/notification"; +import { UserProvider } from "./contexts/user"; const App = () => { - const [items, setItems] = useState(generateItems(1000)); - - const addItems = () => { - setItems((prevItems) => [ - ...prevItems, - ...generateItems(1000, prevItems.length), - ]); - }; - return ( - - {(context) => ( -
-
-
-
-
- -
-
- -
-
-
- -
- )} -
+
diff --git a/src/app/components/ComplexForm.tsx b/src/app/components/ComplexForm.tsx index bd7ee0e..c029c40 100644 --- a/src/app/components/ComplexForm.tsx +++ b/src/app/components/ComplexForm.tsx @@ -1,9 +1,9 @@ +import { renderLog } from "@/utils"; +import { memo } from "@lib/hocs"; import { useState } from "react"; -import { renderLog } from "../../utils"; -import { memo } from "../../@lib"; -import { useNotificationContext } from "../contexts"; +import { useNotificationContext } from "../contexts/notification"; -export const ComplexForm: React.FC = memo(() => { +export const ComplexForm = memo(() => { renderLog("ComplexForm rendered"); const { addNotification } = useNotificationContext(); const [formData, setFormData] = useState({ diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx index a0ac339..0c549f3 100644 --- a/src/app/components/Header.tsx +++ b/src/app/components/Header.tsx @@ -1,7 +1,7 @@ -import { useThemeContext } from "../contexts/ThemeContext"; -import { useUserContext } from "../contexts/UserContext"; -import { renderLog } from "../../utils"; -import { memo } from "../../@lib"; +import { renderLog } from "@/utils"; +import { memo } from "@lib/hocs"; +import { useThemeContext } from "../contexts/theme"; +import { useUserContext } from "../contexts/user"; export const Header = memo(() => { renderLog("Header rendered"); diff --git a/src/app/components/ItemList.tsx b/src/app/components/ItemList.tsx index 9608f1d..d439a6b 100644 --- a/src/app/components/ItemList.tsx +++ b/src/app/components/ItemList.tsx @@ -1,14 +1,8 @@ +import { Item } from "@/types"; +import { renderLog } from "@/utils"; +import { memo } from "@lib/hocs"; import { useState } from "react"; -import { useThemeContext } from "../contexts/ThemeContext"; -import { renderLog } from "../../utils"; -import { memo } from "../../@lib"; - -interface Item { - id: number; - name: string; - category: string; - price: number; -} +import { useThemeContext } from "../contexts/theme"; interface ItemListProps { items: Item[]; diff --git a/src/app/components/MainSection.tsx b/src/app/components/MainSection.tsx new file mode 100644 index 0000000..72cef22 --- /dev/null +++ b/src/app/components/MainSection.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import { ComplexForm } from "./ComplexForm"; +import { Header } from "./Header"; +import { ItemList } from "./ItemList"; +import { NotificationSystem } from "./NotificationSystem"; +import { generateItems } from "@/utils"; +import { useThemeContext } from "../contexts/theme"; + +export function MainSection() { + const { theme } = useThemeContext(); + const [items, setItems] = useState(() => generateItems(1000)); + + const addItems = () => { + setItems((prevItems) => [ + ...prevItems, + ...generateItems(1000, prevItems.length), + ]); + }; + + return ( +
+
+
+
+
+ +
+
+ +
+
+
+ +
+ ); +} diff --git a/src/app/components/NotificationSystem.tsx b/src/app/components/NotificationSystem.tsx index 2e1dd84..3faabf1 100644 --- a/src/app/components/NotificationSystem.tsx +++ b/src/app/components/NotificationSystem.tsx @@ -1,6 +1,6 @@ -import { memo } from "../../@lib"; -import { renderLog } from "../../utils"; -import { useNotificationContext } from "../contexts/NotificationContext"; +import { renderLog } from "@/utils"; +import { memo } from "@lib/hocs"; +import { useNotificationContext } from "../contexts/notification"; export const NotificationSystem = memo(() => { renderLog("NotificationSystem rendered"); diff --git a/src/app/components/index.ts b/src/app/components/index.ts index 268c788..7378403 100644 --- a/src/app/components/index.ts +++ b/src/app/components/index.ts @@ -1,4 +1,5 @@ export * from "./ComplexForm"; export * from "./Header"; export * from "./ItemList"; +export * from "./MainSection"; export * from "./NotificationSystem"; diff --git a/src/app/contexts/NotificationContext.tsx b/src/app/contexts/NotificationContext.tsx deleted file mode 100644 index 0baeba3..0000000 --- a/src/app/contexts/NotificationContext.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { createContext, PropsWithChildren, useContext, useState } from "react"; -import { useCallback, useMemo } from "../../@lib"; - -interface Notification { - id: number; - message: string; - type: "info" | "success" | "warning" | "error"; -} - -interface NotificationState { - notifications: Notification[]; -} - -interface NotificationActions { - addNotification: (message: string, type: Notification["type"]) => void; - removeNotification: (id: number) => void; -} - -type NotificationType = NotificationState & NotificationActions; - -const NotificationContext = createContext( - undefined, -); - -const NotificationProvider = ({ children }: PropsWithChildren) => { - const [notifications, setNotifications] = useState([]); - - 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((notification) => notification.id !== id), - ); - }, []); - - const contextValue = useMemo( - () => ({ - notifications, - addNotification, - removeNotification, - }), - [notifications, addNotification, removeNotification], - ); - - return ( - - {children} - - ); -}; - -const useNotificationContext = () => { - const context = useContext(NotificationContext); - if (context === undefined) { - throw new Error( - "useNotificationContext must be used within an NotificationProvider", - ); - } - return context; -}; - -export { NotificationProvider, useNotificationContext }; diff --git a/src/app/contexts/ThemeContext.tsx b/src/app/contexts/ThemeContext.tsx deleted file mode 100644 index e56273c..0000000 --- a/src/app/contexts/ThemeContext.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { createContext, PropsWithChildren, useContext, useState } from "react"; -import { useCallback, useMemo } from "../../@lib"; - -interface ThemeState { - theme: string; -} - -interface ThemeActions { - toggleTheme: () => void; -} - -type ThemeType = ThemeState & ThemeActions; - -const ThemeContext = createContext(undefined); - -const ThemeProvider = ({ children }: PropsWithChildren) => { - const [theme, setTheme] = useState("light"); - - const toggleTheme = useCallback(() => { - setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); - }, []); - - const contextValue = useMemo( - () => ({ - theme, - toggleTheme, - }), - [theme, toggleTheme], - ); - - return ( - - {children} - - ); -}; - -const useThemeContext = () => { - const context = useContext(ThemeContext); - if (context === undefined) { - throw new Error("useThemeContext must be used within an ThemeProvider"); - } - return context; -}; - -const ThemeConsumer = ThemeContext.Consumer; -export { ThemeConsumer, ThemeProvider, useThemeContext }; diff --git a/src/app/contexts/UserContext.tsx b/src/app/contexts/UserContext.tsx deleted file mode 100644 index 06a885b..0000000 --- a/src/app/contexts/UserContext.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { createContext, PropsWithChildren, useContext, useState } from "react"; -import { useNotificationContext } from "./NotificationContext"; -import { useCallback, useMemo } from "../../@lib"; - -interface User { - id: number; - name: string; - email: string; -} - -interface UserState { - user: User | null; -} - -interface UserActions { - login: (email: string, password: string) => void; - logout: () => void; -} - -type UserType = UserState & UserActions; - -const UserContext = createContext(undefined); - -const UserProvider = ({ children }: PropsWithChildren) => { - const [user, setUser] = useState(null); - const { addNotification } = useNotificationContext(); - - const login = useCallback( - (email: string) => { - setUser({ id: 1, name: "홍길동", email }); - addNotification("성공적으로 로그인되었습니다", "success"); - }, - [addNotification], - ); - - const logout = useCallback(() => { - setUser(null); - addNotification("로그아웃되었습니다", "info"); - }, [addNotification]); - - const contextValue = useMemo( - () => ({ - user, - login, - logout, - }), - [user, login, logout], - ); - - return ( - {children} - ); -}; - -const useUserContext = () => { - const context = useContext(UserContext); - if (context === undefined) { - throw new Error("useUserContext must be used within an UserProvider"); - } - return context; -}; - -export { UserProvider, useUserContext }; diff --git a/src/app/contexts/index.ts b/src/app/contexts/index.ts deleted file mode 100644 index 484c384..0000000 --- a/src/app/contexts/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./NotificationContext"; -export * from "./ThemeContext"; -export * from "./UserContext"; diff --git a/src/app/contexts/notification/NotificationContext.ts b/src/app/contexts/notification/NotificationContext.ts new file mode 100644 index 0000000..0b47880 --- /dev/null +++ b/src/app/contexts/notification/NotificationContext.ts @@ -0,0 +1,17 @@ +import { Notification } from "@/types"; +import { createContext } from "react"; + +interface NotificationState { + notifications: Notification[]; +} + +interface NotificationAction { + addNotification: (message: string, type: Notification["type"]) => void; + removeNotification: (id: number) => void; +} + +export type NotificationContextType = NotificationState & NotificationAction; + +export const NotificationContext = createContext< + NotificationContextType | undefined +>(undefined); diff --git a/src/app/contexts/notification/NotificationProvider.tsx b/src/app/contexts/notification/NotificationProvider.tsx new file mode 100644 index 0000000..e74be91 --- /dev/null +++ b/src/app/contexts/notification/NotificationProvider.tsx @@ -0,0 +1,44 @@ +import { Notification } from "@/types"; +import { useCallback, useMemo } from "@lib/hooks"; +import { PropsWithChildren, useState } from "react"; +import { + NotificationContext, + NotificationContextType, +} from "./NotificationContext"; + +export const NotificationProvider = ({ children }: PropsWithChildren) => { + const [notifications, setNotifications] = useState([]); + + 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((notification) => notification.id !== id), + ); + }, []); + + const contextValue = useMemo( + () => ({ + notifications, + addNotification, + removeNotification, + }), + [notifications, addNotification, removeNotification], + ); + + return ( + + {children} + + ); +}; diff --git a/src/app/contexts/notification/index.ts b/src/app/contexts/notification/index.ts new file mode 100644 index 0000000..e8e6522 --- /dev/null +++ b/src/app/contexts/notification/index.ts @@ -0,0 +1,2 @@ +export * from "./NotificationProvider"; +export * from "./useNotification"; diff --git a/src/app/contexts/notification/useNotification.ts b/src/app/contexts/notification/useNotification.ts new file mode 100644 index 0000000..eb340ff --- /dev/null +++ b/src/app/contexts/notification/useNotification.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { NotificationContext } from "./NotificationContext"; + +export const useNotificationContext = () => { + const context = useContext(NotificationContext); + if (context === undefined) { + throw new Error( + "useNotificationContext must be used within an NotificationProvider", + ); + } + return context; +}; diff --git a/src/app/contexts/theme/ThemeContext.ts b/src/app/contexts/theme/ThemeContext.ts new file mode 100644 index 0000000..c638818 --- /dev/null +++ b/src/app/contexts/theme/ThemeContext.ts @@ -0,0 +1,15 @@ +import { createContext } from "react"; + +interface ThemeState { + theme: string; +} + +interface ThemeAction { + toggleTheme: () => void; +} + +export type ThemeContextType = ThemeState & ThemeAction; + +export const ThemeContext = createContext( + undefined, +); diff --git a/src/app/contexts/theme/ThemeProvider.tsx b/src/app/contexts/theme/ThemeProvider.tsx new file mode 100644 index 0000000..807780d --- /dev/null +++ b/src/app/contexts/theme/ThemeProvider.tsx @@ -0,0 +1,25 @@ +import { useCallback, useMemo } from "@lib/hooks"; +import { PropsWithChildren, useState } from "react"; +import { ThemeContext, ThemeContextType } from "./ThemeContext"; + +export const ThemeProvider = ({ children }: PropsWithChildren) => { + const [theme, setTheme] = useState("light"); + + const toggleTheme = useCallback(() => { + setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); + }, []); + + const contextValue = useMemo( + () => ({ + theme, + toggleTheme, + }), + [theme, toggleTheme], + ); + + return ( + + {children} + + ); +}; diff --git a/src/app/contexts/theme/index.ts b/src/app/contexts/theme/index.ts new file mode 100644 index 0000000..9cac196 --- /dev/null +++ b/src/app/contexts/theme/index.ts @@ -0,0 +1,2 @@ +export * from "./ThemeProvider"; +export * from "./useTheme"; diff --git a/src/app/contexts/theme/useTheme.ts b/src/app/contexts/theme/useTheme.ts new file mode 100644 index 0000000..1956d82 --- /dev/null +++ b/src/app/contexts/theme/useTheme.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { ThemeContext } from "./ThemeContext"; + +export const useThemeContext = () => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error("useThemeContext must be used within an ThemeProvider"); + } + return context; +}; diff --git a/src/app/contexts/user/UserContext.ts b/src/app/contexts/user/UserContext.ts new file mode 100644 index 0000000..9c67af4 --- /dev/null +++ b/src/app/contexts/user/UserContext.ts @@ -0,0 +1,17 @@ +import { User } from "@/types"; +import { createContext } from "react"; + +interface UserState { + user: User | null; +} + +interface UserAction { + login: (email: string, password: string) => void; + logout: () => void; +} + +export type UserContextType = UserState & UserAction; + +export const UserContext = createContext( + undefined, +); diff --git a/src/app/contexts/user/UserProvider.tsx b/src/app/contexts/user/UserProvider.tsx new file mode 100644 index 0000000..9dd51cf --- /dev/null +++ b/src/app/contexts/user/UserProvider.tsx @@ -0,0 +1,36 @@ +import { User } from "@/types"; +import { PropsWithChildren, useState } from "react"; +import { useNotificationContext } from "../notification"; +import { useCallback, useMemo } from "@lib/hooks"; +import { UserContext, UserContextType } from "./UserContext"; + +export const UserProvider = ({ children }: PropsWithChildren) => { + const [user, setUser] = useState(null); + const { addNotification } = useNotificationContext(); + + const login = useCallback( + (email: string) => { + setUser({ id: 1, name: "홍길동", email }); + addNotification("성공적으로 로그인되었습니다", "success"); + }, + [addNotification], + ); + + const logout = useCallback(() => { + setUser(null); + addNotification("로그아웃되었습니다", "info"); + }, [addNotification]); + + const contextValue = useMemo( + () => ({ + user, + login, + logout, + }), + [user, login, logout], + ); + + return ( + {children} + ); +}; diff --git a/src/app/contexts/user/index.ts b/src/app/contexts/user/index.ts new file mode 100644 index 0000000..311416e --- /dev/null +++ b/src/app/contexts/user/index.ts @@ -0,0 +1,2 @@ +export * from "./UserProvider"; +export * from "./useUser"; diff --git a/src/app/contexts/user/useUser.ts b/src/app/contexts/user/useUser.ts new file mode 100644 index 0000000..9931e33 --- /dev/null +++ b/src/app/contexts/user/useUser.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { UserContext } from "./UserContext"; + +export const useUserContext = () => { + const context = useContext(UserContext); + if (context === undefined) { + throw new Error("useUserContext must be used within an UserProvider"); + } + return context; +}; diff --git a/src/types.ts b/src/types.ts index 5c2a653..2566d28 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,3 +9,10 @@ export interface Notification { message: string; type: "info" | "success" | "warning" | "error"; } + +export interface Item { + id: number; + name: string; + category: string; + price: number; +} From cbaa7e9984f9a4fc4f3d1df595e994d97f5469c1 Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Thu, 2 Jan 2025 20:14:59 +0900 Subject: [PATCH 28/29] refactor: app-plus --- src/app-plus/App.tsx | 4 +- src/app-plus/components/ComplexForm.tsx | 4 +- src/app-plus/components/Header.tsx | 6 +- src/app-plus/components/ItemList.tsx | 14 +--- src/app-plus/components/MainLayout.tsx | 2 +- src/app-plus/components/MainSection.tsx | 4 +- .../components/NotificationSystem.tsx | 7 +- src/app-plus/contexts/NotificationContext.tsx | 77 ------------------- src/app-plus/contexts/ThemeContext.tsx | 50 ------------ src/app-plus/contexts/UserContext.tsx | 61 --------------- src/app-plus/contexts/index.ts | 3 - .../notification/NotificationContext.ts | 44 +++++++++++ .../notification/NotificationProvider.tsx | 21 +++++ src/app-plus/contexts/notification/index.ts | 2 + .../contexts/notification/useNotification.ts | 15 ++++ src/app-plus/contexts/theme/ThemeContext.tsx | 25 ++++++ src/app-plus/contexts/theme/ThemeProvider.tsx | 17 ++++ src/app-plus/contexts/theme/index.ts | 2 + src/app-plus/contexts/theme/useTheme.ts | 11 +++ src/app-plus/contexts/user/UserContext.ts | 31 ++++++++ src/app-plus/contexts/user/UserProvider.tsx | 17 ++++ src/app-plus/contexts/user/index.ts | 2 + src/app-plus/contexts/user/useUser.ts | 11 +++ 23 files changed, 217 insertions(+), 213 deletions(-) delete mode 100644 src/app-plus/contexts/NotificationContext.tsx delete mode 100644 src/app-plus/contexts/ThemeContext.tsx delete mode 100644 src/app-plus/contexts/UserContext.tsx delete mode 100644 src/app-plus/contexts/index.ts create mode 100644 src/app-plus/contexts/notification/NotificationContext.ts create mode 100644 src/app-plus/contexts/notification/NotificationProvider.tsx create mode 100644 src/app-plus/contexts/notification/index.ts create mode 100644 src/app-plus/contexts/notification/useNotification.ts create mode 100644 src/app-plus/contexts/theme/ThemeContext.tsx create mode 100644 src/app-plus/contexts/theme/ThemeProvider.tsx create mode 100644 src/app-plus/contexts/theme/index.ts create mode 100644 src/app-plus/contexts/theme/useTheme.ts create mode 100644 src/app-plus/contexts/user/UserContext.ts create mode 100644 src/app-plus/contexts/user/UserProvider.tsx create mode 100644 src/app-plus/contexts/user/index.ts create mode 100644 src/app-plus/contexts/user/useUser.ts diff --git a/src/app-plus/App.tsx b/src/app-plus/App.tsx index 773762c..49d84be 100644 --- a/src/app-plus/App.tsx +++ b/src/app-plus/App.tsx @@ -4,7 +4,9 @@ import { MainSection, NotificationSystem, } from "./components"; -import { NotificationProvider, ThemeProvider, UserProvider } from "./contexts"; +import { NotificationProvider } from "./contexts/notification"; +import { ThemeProvider } from "./contexts/theme"; +import { UserProvider } from "./contexts/user"; const App = () => { return ( diff --git a/src/app-plus/components/ComplexForm.tsx b/src/app-plus/components/ComplexForm.tsx index c4e9cb1..b9e354c 100644 --- a/src/app-plus/components/ComplexForm.tsx +++ b/src/app-plus/components/ComplexForm.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { renderLog } from "../../utils"; -import { useNotificationStore } from "../contexts"; +import { useNotificationStore } from "../contexts/notification"; +import { renderLog } from "@/utils"; export const ComplexForm = () => { renderLog("ComplexForm rendered"); diff --git a/src/app-plus/components/Header.tsx b/src/app-plus/components/Header.tsx index 030e7c8..c057d8a 100644 --- a/src/app-plus/components/Header.tsx +++ b/src/app-plus/components/Header.tsx @@ -1,5 +1,7 @@ -import { useNotificationStore, useThemeStore, useUserStore } from "../contexts"; -import { renderLog } from "../../utils"; +import { renderLog } from "@/utils"; +import { useThemeStore } from "../contexts/theme"; +import { useUserStore } from "../contexts/user"; +import { useNotificationStore } from "../contexts/notification"; export const Header = () => { renderLog("Header rendered"); diff --git a/src/app-plus/components/ItemList.tsx b/src/app-plus/components/ItemList.tsx index 6049163..98774ee 100644 --- a/src/app-plus/components/ItemList.tsx +++ b/src/app-plus/components/ItemList.tsx @@ -1,14 +1,8 @@ +import { renderLog } from "@/utils"; import { useState } from "react"; -import { renderLog } from "../../utils"; -import { useThemeStore } from "../contexts"; -import { useMemo } from "../../@lib"; - -interface Item { - id: number; - name: string; - category: string; - price: number; -} +import { useThemeStore } from "../contexts/theme"; +import { useMemo } from "@lib/hooks"; +import { Item } from "@/types"; interface ItemListProps { items: Item[]; diff --git a/src/app-plus/components/MainLayout.tsx b/src/app-plus/components/MainLayout.tsx index bb47f8e..f2f3a53 100644 --- a/src/app-plus/components/MainLayout.tsx +++ b/src/app-plus/components/MainLayout.tsx @@ -1,5 +1,5 @@ import { PropsWithChildren } from "react"; -import { useThemeStore } from "../contexts"; +import { useThemeStore } from "../contexts/theme"; export const MainLayout = ({ children }: PropsWithChildren) => { const theme = useThemeStore((state) => state.theme); diff --git a/src/app-plus/components/MainSection.tsx b/src/app-plus/components/MainSection.tsx index 657806d..b66b4ca 100644 --- a/src/app-plus/components/MainSection.tsx +++ b/src/app-plus/components/MainSection.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; import { ItemList } from "./ItemList"; -import { generateItems } from "../../utils"; -import { useCallback } from "../../@lib"; import { ComplexForm } from "./ComplexForm"; +import { generateItems } from "@/utils"; +import { useCallback } from "@lib/hooks"; export const MainSection = () => { const [items, setItems] = useState(() => generateItems(1000)); diff --git a/src/app-plus/components/NotificationSystem.tsx b/src/app-plus/components/NotificationSystem.tsx index 0aba995..59c3d9b 100644 --- a/src/app-plus/components/NotificationSystem.tsx +++ b/src/app-plus/components/NotificationSystem.tsx @@ -1,15 +1,14 @@ -import { useNotificationStore } from "../contexts"; -import { renderLog } from "../../utils"; +import { renderLog } from "@/utils"; +import { useNotificationStore } from "../contexts/notification"; export const NotificationSystem = () => { + renderLog("NotificationSystem rendered"); const notifications = useNotificationStore((state) => state.notifications); const removeNotification = useNotificationStore( (state) => state.removeNotification, ); - renderLog("NotificationSystem rendered"); - return (
{notifications.map((notification) => ( diff --git a/src/app-plus/contexts/NotificationContext.tsx b/src/app-plus/contexts/NotificationContext.tsx deleted file mode 100644 index 658bf33..0000000 --- a/src/app-plus/contexts/NotificationContext.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { createContext, PropsWithChildren, useContext } from "react"; -import { useRef, useStore } from "../../@lib"; -import { createStore, Store } from "../../storeUtils"; - -interface Notification { - id: number; - message: string; - type: "info" | "success" | "warning" | "error"; -} - -interface NotificationState { - notifications: Notification[]; -} - -interface NotificationActions { - addNotification: (message: string, type: Notification["type"]) => void; - removeNotification: (id: number) => void; -} - -type NotificationType = NotificationState & NotificationActions; -type NotificationStore = Store; - -const notificationStore: NotificationStore = createStore( - (set) => ({ - notifications: [], - addNotification: (message, type) => { - const newNotification: Notification = { - id: Date.now(), - message, - type, - }; - - set((prev) => ({ - ...prev, - notifications: [...prev.notifications, newNotification], - })); - }, - removeNotification: (id) => { - set((prev) => ({ - ...prev, - notifications: prev.notifications.filter( - (notification) => notification.id !== id, - ), - })); - }, - }), -); - -const NotificationContext = createContext( - undefined, -); - -const NotificationProvider = ({ children }: PropsWithChildren) => { - const store = useRef(null); - - if (store.current === null) { - store.current = notificationStore; - } - - return ( - - {children} - - ); -}; - -const useNotificationStore = (selector: (store: NotificationType) => S) => { - const store = useContext(NotificationContext); - if (store === undefined) { - throw new Error( - "useNotificationContext must be used within an NotificationProvider", - ); - } - return useStore(store, selector); -}; - -export { NotificationProvider, useNotificationStore }; diff --git a/src/app-plus/contexts/ThemeContext.tsx b/src/app-plus/contexts/ThemeContext.tsx deleted file mode 100644 index 9f2e01c..0000000 --- a/src/app-plus/contexts/ThemeContext.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { createContext, PropsWithChildren, useContext } from "react"; -import { useRef, useStore } from "../../@lib"; -import { createStore, Store } from "../../storeUtils"; - -interface ThemeState { - theme: string; -} - -interface ThemeActions { - toggleTheme: () => void; -} - -type ThemeType = ThemeState & ThemeActions; -type ThemeStore = Store; - -const themeStore: ThemeStore = createStore((set) => ({ - theme: "light", - toggleTheme: () => { - set((prev) => ({ - ...prev, - theme: prev.theme === "light" ? "dark" : "light", - })); - }, -})); - -const ThemeContext = createContext(undefined); - -const ThemeProvider = ({ children }: PropsWithChildren) => { - const store = useRef(null); - - if (store.current === null) { - store.current = themeStore; - } - - return ( - - {children} - - ); -}; - -const useThemeStore = (selector: (context: ThemeType) => S) => { - const store = useContext(ThemeContext); - if (store === undefined) { - throw new Error("useThemeContext must be used within an ThemeProvider"); - } - return useStore(store, selector); -}; - -export { ThemeProvider, useThemeStore }; diff --git a/src/app-plus/contexts/UserContext.tsx b/src/app-plus/contexts/UserContext.tsx deleted file mode 100644 index f470fa4..0000000 --- a/src/app-plus/contexts/UserContext.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { createContext, PropsWithChildren, useContext } from "react"; -import { useRef, useStore } from "../../@lib"; -import { createStore, Store } from "../../storeUtils"; - -interface User { - id: number; - name: string; - email: string; -} - -interface UserState { - user: User | null; -} - -interface UserActions { - login: (email: string, password: string) => void; - logout: () => void; -} - -type UserType = UserState & UserActions; -type UserStore = Store; - -const userStore: UserStore = createStore((set) => ({ - user: null, - login: (email) => { - set((prev) => { - return { ...prev, user: { id: 1, name: "홍길동", email } }; - }); - }, - logout: () => { - set((prev) => { - return { ...prev, user: null }; - }); - }, -})); - -const UserContext = createContext(undefined); - -const UserProvider = ({ children }: PropsWithChildren) => { - const store = useRef(null); - - if (store.current === null) { - store.current = userStore; - } - - return ( - - {children} - - ); -}; - -const useUserStore = (selector: (context: UserType) => S) => { - const store = useContext(UserContext); - if (store === undefined) { - throw new Error("useUserContext must be used within an UserProvider"); - } - return useStore(store, selector); -}; - -export { UserProvider, useUserStore }; diff --git a/src/app-plus/contexts/index.ts b/src/app-plus/contexts/index.ts deleted file mode 100644 index 484c384..0000000 --- a/src/app-plus/contexts/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./NotificationContext"; -export * from "./ThemeContext"; -export * from "./UserContext"; diff --git a/src/app-plus/contexts/notification/NotificationContext.ts b/src/app-plus/contexts/notification/NotificationContext.ts new file mode 100644 index 0000000..6ee53b1 --- /dev/null +++ b/src/app-plus/contexts/notification/NotificationContext.ts @@ -0,0 +1,44 @@ +import { createStore, Store } from "@/storeUtils"; +import { Notification } from "@/types"; +import { createContext } from "react"; + +interface NotificationState { + notifications: Notification[]; +} + +interface NotificationAction { + addNotification: (message: string, type: Notification["type"]) => void; + removeNotification: (id: number) => void; +} + +export type NotificationType = NotificationState & NotificationAction; +export type NotificationStore = Store; + +export const notificationStore: NotificationStore = + createStore((set) => ({ + notifications: [], + addNotification: (message, type) => { + const newNotification: Notification = { + id: Date.now(), + message, + type, + }; + + set((prev) => ({ + ...prev, + notifications: [...prev.notifications, newNotification], + })); + }, + removeNotification: (id) => { + set((prev) => ({ + ...prev, + notifications: prev.notifications.filter( + (notification) => notification.id !== id, + ), + })); + }, + })); + +export const NotificationContext = createContext( + undefined, +); diff --git a/src/app-plus/contexts/notification/NotificationProvider.tsx b/src/app-plus/contexts/notification/NotificationProvider.tsx new file mode 100644 index 0000000..4df67a3 --- /dev/null +++ b/src/app-plus/contexts/notification/NotificationProvider.tsx @@ -0,0 +1,21 @@ +import { PropsWithChildren } from "react"; +import { + NotificationContext, + NotificationStore, + notificationStore, +} from "./NotificationContext"; +import { useRef } from "@lib/hooks"; + +export const NotificationProvider = ({ children }: PropsWithChildren) => { + const store = useRef(null); + + if (store.current === null) { + store.current = notificationStore; + } + + return ( + + {children} + + ); +}; diff --git a/src/app-plus/contexts/notification/index.ts b/src/app-plus/contexts/notification/index.ts new file mode 100644 index 0000000..e8e6522 --- /dev/null +++ b/src/app-plus/contexts/notification/index.ts @@ -0,0 +1,2 @@ +export * from "./NotificationProvider"; +export * from "./useNotification"; diff --git a/src/app-plus/contexts/notification/useNotification.ts b/src/app-plus/contexts/notification/useNotification.ts new file mode 100644 index 0000000..5bcca8a --- /dev/null +++ b/src/app-plus/contexts/notification/useNotification.ts @@ -0,0 +1,15 @@ +import { useContext } from "react"; +import { NotificationContext, NotificationType } from "./NotificationContext"; +import { useStore } from "@lib/hooks"; + +export const useNotificationStore = ( + selector: (store: NotificationType) => S, +) => { + const store = useContext(NotificationContext); + if (store === undefined) { + throw new Error( + "useNotificationContext must be used within an NotificationProvider", + ); + } + return useStore(store, selector); +}; diff --git a/src/app-plus/contexts/theme/ThemeContext.tsx b/src/app-plus/contexts/theme/ThemeContext.tsx new file mode 100644 index 0000000..2d4e86e --- /dev/null +++ b/src/app-plus/contexts/theme/ThemeContext.tsx @@ -0,0 +1,25 @@ +import { createStore, Store } from "@/storeUtils"; +import { createContext } from "react"; + +interface ThemeState { + theme: string; +} + +interface ThemeAction { + toggleTheme: () => void; +} + +export type ThemeType = ThemeState & ThemeAction; +export type ThemeStore = Store; + +export const themeStore: ThemeStore = createStore((set) => ({ + theme: "light", + toggleTheme: () => { + set((prev) => ({ + ...prev, + theme: prev.theme === "light" ? "dark" : "light", + })); + }, +})); + +export const ThemeContext = createContext(undefined); diff --git a/src/app-plus/contexts/theme/ThemeProvider.tsx b/src/app-plus/contexts/theme/ThemeProvider.tsx new file mode 100644 index 0000000..da10cdb --- /dev/null +++ b/src/app-plus/contexts/theme/ThemeProvider.tsx @@ -0,0 +1,17 @@ +import { useRef } from "@lib/hooks"; +import { PropsWithChildren } from "react"; +import { ThemeContext, themeStore, ThemeStore } from "./ThemeContext"; + +export const ThemeProvider = ({ children }: PropsWithChildren) => { + const store = useRef(null); + + if (store.current === null) { + store.current = themeStore; + } + + return ( + + {children} + + ); +}; diff --git a/src/app-plus/contexts/theme/index.ts b/src/app-plus/contexts/theme/index.ts new file mode 100644 index 0000000..9cac196 --- /dev/null +++ b/src/app-plus/contexts/theme/index.ts @@ -0,0 +1,2 @@ +export * from "./ThemeProvider"; +export * from "./useTheme"; diff --git a/src/app-plus/contexts/theme/useTheme.ts b/src/app-plus/contexts/theme/useTheme.ts new file mode 100644 index 0000000..4971ce5 --- /dev/null +++ b/src/app-plus/contexts/theme/useTheme.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { ThemeContext, ThemeType } from "./ThemeContext"; +import { useStore } from "@lib/hooks"; + +export const useThemeStore = (selector: (context: ThemeType) => S) => { + const store = useContext(ThemeContext); + if (store === undefined) { + throw new Error("useThemeContext must be used within an ThemeProvider"); + } + return useStore(store, selector); +}; diff --git a/src/app-plus/contexts/user/UserContext.ts b/src/app-plus/contexts/user/UserContext.ts new file mode 100644 index 0000000..083c735 --- /dev/null +++ b/src/app-plus/contexts/user/UserContext.ts @@ -0,0 +1,31 @@ +import { createStore, Store } from "@/storeUtils"; +import { User } from "@/types"; +import { createContext } from "react"; + +interface UserState { + user: User | null; +} + +interface UserAction { + login: (email: string, password: string) => void; + logout: () => void; +} + +export type UserType = UserState & UserAction; +export type UserStore = Store; + +export const userStore: UserStore = createStore((set) => ({ + user: null, + login: (email) => { + set((prev) => { + return { ...prev, user: { id: 1, name: "홍길동", email } }; + }); + }, + logout: () => { + set((prev) => { + return { ...prev, user: null }; + }); + }, +})); + +export const UserContext = createContext(undefined); diff --git a/src/app-plus/contexts/user/UserProvider.tsx b/src/app-plus/contexts/user/UserProvider.tsx new file mode 100644 index 0000000..d98d2d9 --- /dev/null +++ b/src/app-plus/contexts/user/UserProvider.tsx @@ -0,0 +1,17 @@ +import { useRef } from "@lib/hooks"; +import { PropsWithChildren } from "react"; +import { UserContext, userStore, UserStore } from "./UserContext"; + +export const UserProvider = ({ children }: PropsWithChildren) => { + const store = useRef(null); + + if (store.current === null) { + store.current = userStore; + } + + return ( + + {children} + + ); +}; diff --git a/src/app-plus/contexts/user/index.ts b/src/app-plus/contexts/user/index.ts new file mode 100644 index 0000000..311416e --- /dev/null +++ b/src/app-plus/contexts/user/index.ts @@ -0,0 +1,2 @@ +export * from "./UserProvider"; +export * from "./useUser"; diff --git a/src/app-plus/contexts/user/useUser.ts b/src/app-plus/contexts/user/useUser.ts new file mode 100644 index 0000000..7335edb --- /dev/null +++ b/src/app-plus/contexts/user/useUser.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { UserContext, UserType } from "./UserContext"; +import { useStore } from "@lib/hooks"; + +export const useUserStore = (selector: (context: UserType) => S) => { + const store = useContext(UserContext); + if (store === undefined) { + throw new Error("useUserContext must be used within an UserProvider"); + } + return useStore(store, selector); +}; From 372d821466d1fd3e9749a090e7a7c63e6b2f622b Mon Sep 17 00:00:00 2001 From: Geunbaek Date: Thu, 2 Jan 2025 22:15:44 +0900 Subject: [PATCH 29/29] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EC=96=B4=20=EC=83=9D=EC=84=B1=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/enhanced.test.tsx | 10 +------ src/__tests__/externalStore.test.tsx | 10 +------ .../notification/NotificationContext.ts | 2 +- .../notification/NotificationProvider.tsx | 4 +-- src/app-plus/contexts/theme/ThemeContext.tsx | 19 ++++++------- src/app-plus/contexts/theme/ThemeProvider.tsx | 4 +-- src/app-plus/contexts/user/UserContext.ts | 27 ++++++++++--------- src/app-plus/contexts/user/UserProvider.tsx | 4 +-- 8 files changed, 33 insertions(+), 47 deletions(-) diff --git a/src/__tests__/enhanced.test.tsx b/src/__tests__/enhanced.test.tsx index 00cd21a..dc47522 100644 --- a/src/__tests__/enhanced.test.tsx +++ b/src/__tests__/enhanced.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import * as utils from "../utils"; import App from "../app-enhanced/App"; @@ -13,14 +13,6 @@ describe("최적화된 App 컴포넌트 테스트", () => { generateItemsSpy.mockClear(); }); - afterEach(() => { - try { - screen.getAllByText("닫기").forEach((button) => fireEvent.click(button)); - } catch { - console.log("닫기 버튼 없음"); - } - }); - it("초기 렌더링 시 모든 컴포넌트가 한 번씩 렌더링되어야 한다", () => { render(); expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); diff --git a/src/__tests__/externalStore.test.tsx b/src/__tests__/externalStore.test.tsx index 18ccd66..87a20cd 100644 --- a/src/__tests__/externalStore.test.tsx +++ b/src/__tests__/externalStore.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import * as utils from "../utils"; import App from "../app-plus/App"; @@ -13,14 +13,6 @@ describe("최적화된 App 컴포넌트 테스트", () => { generateItemsSpy.mockClear(); }); - afterEach(() => { - try { - screen.getAllByText("닫기").forEach((button) => fireEvent.click(button)); - } catch { - console.log("닫기 버튼 없음"); - } - }); - it("초기 렌더링 시 모든 컴포넌트가 한 번씩 렌더링되어야 한다", () => { render(); expect(renderLogMock).toHaveBeenCalledWith("Header rendered"); diff --git a/src/app-plus/contexts/notification/NotificationContext.ts b/src/app-plus/contexts/notification/NotificationContext.ts index 6ee53b1..49ea9cb 100644 --- a/src/app-plus/contexts/notification/NotificationContext.ts +++ b/src/app-plus/contexts/notification/NotificationContext.ts @@ -14,7 +14,7 @@ interface NotificationAction { export type NotificationType = NotificationState & NotificationAction; export type NotificationStore = Store; -export const notificationStore: NotificationStore = +export const createNotificationStore: () => NotificationStore = () => createStore((set) => ({ notifications: [], addNotification: (message, type) => { diff --git a/src/app-plus/contexts/notification/NotificationProvider.tsx b/src/app-plus/contexts/notification/NotificationProvider.tsx index 4df67a3..26655ea 100644 --- a/src/app-plus/contexts/notification/NotificationProvider.tsx +++ b/src/app-plus/contexts/notification/NotificationProvider.tsx @@ -2,7 +2,7 @@ import { PropsWithChildren } from "react"; import { NotificationContext, NotificationStore, - notificationStore, + createNotificationStore, } from "./NotificationContext"; import { useRef } from "@lib/hooks"; @@ -10,7 +10,7 @@ export const NotificationProvider = ({ children }: PropsWithChildren) => { const store = useRef(null); if (store.current === null) { - store.current = notificationStore; + store.current = createNotificationStore(); } return ( diff --git a/src/app-plus/contexts/theme/ThemeContext.tsx b/src/app-plus/contexts/theme/ThemeContext.tsx index 2d4e86e..afcd0fc 100644 --- a/src/app-plus/contexts/theme/ThemeContext.tsx +++ b/src/app-plus/contexts/theme/ThemeContext.tsx @@ -12,14 +12,15 @@ interface ThemeAction { export type ThemeType = ThemeState & ThemeAction; export type ThemeStore = Store; -export const themeStore: ThemeStore = createStore((set) => ({ - theme: "light", - toggleTheme: () => { - set((prev) => ({ - ...prev, - theme: prev.theme === "light" ? "dark" : "light", - })); - }, -})); +export const createThemeStore: () => ThemeStore = () => + createStore((set) => ({ + theme: "light", + toggleTheme: () => { + set((prev) => ({ + ...prev, + theme: prev.theme === "light" ? "dark" : "light", + })); + }, + })); export const ThemeContext = createContext(undefined); diff --git a/src/app-plus/contexts/theme/ThemeProvider.tsx b/src/app-plus/contexts/theme/ThemeProvider.tsx index da10cdb..83b3a44 100644 --- a/src/app-plus/contexts/theme/ThemeProvider.tsx +++ b/src/app-plus/contexts/theme/ThemeProvider.tsx @@ -1,12 +1,12 @@ import { useRef } from "@lib/hooks"; import { PropsWithChildren } from "react"; -import { ThemeContext, themeStore, ThemeStore } from "./ThemeContext"; +import { ThemeContext, createThemeStore, ThemeStore } from "./ThemeContext"; export const ThemeProvider = ({ children }: PropsWithChildren) => { const store = useRef(null); if (store.current === null) { - store.current = themeStore; + store.current = createThemeStore(); } return ( diff --git a/src/app-plus/contexts/user/UserContext.ts b/src/app-plus/contexts/user/UserContext.ts index 083c735..f5024c1 100644 --- a/src/app-plus/contexts/user/UserContext.ts +++ b/src/app-plus/contexts/user/UserContext.ts @@ -14,18 +14,19 @@ interface UserAction { export type UserType = UserState & UserAction; export type UserStore = Store; -export const userStore: UserStore = createStore((set) => ({ - user: null, - login: (email) => { - set((prev) => { - return { ...prev, user: { id: 1, name: "홍길동", email } }; - }); - }, - logout: () => { - set((prev) => { - return { ...prev, user: null }; - }); - }, -})); +export const createUserStore: () => UserStore = () => + createStore((set) => ({ + user: null, + login: (email) => { + set((prev) => { + return { ...prev, user: { id: 1, name: "홍길동", email } }; + }); + }, + logout: () => { + set((prev) => { + return { ...prev, user: null }; + }); + }, + })); export const UserContext = createContext(undefined); diff --git a/src/app-plus/contexts/user/UserProvider.tsx b/src/app-plus/contexts/user/UserProvider.tsx index d98d2d9..43bb7cf 100644 --- a/src/app-plus/contexts/user/UserProvider.tsx +++ b/src/app-plus/contexts/user/UserProvider.tsx @@ -1,12 +1,12 @@ import { useRef } from "@lib/hooks"; import { PropsWithChildren } from "react"; -import { UserContext, userStore, UserStore } from "./UserContext"; +import { UserContext, createUserStore, UserStore } from "./UserContext"; export const UserProvider = ({ children }: PropsWithChildren) => { const store = useRef(null); if (store.current === null) { - store.current = userStore; + store.current = createUserStore(); } return (