diff --git a/packages/app/404.html b/packages/app/404.html new file mode 100644 index 00000000..a52cb5db --- /dev/null +++ b/packages/app/404.html @@ -0,0 +1,26 @@ + + + + + + 상품 쇼핑몰 + + + + + +
+ + + diff --git a/packages/app/src/components/toast/ToastProvider.tsx b/packages/app/src/components/toast/ToastProvider.tsx index 0aed9451..1b452bf2 100644 --- a/packages/app/src/components/toast/ToastProvider.tsx +++ b/packages/app/src/components/toast/ToastProvider.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-refresh/only-export-components */ -import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react"; +import { createContext, memo, type PropsWithChildren, useContext, useReducer, useMemo, useCallback } from "react"; import { createPortal } from "react-dom"; import { Toast } from "./Toast"; import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer"; @@ -8,45 +8,67 @@ import { debounce } from "../../utils"; type ShowToast = (message: string, type: ToastType) => void; type Hide = () => void; -const ToastContext = createContext<{ - message: string; - type: ToastType; +// Command Context +const ToastCommandContext = createContext<{ show: ShowToast; hide: Hide; }>({ - ...initialState, show: () => null, hide: () => null, }); +// State Context +const ToastStateContext = createContext<{ + message: string; + type: ToastType; +}>({ + ...initialState, +}); + const DEFAULT_DELAY = 3000; -const useToastContext = () => useContext(ToastContext); -export const useToastCommand = () => { - const { show, hide } = useToastContext(); - return { show, hide }; -}; -export const useToastState = () => { - const { message, type } = useToastContext(); - return { message, type }; -}; +export const useToastCommand = () => useContext(ToastCommandContext); +export const useToastState = () => useContext(ToastStateContext); export const ToastProvider = memo(({ children }: PropsWithChildren) => { const [state, dispatch] = useReducer(toastReducer, initialState); - const { show, hide } = createActions(dispatch); const visible = state.message !== ""; - const hideAfter = debounce(hide, DEFAULT_DELAY); + const actions = useMemo(() => createActions(dispatch), []); + const { show, hide } = actions; + + const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]); + + const showWithHide = useCallback( + (...args) => { + show(...args); + hideAfter(); + }, + [show, hideAfter], + ); - const showWithHide: ShowToast = (...args) => { - show(...args); - hideAfter(); - }; + const commandValue = useMemo( + () => ({ + show: showWithHide, + hide, + }), + [showWithHide, hide], + ); + + const stateValue = useMemo( + () => ({ + message: state.message, + type: state.type, + }), + [state.message, state.type], + ); return ( - - {children} - {visible && createPortal(, document.body)} - + + + {children} + {visible && createPortal(, document.body)} + + ); }); diff --git a/packages/lib/src/createObserver.ts b/packages/lib/src/createObserver.ts index fe97bf6b..efa16976 100644 --- a/packages/lib/src/createObserver.ts +++ b/packages/lib/src/createObserver.ts @@ -6,10 +6,8 @@ export const createObserver = () => { // useSyncExternalStore 에서 활용할 수 있도록 subscribe 함수를 수정합니다. const subscribe = (fn: Listener) => { listeners.add(fn); - }; - const unsubscribe = (fn: Listener) => { - listeners.delete(fn); + return () => listeners.delete(fn); }; const notify = () => listeners.forEach((listener) => listener()); diff --git a/packages/lib/src/equals/deepEquals.ts b/packages/lib/src/equals/deepEquals.ts index f5f4ebd5..022ae35d 100644 --- a/packages/lib/src/equals/deepEquals.ts +++ b/packages/lib/src/equals/deepEquals.ts @@ -1,3 +1,34 @@ -export const deepEquals = (a: unknown, b: unknown) => { - return a === b; -}; +/** + * 두 값의 깊은 비교를 수행합니다. (모든 중첩 레벨에서 재귀적으로 비교) + * + * @param objA - 비교할 첫 번째 값 (모든 타입 허용) + * @param objB - 비교할 두 번째 값 (모든 타입 허용) + * @returns 두 값이 깊은 수준에서 동일하면 true, 다르면 false + */ +export function deepEquals(objA: unknown, objB: unknown): boolean { + // 1. 참조 동일성 체크 + if (objA === objB) return true; + + // 2. 타입 및 null 검증 + if (typeof objA !== typeof objB) return false; + if (objA === null || objB === null) return false; + if (typeof objA !== "object") return false; + + // 3. 배열 타입 일치성 확인 + if (Array.isArray(objA) !== Array.isArray(objB)) return false; + + const recordA = objA as Record; + const recordB = objB as Record; + + // 4. 키 개수 비교 + const keysA = Object.keys(recordA); + if (keysA.length !== Object.keys(recordB).length) return false; + + // 5. 키별 재귀적 깊은 비교 + for (const key of keysA) { + if (!(key in recordB)) return false; + if (!deepEquals(recordA[key], recordB[key])) return false; + } + + return true; +} diff --git a/packages/lib/src/equals/shallowEquals.ts b/packages/lib/src/equals/shallowEquals.ts index 1cf22130..58558aee 100644 --- a/packages/lib/src/equals/shallowEquals.ts +++ b/packages/lib/src/equals/shallowEquals.ts @@ -1,3 +1,31 @@ -export const shallowEquals = (a: unknown, b: unknown) => { - return a === b; -}; +/** + * 두 값의 얕은 비교를 수행합니다. (1단계 깊이만 비교) + * + * @param objA - 비교할 첫 번째 값 (모든 타입 허용) + * @param objB - 비교할 두 번째 값 (모든 타입 허용) + * @returns 두 값이 얕은 수준에서 동일하면 true, 다르면 false + */ +export function shallowEquals(objA: unknown, objB: unknown): boolean { + // 1. 참조 동일성 체크 + if (objA === objB) return true; + + // 2. 타입 및 null 검증 + if (typeof objA !== typeof objB) return false; + if (objA === null || objB === null) return false; + if (typeof objA !== "object") return false; + + const recordA = objA as Record; + const recordB = objB as Record; + + // 3. 키 개수 비교 + const keysA = Object.keys(recordA); + if (keysA.length !== Object.keys(recordB).length) return false; + + // 4. 키별 얕은 비교 + for (const key of keysA) { + if (!(key in recordB)) return false; + if (recordA[key] !== recordB[key]) return false; + } + + return true; +} diff --git a/packages/lib/src/hocs/deepMemo.ts b/packages/lib/src/hocs/deepMemo.ts index 13f7f18e..1235647c 100644 --- a/packages/lib/src/hocs/deepMemo.ts +++ b/packages/lib/src/hocs/deepMemo.ts @@ -1,5 +1,12 @@ -import type { FunctionComponent } from "react"; +import { memo, type FunctionComponent } from "react"; +import { deepEquals } from "../equals"; +/** + * 컴포넌트의 props를 깊은 비교하여 불필요한 리렌더링을 방지하는 HOC입니다. + * + * @param Component - 메모이제이션할 함수형 컴포넌트 + * @returns 메모이제이션된 컴포넌트 + */ export function deepMemo

(Component: FunctionComponent

) { - return Component; + return memo(Component, deepEquals); } diff --git a/packages/lib/src/hocs/memo.ts b/packages/lib/src/hocs/memo.ts index 532c3a5c..91eb3227 100644 --- a/packages/lib/src/hocs/memo.ts +++ b/packages/lib/src/hocs/memo.ts @@ -1,6 +1,27 @@ -import { type FunctionComponent } from "react"; +import { type FunctionComponent, type ReactNode } from "react"; import { shallowEquals } from "../equals"; +import { useRef } from "../hooks/useRef"; -export function memo

(Component: FunctionComponent

, equals = shallowEquals) { - return Component; +/** + * 컴포넌트의 props를 얕은 비교하여 불필요한 리렌더링을 방지 하는 HOC입니다. + * + * @param Component - 메모이제이션할 함수형 컴포넌트 + * @param equals - props 비교에 사용할 함수 (기본값: shallowEquals) + * @returns 메모이제이션된 컴포넌트 + */ +export function memo

(Component: FunctionComponent

, equals = shallowEquals): FunctionComponent

{ + return function MemoizedComponent(props: P) { + // 이전 props와 렌더링 결과를 저장할 ref들 + const prevPropsRef = useRef

(null); + const memoizedResultRef = useRef | null>(null); + + // 첫 렌더링이거나 props가 변경된 경우에만 새로 렌더링 + if (prevPropsRef.current === null || !equals(prevPropsRef.current, props)) { + memoizedResultRef.current = Component(props); + prevPropsRef.current = props; + } + + // 캐시된 결과 반환 + return memoizedResultRef.current!; + }; } diff --git a/packages/lib/src/hooks/useAutoCallback.ts b/packages/lib/src/hooks/useAutoCallback.ts index 23100162..98dffd33 100644 --- a/packages/lib/src/hooks/useAutoCallback.ts +++ b/packages/lib/src/hooks/useAutoCallback.ts @@ -2,6 +2,22 @@ import type { AnyFunction } from "../types"; import { useCallback } from "./useCallback"; import { useRef } from "./useRef"; +/** + * 항상 최신 클로저를 참조하면서도 안정된 함수 참조를 제공하는 훅입니다. + * + * @param fn - 메모이제이션할 함수 + * @returns 항상 동일한 참조를 가지지만 최신 클로저를 사용하는 함수 + */ export const useAutoCallback = (fn: T): T => { - return fn; + // 최신 함수를 저장할 ref + const fnRef = useRef(fn); + + // 매 렌더링마다 최신 함수로 업데이트 + fnRef.current = fn; + + // 항상 동일한 참조를 가진 wrapper 함수 반환 + return useCallback((...args: Parameters) => { + // 호출 시점에 최신 함수 실행 + return fnRef.current(...args); + }, []) as T; }; diff --git a/packages/lib/src/hooks/useCallback.ts b/packages/lib/src/hooks/useCallback.ts index 712bc898..b4b406ec 100644 --- a/packages/lib/src/hooks/useCallback.ts +++ b/packages/lib/src/hooks/useCallback.ts @@ -1,7 +1,15 @@ /* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-unsafe-function-type */ import type { DependencyList } from "react"; +import { useMemo } from "./useMemo"; -export function useCallback(factory: T, _deps: DependencyList) { - // 직접 작성한 useMemo를 통해서 만들어보세요. - return factory as T; +/** + * 함수를 메모이제이션합니다. + * + * @param factory - 메모이제이션할 함수 + * @param deps - 의존성 배열 + * @returns 메모이제이션된 함수 + */ +export function useCallback(factory: T, deps: DependencyList): T { + // useMemo를 사용해서 함수 자체를 메모이제이션 + return useMemo(() => factory, deps); } diff --git a/packages/lib/src/hooks/useMemo.ts b/packages/lib/src/hooks/useMemo.ts index e80692e2..b99fecef 100644 --- a/packages/lib/src/hooks/useMemo.ts +++ b/packages/lib/src/hooks/useMemo.ts @@ -1,8 +1,26 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import type { DependencyList } from "react"; import { shallowEquals } from "../equals"; +import { useRef } from "./useRef"; -export function useMemo(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T { - // 직접 작성한 useRef를 통해서 만들어보세요. - return factory(); +/** + * 계산 비용이 높은 값을 메모이제이션합니다. + * + * @param factory - 메모이제이션할 값을 계산하는 함수 + * @param deps - 의존성 배열 + * @param equals - 의존성 비교에 사용할 함수 (기본값: shallowEquals) + * @returns 메모이제이션된 값 + */ +export function useMemo(factory: () => T, deps: DependencyList, equals = shallowEquals): T { + // 이전 의존성과 계산 결과를 저장할 ref + const ref = useRef<{ deps: DependencyList; value: T } | null>(null); + + // 첫 호출이거나 의존성이 변경된 경우 재계산 + if (ref.current === null || !equals(ref.current.deps, deps)) { + const value = factory(); + ref.current = { deps, value }; // 새 의존성과 결과 저장 + } + + // 메모이제이션된 값 반환 + return ref.current.value; } diff --git a/packages/lib/src/hooks/useRef.ts b/packages/lib/src/hooks/useRef.ts index 285d4ae7..3af6d1d0 100644 --- a/packages/lib/src/hooks/useRef.ts +++ b/packages/lib/src/hooks/useRef.ts @@ -1,4 +1,12 @@ +import { useState } from "react"; + +/** + * 렌더링 사이에 값을 유지하는 가변 ref 객체를 생성합니다. + * + * @param initialValue - ref 객체의 초기값 (모든 타입 허용) + * @returns current 속성을 가진 가변 객체 ({ current: T }) + */ export function useRef(initialValue: T): { current: T } { - // useState를 이용해서 만들어보세요. - return { current: initialValue }; + const [ref] = useState(() => ({ current: initialValue })); + return ref; } diff --git a/packages/lib/src/hooks/useRouter.ts b/packages/lib/src/hooks/useRouter.ts index a18ae01f..835ea179 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -5,8 +5,20 @@ import { useShallowSelector } from "./useShallowSelector"; const defaultSelector = (state: T) => state as unknown as S; +/** + * router의 상태를 구독하고 선택된 값을 반환하는 Hook입니다. + * + * @template T RouterInstance의 타입 + * @template S selector 결과의 타입 + * @param router Router 인스턴스 + * @param selector router에서 값을 선택하는 함수 (기본: 전체 router) + * @returns 선택된 router 상태 값 + */ export const useRouter = , S>(router: T, selector = defaultSelector) => { - // useSyncExternalStore를 사용하여 router의 상태를 구독하고 가져오는 훅을 구현합니다. const shallowSelector = useShallowSelector(selector); - return shallowSelector(router); + + return useSyncExternalStore( + router.subscribe, // 구독 등록 + () => shallowSelector(router), // 현재 경로 반환 + ); }; diff --git a/packages/lib/src/hooks/useShallowSelector.ts b/packages/lib/src/hooks/useShallowSelector.ts index b75ed791..88788f89 100644 --- a/packages/lib/src/hooks/useShallowSelector.ts +++ b/packages/lib/src/hooks/useShallowSelector.ts @@ -1,9 +1,30 @@ -import { useRef } from "react"; +import { useCallback, useRef } from "react"; import { shallowEquals } from "../equals"; type Selector = (state: T) => S; +/** + * shallow comparison을 사용하여 selector 함수를 메모이제이션하는 Hook입니다. + * + * @template T 입력 상태의 타입 + * @template S selector 결과의 타입 + * @param selector 상태에서 값을 선택하는 함수 + * @returns 메모이제이션된 selector 함수 + */ export const useShallowSelector = (selector: Selector) => { - // 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅을 구현합니다. - return (state: T): S => selector(state); + const prevResult = useRef(undefined); + + return useCallback( + (state: T): S => { + const nextResult = selector(state); + + // 첫 호출이거나 내용이 다른 경우에만 업데이트 + if (prevResult.current === undefined || !shallowEquals(prevResult.current, nextResult)) { + prevResult.current = nextResult; + } + + return prevResult.current; + }, + [selector], + ); }; diff --git a/packages/lib/src/hooks/useShallowState.ts b/packages/lib/src/hooks/useShallowState.ts index 7b589748..dce5b665 100644 --- a/packages/lib/src/hooks/useShallowState.ts +++ b/packages/lib/src/hooks/useShallowState.ts @@ -1,7 +1,37 @@ -import { useState } from "react"; +import { useState, useCallback } from "react"; import { shallowEquals } from "../equals"; -export const useShallowState = (initialValue: Parameters>[0]) => { - // useState를 사용하여 상태를 관리하고, shallowEquals를 사용하여 상태 변경을 감지하는 훅을 구현합니다. - return useState(initialValue); +/** + * 얕은 비교를 통해 불필요한 리렌더링을 방지하는 useState의 개선 버전입니다. + * + * 새로운 상태값이 이전 상태값과 얕은 비교(shallowEquals)에서 동일하다면 + * 리렌더링을 발생시키지 않습니다. + * + * @param initialValue - 초기 상태값 또는 초기값을 반환하는 함수 + * @returns [state, setState] 튜플 + * - state: 현재 상태값 + * - setState: 상태 업데이트 함수 (직접 값 또는 함수형 업데이트 지원) + */ +export const useShallowState = (initialValue: T | (() => T)) => { + const [state, setState] = useState(initialValue); + + const setShallowState = useCallback((newValue: T | ((prev: T) => T)) => { + setState((prevState) => { + let nextState: T; + + // newValue 타입에 따라 분기 처리 + if (typeof newValue === "function") { + // 1. 함수형 업데이트: prevState를 인자로 전달하여 새로운 상태 계산 + nextState = (newValue as (prev: T) => T)(prevState); + } else { + // 2. 직접 값: 그대로 사용 + nextState = newValue; + } + + // 얕은 비교로 실제 변경 여부 확인 + return shallowEquals(prevState, nextState) ? prevState : nextState; + }); + }, []); + + return [state, setShallowState] as const; }; diff --git a/packages/lib/src/hooks/useStorage.ts b/packages/lib/src/hooks/useStorage.ts index fdc97a6f..4fc39c1a 100644 --- a/packages/lib/src/hooks/useStorage.ts +++ b/packages/lib/src/hooks/useStorage.ts @@ -3,7 +3,16 @@ import type { createStorage } from "../createStorage"; type Storage = ReturnType>; +/** + * storage의 상태를 구독하고 현재 값을 반환하는 Hook입니다. + * + * @template T storage에 저장되는 값의 타입 + * @param storage createStorage로 생성된 storage 인스턴스 + * @returns 현재 storage 값 (T | null) + */ export const useStorage = (storage: Storage) => { - // useSyncExternalStore를 사용해서 storage의 상태를 구독하고 가져오는 훅을 구현해보세요. - return storage.get(); + return useSyncExternalStore( + storage.subscribe, // 구독 함수 + storage.get, // getSnapshot 함수 + ); }; diff --git a/packages/lib/src/hooks/useStore.ts b/packages/lib/src/hooks/useStore.ts index acf3ad79..9e2565ae 100644 --- a/packages/lib/src/hooks/useStore.ts +++ b/packages/lib/src/hooks/useStore.ts @@ -6,8 +6,20 @@ type Store = ReturnType>; const defaultSelector = (state: T) => state as unknown as S; +/** + * store의 상태를 구독하고 선택된 값을 반환하는 Hook입니다. + * + * @template T store 상태의 타입 + * @template S selector 결과의 타입 + * @param store createStore로 생성된 store 인스턴스 + * @param selector 상태에서 값을 선택하는 함수 (기본: 전체 상태) + * @returns 선택된 상태 값 (shallow comparison으로 최적화됨) + */ export const useStore = (store: Store, selector: (state: T) => S = defaultSelector) => { - // useSyncExternalStore와 useShallowSelector를 사용해서 store의 상태를 구독하고 가져오는 훅을 구현해보세요. const shallowSelector = useShallowSelector(selector); - return shallowSelector(store.getState()); + + return useSyncExternalStore( + store.subscribe, // 구독 함수 + () => shallowSelector(store.getState()), // getSnapshot 함수 + ); };