diff --git a/packages/app/src/components/toast/ToastProvider.tsx b/packages/app/src/components/toast/ToastProvider.tsx index 0aed9451..30945ca9 100644 --- a/packages/app/src/components/toast/ToastProvider.tsx +++ b/packages/app/src/components/toast/ToastProvider.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-refresh/only-export-components */ -import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react"; +import { createContext, useContext, useReducer, type PropsWithChildren } from "react"; +import { useAutoCallback, useMemo } from "@hanghae-plus/lib"; import { createPortal } from "react-dom"; import { Toast } from "./Toast"; import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer"; @@ -8,45 +9,50 @@ import { debounce } from "../../utils"; type ShowToast = (message: string, type: ToastType) => void; type Hide = () => void; -const ToastContext = createContext<{ - message: string; - type: ToastType; +// 명령 context: show/hide만 제공 +const ToastCommandContext = createContext<{ show: ShowToast; hide: Hide; }>({ - ...initialState, show: () => null, hide: () => null, }); +// 상태 context: message/type만 제공 +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) => { +export const ToastProvider = ({ children }: PropsWithChildren) => { const [state, dispatch] = useReducer(toastReducer, initialState); - const { show, hide } = createActions(dispatch); - const visible = state.message !== ""; - - const hideAfter = debounce(hide, DEFAULT_DELAY); - const showWithHide: ShowToast = (...args) => { + const show = useAutoCallback((...args: Parameters) => createActions(dispatch).show(...args)); + const hide = useAutoCallback(() => createActions(dispatch).hide()); + const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]); + const showWithHide: ShowToast = useAutoCallback((...args: Parameters) => { show(...args); hideAfter(); - }; + }); + + // 명령 context value + const commandValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]); + // 상태 context value + const stateValue = useMemo(() => ({ message: state.message, type: state.type }), [state.message, state.type]); return ( - - {children} - {visible && createPortal(, document.body)} - + + + {children} + {state.message !== "" && createPortal(, document.body)} + + ); -}); +}; diff --git a/packages/app/src/entities/carts/cartStore.ts b/packages/app/src/entities/carts/cartStore.ts index a1305c1e..0c2a2809 100644 --- a/packages/app/src/entities/carts/cartStore.ts +++ b/packages/app/src/entities/carts/cartStore.ts @@ -132,7 +132,7 @@ const cartReducer = (state: typeof initialState, action: any) => { case CART_ACTIONS.LOAD_FROM_STORAGE: return { ...state, - ...action.payload, + ...(action.payload || {}), }; default: diff --git a/packages/app/src/entities/carts/components/CartItem.tsx b/packages/app/src/entities/carts/components/CartItem.tsx index 314b5983..42047aa5 100644 --- a/packages/app/src/entities/carts/components/CartItem.tsx +++ b/packages/app/src/entities/carts/components/CartItem.tsx @@ -2,7 +2,7 @@ import { removeFromCart, toggleCartSelect, updateCartQuantity } from "../cartUse import { PublicImage } from "../../../components"; import type { Cart } from "../types"; import { useCartStoreSelector } from "../hooks"; -import { useMemo } from "react"; +import { useMemo } from "@hanghae-plus/lib"; export function CartItem({ id }: Readonly>) { const selector = useMemo( diff --git a/packages/lib/src/createObserver.ts b/packages/lib/src/createObserver.ts index fe97bf6b..1f1c29de 100644 --- a/packages/lib/src/createObserver.ts +++ b/packages/lib/src/createObserver.ts @@ -3,13 +3,9 @@ type Listener = () => void; export const createObserver = () => { const listeners = new Set(); - // 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/createStorage.ts b/packages/lib/src/createStorage.ts index fdf2986c..b1b5f5ea 100644 --- a/packages/lib/src/createStorage.ts +++ b/packages/lib/src/createStorage.ts @@ -1,15 +1,33 @@ import { createObserver } from "./createObserver.ts"; export const createStorage = (key: string, storage = window.localStorage) => { - let data: T | null = JSON.parse(storage.getItem(key) ?? "null"); + let data: T | null; + try { + const storedValue = storage.getItem(key); + if (storedValue === null) { + console.log(`[createStorage] No stored value for ${key}, initializing as null`); + data = null; + } else { + data = JSON.parse(storedValue); + console.log(`[createStorage] Successfully loaded ${key}:`, data); + } + } catch (error) { + console.error(`[createStorage] Error parsing data for ${key}:`, error); + data = null; + } + const { subscribe, notify } = createObserver(); - const get = () => data; + const get = () => { + return data; + }; const set = (value: T) => { try { data = value; - storage.setItem(key, JSON.stringify(data)); + const serialized = JSON.stringify(data); + storage.setItem(key, serialized); + console.log(`[createStorage] Successfully stored ${key}`); notify(); } catch (error) { console.error(`Error setting storage item for key "${key}":`, error); @@ -20,6 +38,7 @@ export const createStorage = (key: string, storage = window.localStorage) => try { data = null; storage.removeItem(key); + console.log(`[createStorage] Successfully removed ${key} from storage`); notify(); } catch (error) { console.error(`Error removing storage item for key "${key}":`, error); diff --git a/packages/lib/src/createStore.ts b/packages/lib/src/createStore.ts index c2e854b4..3b209197 100644 --- a/packages/lib/src/createStore.ts +++ b/packages/lib/src/createStore.ts @@ -1,4 +1,5 @@ import { createObserver } from "./createObserver"; +import { shallowEquals } from "./equals"; export const createStore = S>( reducer: (state: S, action: A) => S, @@ -12,7 +13,7 @@ export const createStore = { const newState = reducer(state, action); - if (!Object.is(newState, state)) { + if (!shallowEquals(newState, state)) { state = newState; notify(); } diff --git a/packages/lib/src/equals/baseEquals.ts b/packages/lib/src/equals/baseEquals.ts new file mode 100644 index 00000000..b772a036 --- /dev/null +++ b/packages/lib/src/equals/baseEquals.ts @@ -0,0 +1,33 @@ +import { isNullish, isPrimitive, isArray, isObject, isSameType } from "../utils"; + +type ComparisonCallback = (a: unknown, b: unknown) => boolean; + +export const baseEquals = (a: unknown, b: unknown, compareValues: ComparisonCallback): boolean => { + if (a === b) return true; + if (isNullish(a) || isNullish(b)) return false; + if (isPrimitive(a) || isPrimitive(b)) return false; + if (!isSameType(a, b)) return false; + + if (isArray(a) && isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!compareValues(a[i], b[i])) return false; + } + return true; + } + + if (isObject(a) && isObject(b)) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + + for (const key of keysA) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (!compareValues(a[key], b[key])) return false; + } + return true; + } + + return false; +}; diff --git a/packages/lib/src/equals/deepEquals.ts b/packages/lib/src/equals/deepEquals.ts index f5f4ebd5..6e04d48b 100644 --- a/packages/lib/src/equals/deepEquals.ts +++ b/packages/lib/src/equals/deepEquals.ts @@ -1,3 +1,6 @@ -export const deepEquals = (a: unknown, b: unknown) => { - return a === b; +import { baseEquals } from "./baseEquals"; + +export const deepEquals = (a: unknown, b: unknown): boolean => { + // baseEquals를 사용하여 공통 로직 처리, 값 비교는 깊은 비교(재귀 호출) + return baseEquals(a, b, (valueA, valueB) => deepEquals(valueA, valueB)); }; diff --git a/packages/lib/src/equals/index.ts b/packages/lib/src/equals/index.ts index 70e42707..48dc24cb 100644 --- a/packages/lib/src/equals/index.ts +++ b/packages/lib/src/equals/index.ts @@ -1,2 +1,3 @@ +export * from "./baseEquals"; export * from "./shallowEquals"; export * from "./deepEquals"; diff --git a/packages/lib/src/equals/shallowEquals.ts b/packages/lib/src/equals/shallowEquals.ts index 1cf22130..23687e52 100644 --- a/packages/lib/src/equals/shallowEquals.ts +++ b/packages/lib/src/equals/shallowEquals.ts @@ -1,3 +1,5 @@ +import { baseEquals } from "./baseEquals"; + export const shallowEquals = (a: unknown, b: unknown) => { - return a === b; + return baseEquals(a, b, (valueA, valueB) => valueA === valueB); }; diff --git a/packages/lib/src/hocs/deepMemo.ts b/packages/lib/src/hocs/deepMemo.ts index 13f7f18e..4e5b9aa1 100644 --- a/packages/lib/src/hocs/deepMemo.ts +++ b/packages/lib/src/hocs/deepMemo.ts @@ -1,5 +1,8 @@ import type { FunctionComponent } from "react"; +import { memo } from "./memo"; +import { deepEquals } from "../equals"; +// deepMemo HOC는 컴포넌트의 props를 깊은 비교하여 불필요한 리렌더링을 방지합니다. 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..d899a58f 100644 --- a/packages/lib/src/hocs/memo.ts +++ b/packages/lib/src/hocs/memo.ts @@ -1,6 +1,23 @@ import { type FunctionComponent } from "react"; import { shallowEquals } from "../equals"; +import { useRef } from "../hooks/useRef"; export function memo

(Component: FunctionComponent

, equals = shallowEquals) { - return Component; + return function MemoizedComponent(props: P) { + // 1. 이전 props와 결과 저장 (커스텀 useRef 사용) + const prevPropsRef = useRef

(null); + const prevResultRef = useRef | null>(null); + + // 2. props가 변경됐는지 비교 + const isSame = prevPropsRef.current !== null && equals(prevPropsRef.current, props); + + if (!isSame) { + // 3. 변경된 경우: 새로 렌더링 + prevPropsRef.current = props; + prevResultRef.current = Component(props); + } + + // 4. 변경되지 않은 경우: 이전 결과 재사용 + return prevResultRef.current; + }; } diff --git a/packages/lib/src/hooks/useAutoCallback.ts b/packages/lib/src/hooks/useAutoCallback.ts index 23100162..effaa37c 100644 --- a/packages/lib/src/hooks/useAutoCallback.ts +++ b/packages/lib/src/hooks/useAutoCallback.ts @@ -3,5 +3,13 @@ import { useCallback } from "./useCallback"; import { useRef } from "./useRef"; export const useAutoCallback = (fn: T): T => { - return fn; + const fnRef = useRef(fn); + fnRef.current = fn; + + return useCallback( + ((...args: unknown[]) => { + return fnRef.current(...args); + }) as T, + [], + ); }; diff --git a/packages/lib/src/hooks/useCallback.ts b/packages/lib/src/hooks/useCallback.ts index 712bc898..5f84215c 100644 --- a/packages/lib/src/hooks/useCallback.ts +++ b/packages/lib/src/hooks/useCallback.ts @@ -1,7 +1,16 @@ -/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-unsafe-function-type */ import type { DependencyList } from "react"; +import { useRef } from "./useRef"; +import { shallowEquals } from "../equals"; -export function useCallback(factory: T, _deps: DependencyList) { - // 직접 작성한 useMemo를 통해서 만들어보세요. - return factory as T; +export function useCallback(factory: T, deps: DependencyList): T { + const callbackRef = useRef(factory); + const depsRef = useRef(deps); + + // 의존성이 변경되었는지 확인 + if (!shallowEquals(deps, depsRef.current)) { + callbackRef.current = factory; + depsRef.current = deps; + } + + return callbackRef.current; } diff --git a/packages/lib/src/hooks/useMemo.ts b/packages/lib/src/hooks/useMemo.ts index e80692e2..c669bda1 100644 --- a/packages/lib/src/hooks/useMemo.ts +++ b/packages/lib/src/hooks/useMemo.ts @@ -1,8 +1,36 @@ -/* 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(); +type MemoState = { deps: DependencyList | undefined; value: T } | null; +type EqualsFn = (a: unknown, b: unknown) => boolean; +type Factory = () => T; + +const isFirstRender = (state: MemoState): boolean => state === null; + +const hasDepsChanged = (state: MemoState, newDeps: DependencyList, equals: EqualsFn): boolean => + state !== null && !equals(state.deps, newDeps); + +const shouldRecalculate = (state: MemoState, deps: DependencyList, equals: EqualsFn): boolean => + isFirstRender(state) || hasDepsChanged(state, deps, equals); + +const createMemoState = (deps: DependencyList, value: T): MemoState => ({ + deps, + value, +}); + +const calculateAndCache = (factory: Factory, deps: DependencyList, stateRef: { current: MemoState }): T => { + const value = factory(); + stateRef.current = createMemoState(deps, value); + return value; +}; + +const getCachedValue = (state: MemoState): T => state!.value; + +export function useMemo(factory: Factory, deps: DependencyList, equals: EqualsFn = shallowEquals): T { + const memoRef = useRef>(null); + + return shouldRecalculate(memoRef.current, deps, equals) + ? calculateAndCache(factory, deps, memoRef) + : getCachedValue(memoRef.current); } diff --git a/packages/lib/src/hooks/useRef.ts b/packages/lib/src/hooks/useRef.ts index 285d4ae7..6df289fc 100644 --- a/packages/lib/src/hooks/useRef.ts +++ b/packages/lib/src/hooks/useRef.ts @@ -1,4 +1,7 @@ +import { useState } from "react"; + 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..75557189 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -6,7 +6,11 @@ import { useShallowSelector } from "./useShallowSelector"; const defaultSelector = (state: T) => state as unknown as S; export const useRouter = , S>(router: T, selector = defaultSelector) => { - // useSyncExternalStore를 사용하여 router의 상태를 구독하고 가져오는 훅을 구현합니다. const shallowSelector = useShallowSelector(selector); - return shallowSelector(router); + + return useSyncExternalStore( + (onStoreChange) => router.subscribe(onStoreChange), + () => shallowSelector(router), + () => shallowSelector(router), + ); }; diff --git a/packages/lib/src/hooks/useShallowSelector.ts b/packages/lib/src/hooks/useShallowSelector.ts index b75ed791..57eb4537 100644 --- a/packages/lib/src/hooks/useShallowSelector.ts +++ b/packages/lib/src/hooks/useShallowSelector.ts @@ -1,9 +1,21 @@ -import { useRef } from "react"; +import { useRef } from "./useRef"; import { shallowEquals } from "../equals"; type Selector = (state: T) => S; export const useShallowSelector = (selector: Selector) => { - // 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅을 구현합니다. - return (state: T): S => selector(state); + const prevValueRef = useRef(null); + + return (state: T): S => { + const newValue = selector(state); + + // 이전 값과 비교하여 변경되었는지 확인 + if (prevValueRef.current !== null && shallowEquals(prevValueRef.current, newValue)) { + return prevValueRef.current; + } + + // 값이 변경되었으면 새로운 값으로 업데이트 + prevValueRef.current = newValue; + return newValue; + }; }; diff --git a/packages/lib/src/hooks/useShallowState.ts b/packages/lib/src/hooks/useShallowState.ts index 7b589748..1e7ed15c 100644 --- a/packages/lib/src/hooks/useShallowState.ts +++ b/packages/lib/src/hooks/useShallowState.ts @@ -1,7 +1,27 @@ import { useState } from "react"; import { shallowEquals } from "../equals"; +import { useCallback } from "./useCallback"; +/** + * useShallowState는 얕은 비교를 통해 불필요한 리렌더링을 방지하는 커스텀 훅 + * 역할 + * - 객체 상태에서 실제 값이 변경되지 않았을 때 리렌더링 방지 + * - 폼 상태 관리에서 성능 최적화 + * - 복잡한 객체의 얕은 속성만 관리할 때 + */ +export const useShallowState = (initialValue: T | (() => T)) => { + const [state, setState] = useState(initialValue); -export const useShallowState = (initialValue: Parameters>[0]) => { - // useState를 사용하여 상태를 관리하고, shallowEquals를 사용하여 상태 변경을 감지하는 훅을 구현합니다. - return useState(initialValue); + const setShallowState = useCallback((newValue: T | ((prevState: T) => T)) => { + setState((prevState) => { + const nextValue = typeof newValue === "function" ? (newValue as (prevState: T) => T)(prevState) : newValue; + + if (shallowEquals(prevState, nextValue)) { + return prevState; + } + + return nextValue; + }); + }, []); + + return [state, setShallowState] as const; }; diff --git a/packages/lib/src/hooks/useStorage.ts b/packages/lib/src/hooks/useStorage.ts index fdc97a6f..c5a23674 100644 --- a/packages/lib/src/hooks/useStorage.ts +++ b/packages/lib/src/hooks/useStorage.ts @@ -4,6 +4,5 @@ import type { createStorage } from "../createStorage"; type Storage = ReturnType>; export const useStorage = (storage: Storage) => { - // useSyncExternalStore를 사용해서 storage의 상태를 구독하고 가져오는 훅을 구현해보세요. - return storage.get(); + return useSyncExternalStore((onStoreChange) => storage.subscribe(onStoreChange), storage.get, storage.get); }; diff --git a/packages/lib/src/hooks/useStore.ts b/packages/lib/src/hooks/useStore.ts index acf3ad79..6c902b8f 100644 --- a/packages/lib/src/hooks/useStore.ts +++ b/packages/lib/src/hooks/useStore.ts @@ -7,7 +7,11 @@ type Store = ReturnType>; const defaultSelector = (state: T) => state as unknown as S; export const useStore = (store: Store, selector: (state: T) => S = defaultSelector) => { - // useSyncExternalStore와 useShallowSelector를 사용해서 store의 상태를 구독하고 가져오는 훅을 구현해보세요. const shallowSelector = useShallowSelector(selector); - return shallowSelector(store.getState()); + + return useSyncExternalStore( + (onStoreChange) => store.subscribe(onStoreChange), + () => shallowSelector(store.getState()), + () => shallowSelector(store.getState()), + ); }; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 74605597..9c56fb47 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -2,6 +2,7 @@ export * from "./createObserver"; export * from "./createStorage"; export * from "./createStore"; export * from "./Router"; -export { useStore, useStorage, useRouter, useAutoCallback } from "./hooks"; +export { useStore, useStorage, useRouter, useAutoCallback, useCallback, useMemo, useRef } from "./hooks"; export * from "./equals"; export * from "./types"; +export * from "./utils"; diff --git a/packages/lib/src/utils/index.ts b/packages/lib/src/utils/index.ts new file mode 100644 index 00000000..c1122eb9 --- /dev/null +++ b/packages/lib/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./typeGuards"; diff --git a/packages/lib/src/utils/typeGuards.ts b/packages/lib/src/utils/typeGuards.ts new file mode 100644 index 00000000..c1c13753 --- /dev/null +++ b/packages/lib/src/utils/typeGuards.ts @@ -0,0 +1,47 @@ +/** + * 값이 null 또는 undefined인지 확인하는 타입가드 + */ +export const isNullish = (value: unknown): value is null | undefined => { + return value === null || value === undefined; +}; + +/** + * 값이 primitive 타입인지 확인하는 타입가드 + */ +export const isPrimitive = ( + value: unknown, +): value is string | number | boolean | null | undefined | symbol | bigint => { + return value === null || typeof value !== "object"; +}; + +/** + * 값이 객체 타입인지 확인하는 타입가드 (null 제외) + */ +export const isObject = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +/** + * 값이 배열인지 확인하는 타입가드 + */ +export const isArray = (value: unknown): value is unknown[] => { + return Array.isArray(value); +}; + +/** + * 값이 객체 또는 배열인지 확인하는 타입가드 + */ +export const isObjectLike = (value: unknown): value is Record | unknown[] => { + return typeof value === "object" && value !== null; +}; + +/** + * 두 값의 타입이 같은지 확인하는 헬퍼 함수 + */ +export const isSameType = (a: unknown, b: unknown): boolean => { + if (isNullish(a) && isNullish(b)) return true; + if (isPrimitive(a) && isPrimitive(b)) return typeof a === typeof b; + if (isArray(a) && isArray(b)) return true; + if (isObject(a) && isObject(b)) return true; + return false; +};