diff --git a/packages/app/index.html b/packages/app/index.html index 7e663448..2da6c307 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -18,6 +18,7 @@ } }; +
diff --git a/packages/app/src/components/toast/ToastProvider.tsx b/packages/app/src/components/toast/ToastProvider.tsx index 0aed9451..0af01d3c 100644 --- a/packages/app/src/components/toast/ToastProvider.tsx +++ b/packages/app/src/components/toast/ToastProvider.tsx @@ -1,52 +1,77 @@ -/* eslint-disable react-refresh/only-export-components */ import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react"; import { createPortal } from "react-dom"; import { Toast } from "./Toast"; -import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer"; +import { initialState, toastReducer, type ToastType, useToastActions } from "./toastReducer"; import { debounce } from "../../utils"; +import { useAutoCallback } from "@hanghae-plus/lib"; +import { useMemo } from "@hanghae-plus/lib/src/hooks"; type ShowToast = (message: string, type: ToastType) => void; type Hide = () => void; -const ToastContext = createContext<{ - message: string; - type: ToastType; +// toast command를 관리하는 context +const ToastCommandContext = createContext<{ show: ShowToast; hide: Hide; }>({ - ...initialState, show: () => null, hide: () => null, }); +// toast 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 }; -}; +// context를 사용하는 함수 +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); + // action을 생성 + const { show, hide } = useToastActions(dispatch); - const showWithHide: ShowToast = (...args) => { + // hide 함수를 3초 후에 호출 + const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]); + + // show 함수에 hideAfter 함수를 추가 + const showWithHide: ShowToast = useAutoCallback((...args) => { show(...args); hideAfter(); - }; + }); + + // Command context value를 메모이제이션 + const commandValue = useMemo( + () => ({ + show: showWithHide, + hide, + }), + [showWithHide, hide], + ); + + // State context value를 메모이제이션 + 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/app/src/components/toast/toastReducer.ts b/packages/app/src/components/toast/toastReducer.ts index a2d0202b..e2983fc7 100644 --- a/packages/app/src/components/toast/toastReducer.ts +++ b/packages/app/src/components/toast/toastReducer.ts @@ -1,4 +1,5 @@ -import type { ActionDispatch } from "react"; +import { type ActionDispatch } from "react"; +import { useCallback, useMemo } from "@hanghae-plus/lib/src/hooks"; export type ToastType = "info" | "success" | "warning" | "error"; @@ -33,7 +34,13 @@ export const toastReducer = (state: ToastState, action: any): ToastState => { }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const createActions = (dispatch: ActionDispatch<[action: any]>) => ({ - show: (message: string, type: ToastType) => dispatch({ type: Actions.SHOW, payload: { message, type } }), - hide: () => dispatch({ type: Actions.HIDE }), -}); +export const useToastActions = (dispatch: ActionDispatch<[action: any]>) => { + const show = useCallback( + (message: string, type: ToastType) => dispatch({ type: Actions.SHOW, payload: { message, type } }), + [dispatch], + ); + + const hide = useCallback(() => dispatch({ type: Actions.HIDE }), [dispatch]); + + return useMemo(() => ({ show, hide }), [show, hide]); +}; diff --git a/packages/lib/src/createObserver.ts b/packages/lib/src/createObserver.ts index fe97bf6b..07b09ddc 100644 --- a/packages/lib/src/createObserver.ts +++ b/packages/lib/src/createObserver.ts @@ -3,9 +3,9 @@ type Listener = () => void; export const createObserver = () => { const listeners = new Set(); - // useSyncExternalStore 에서 활용할 수 있도록 subscribe 함수를 수정합니다. const subscribe = (fn: Listener) => { listeners.add(fn); + return () => unsubscribe(fn); }; const unsubscribe = (fn: Listener) => { diff --git a/packages/lib/src/equals/deepEquals.ts b/packages/lib/src/equals/deepEquals.ts index f5f4ebd5..0d5f590d 100644 --- a/packages/lib/src/equals/deepEquals.ts +++ b/packages/lib/src/equals/deepEquals.ts @@ -1,3 +1,30 @@ +import { typeUtils } from "../utils/typeUtil.ts"; +import { deepEqualsArrays } from "./deepEqualsArrays.ts"; +import deepEqualsObjects from "./deepEqualsObjects.ts"; + export const deepEquals = (a: unknown, b: unknown) => { - return a === b; + // 원시 타입인 경우 + if (typeUtils.isPrimitive(a) || typeUtils.isPrimitive(b)) { + return a === b; + } + + const aIsArray = Array.isArray(a); + const bIsArray = Array.isArray(b); + + // 비교하려는 값의 타입이 다르면 false + if (aIsArray !== bIsArray) { + return false; + } + + // 배열인 경우 + if (aIsArray && bIsArray) { + return deepEqualsArrays(a, b); + } + + // 객체인 경우 + if (typeUtils.isObject(a) && typeUtils.isObject(b)) { + return deepEqualsObjects(a, b); + } + + return true; }; diff --git a/packages/lib/src/equals/deepEqualsArrays.ts b/packages/lib/src/equals/deepEqualsArrays.ts new file mode 100644 index 00000000..7a4a0380 --- /dev/null +++ b/packages/lib/src/equals/deepEqualsArrays.ts @@ -0,0 +1,32 @@ +import { deepEquals } from "./deepEquals.ts"; + +type ArrayValue = unknown[]; + +export const deepEqualsArrays = (arrayA: ArrayValue, arrayB: ArrayValue) => { + // 참조가 같으면 true + if (arrayA === arrayB) { + return true; + } + + // 둘 중 하나라도 null 또는 undefined면 false + if (!arrayA || !arrayB) { + return false; + } + + const arrayALength = arrayA.length; + const arrayBLength = arrayB.length; + + // 길이가 다르면 false + if (arrayALength !== arrayBLength) { + return false; + } + + for (let i = 0; i < arrayALength; i++) { + // 각 요소를 재귀적으로 비교 + if (!deepEquals(arrayA[i], arrayB[i])) { + return false; + } + } + + return true; +}; diff --git a/packages/lib/src/equals/deepEqualsObjects.ts b/packages/lib/src/equals/deepEqualsObjects.ts new file mode 100644 index 00000000..efb600a9 --- /dev/null +++ b/packages/lib/src/equals/deepEqualsObjects.ts @@ -0,0 +1,41 @@ +import { deepEquals } from "./deepEquals.ts"; + +type ObjectValue = Record; + +export default function deepEqualsObjects(objectA: ObjectValue, objectB: ObjectValue): boolean { + // 참조가 같으면 true + if (objectA === objectB) { + return true; + } + + // 둘 중 하나라도 null 또는 undefined면 false + if (!objectA || !objectB) { + return false; + } + + const objectAKeys = Object.keys(objectA); + const objectBKeys = Object.keys(objectB); + const objectALength = objectAKeys.length; + const objectBLength = objectBKeys.length; + + // 키가 개수가 다르면 false + if (objectALength !== objectBLength) { + return false; + } + + for (let i = 0; i < objectALength; i++) { + const key = objectAKeys[i]; + + // B에 해당 key가 없으면 다름 + if (!Object.hasOwn(objectB, key)) { + return false; + } + + // 각 요소를 재귀적으로 비교 + if (!deepEquals(objectA[key], objectB[key])) { + return false; + } + } + + return true; +} diff --git a/packages/lib/src/equals/shallowEquals.ts b/packages/lib/src/equals/shallowEquals.ts index 1cf22130..c10ca2ab 100644 --- a/packages/lib/src/equals/shallowEquals.ts +++ b/packages/lib/src/equals/shallowEquals.ts @@ -1,3 +1,28 @@ +import shallowEqualsArrays from "./shallowEqualsArrays.ts"; +import shallowEqualsObjects from "./shallowEqualsObjects.ts"; +import { typeUtils } from "../utils/typeUtil.ts"; + export const shallowEquals = (a: unknown, b: unknown) => { - return a === b; + // 원시 타입인 경우 + if (typeUtils.isPrimitive(a) || typeUtils.isPrimitive(b)) { + return a === b; + } + + const aIsArray = Array.isArray(a); + const bIsArray = Array.isArray(b); + + // 비교하려는 값의 타입이 다르면 false + if (aIsArray !== bIsArray) { + return false; + } + + // 배열인 경우 + if (aIsArray && bIsArray) { + return shallowEqualsArrays(a, b); + } + + // 객체인 경우 + if (typeUtils.isObject(a) && typeUtils.isObject(b)) { + return shallowEqualsObjects(a, b); + } }; diff --git a/packages/lib/src/equals/shallowEqualsArrays.ts b/packages/lib/src/equals/shallowEqualsArrays.ts new file mode 100644 index 00000000..6e853ac0 --- /dev/null +++ b/packages/lib/src/equals/shallowEqualsArrays.ts @@ -0,0 +1,30 @@ +type ArrayValue = unknown[]; + +export default function shallowEqualsArrays(arrayA: ArrayValue, arrayB: ArrayValue) { + // 참조가 같으면 true + if (arrayA === arrayB) { + return true; + } + + // 둘 중 하나라도 null 또는 undefined면 false + if (!arrayA || !arrayB) { + return false; + } + + const arrayALength = arrayA.length; + const arrayBLength = arrayB.length; + + // 길이가 다르면 false + if (arrayALength !== arrayBLength) { + return false; + } + + for (let i = 0; i < arrayALength; i++) { + // 값이 다르면 false + if (arrayA[i] !== arrayB[i]) { + return false; + } + } + + return true; +} diff --git a/packages/lib/src/equals/shallowEqualsObjects.ts b/packages/lib/src/equals/shallowEqualsObjects.ts new file mode 100644 index 00000000..b35042e1 --- /dev/null +++ b/packages/lib/src/equals/shallowEqualsObjects.ts @@ -0,0 +1,34 @@ +type ObjectValue = Record; + +export default function shallowEqualsObjects(objectA: ObjectValue, objectB: ObjectValue): boolean { + // 참조가 같으면 true + if (objectA === objectB) { + return true; + } + + // 둘 중 하나라도 null 또는 undefined면 false + if (!objectA || !objectB) { + return false; + } + + const objectAKeys = Object.keys(objectA); + const objectBKeys = Object.keys(objectB); + const objectALength = objectAKeys.length; + const objectBLength = objectBKeys.length; + + // 키가 개수가 다르면 false + if (objectALength !== objectBLength) { + return false; + } + + for (let i = 0; i < objectALength; i++) { + const key = objectAKeys[i]; + + // 값이 다르거나, B에 해당 key가 없으면 다름 + if (objectA[key] !== objectB[key] || !Object.hasOwn(objectB, key)) { + return false; + } + } + + return true; +} diff --git a/packages/lib/src/hocs/deepMemo.ts b/packages/lib/src/hocs/deepMemo.ts index 13f7f18e..b234690d 100644 --- a/packages/lib/src/hocs/deepMemo.ts +++ b/packages/lib/src/hocs/deepMemo.ts @@ -1,5 +1,7 @@ import type { FunctionComponent } from "react"; +import { memo } from "./memo.ts"; +import { deepEquals } from "../equals"; export function deepMemo

(Component: FunctionComponent

) { - return Component; + return memo(Component, deepEquals); } diff --git a/packages/lib/src/hocs/index.ts b/packages/lib/src/hocs/index.ts index 1366564d..f719bcbb 100644 --- a/packages/lib/src/hocs/index.ts +++ b/packages/lib/src/hocs/index.ts @@ -1,2 +1,2 @@ export * from "./deepMemo"; -export * from "./memo"; +export * from "./memo.ts"; diff --git a/packages/lib/src/hocs/memo.ts b/packages/lib/src/hocs/memo.ts index 532c3a5c..2b3f5378 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, ReactNode } from "react"; import { shallowEquals } from "../equals"; +import { useRef } from "../hooks"; export function memo

(Component: FunctionComponent

, equals = shallowEquals) { - return Component; + return function MemoizedComponent(props: P) { + const prevPropsRef = useRef

(null); + const prevComponentRef = useRef | null>(null); + + // 첫 렌더링이면, 이전 컴포넌트를 생성하고 반환한다. + if (!prevPropsRef.current) { + prevPropsRef.current = props; + prevComponentRef.current = Component({ ...props }); + return prevComponentRef.current; + } + + // props가 변경되지 않았다면, 이전 컴포넌트를 반환한다. + if (equals(prevPropsRef.current, props)) { + return prevComponentRef.current; + } + + // props가 변경되었으므로, 이전 컴포넌트를 삭제하고 새로운 컴포넌트를 생성한다. + prevPropsRef.current = props; + prevComponentRef.current = Component({ ...props }); + return prevComponentRef.current; + }; } diff --git a/packages/lib/src/hooks/useAutoCallback.ts b/packages/lib/src/hooks/useAutoCallback.ts index 23100162..ea8b0991 100644 --- a/packages/lib/src/hooks/useAutoCallback.ts +++ b/packages/lib/src/hooks/useAutoCallback.ts @@ -3,5 +3,10 @@ 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: Parameters): ReturnType => { + return fnRef.current(...args); + }, []) as T; }; diff --git a/packages/lib/src/hooks/useCallback.ts b/packages/lib/src/hooks/useCallback.ts index 712bc898..6e7a64e5 100644 --- a/packages/lib/src/hooks/useCallback.ts +++ b/packages/lib/src/hooks/useCallback.ts @@ -1,7 +1,7 @@ -/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-unsafe-function-type */ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ import type { DependencyList } from "react"; +import { useMemo } from "./useMemo.ts"; export function useCallback(factory: T, _deps: DependencyList) { - // 직접 작성한 useMemo를 통해서 만들어보세요. - return factory as T; + return useMemo(() => factory, _deps); } diff --git a/packages/lib/src/hooks/useMemo.ts b/packages/lib/src/hooks/useMemo.ts index e80692e2..2b32d070 100644 --- a/packages/lib/src/hooks/useMemo.ts +++ b/packages/lib/src/hooks/useMemo.ts @@ -1,8 +1,30 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import type { DependencyList } from "react"; import { shallowEquals } from "../equals"; +import { useRef } from "./useRef.ts"; export function useMemo(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T { - // 직접 작성한 useRef를 통해서 만들어보세요. - return factory(); + // 메모이제이션 된 함수의 결과 + const memoizedValueRef = useRef(null); + // 의존성 + const depsRef = useRef(null); + + // 초기 실행이거나 의존성이 변경되었을 때만 함수를 재생성한다. + if (depsRef.current === null || (_deps.length > 0 && !_equals(depsRef.current, _deps))) { + try { + // factory 함수 실행 결과를 캐싱한다. + memoizedValueRef.current = factory(); + // 의존성을 업데이트한다. + depsRef.current = _deps; + } catch (error) { + console.error("메모이제이션 실패:", error); + } + } + + // 메모이제이션 된 함수가 null이면 예외를 발생시킨다. + if (memoizedValueRef.current === null) { + throw new Error("메모이제이션 실패"); + } + + // 메모이제이션 된 함수를 반환한다. + return memoizedValueRef.current; } diff --git a/packages/lib/src/hooks/useRef.ts b/packages/lib/src/hooks/useRef.ts index 285d4ae7..e8db0786 100644 --- a/packages/lib/src/hooks/useRef.ts +++ b/packages/lib/src/hooks/useRef.ts @@ -1,4 +1,6 @@ +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..4a40cb5d 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -6,7 +6,6 @@ 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(router.subscribe, () => shallowSelector(router)); }; diff --git a/packages/lib/src/hooks/useShallowSelector.ts b/packages/lib/src/hooks/useShallowSelector.ts index b75ed791..e7463eb2 100644 --- a/packages/lib/src/hooks/useShallowSelector.ts +++ b/packages/lib/src/hooks/useShallowSelector.ts @@ -4,6 +4,19 @@ import { shallowEquals } from "../equals"; type Selector = (state: T) => S; export const useShallowSelector = (selector: Selector) => { - // 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅을 구현합니다. - return (state: T): S => selector(state); + const prevStateRef = useRef(null); + + return (state: T): S => { + if (!prevStateRef.current) { + prevStateRef.current = selector(state); + return prevStateRef.current; + } + + if (shallowEquals(prevStateRef.current, selector(state))) { + return prevStateRef.current; + } + + prevStateRef.current = selector(state); + return prevStateRef.current; + }; }; diff --git a/packages/lib/src/hooks/useShallowState.ts b/packages/lib/src/hooks/useShallowState.ts index 7b589748..42e186d1 100644 --- a/packages/lib/src/hooks/useShallowState.ts +++ b/packages/lib/src/hooks/useShallowState.ts @@ -1,7 +1,14 @@ import { useState } from "react"; import { shallowEquals } from "../equals"; +import { useCallback } from "./useCallback.ts"; -export const useShallowState = (initialValue: Parameters>[0]) => { - // useState를 사용하여 상태를 관리하고, shallowEquals를 사용하여 상태 변경을 감지하는 훅을 구현합니다. - return useState(initialValue); +export const useShallowState = (initialValue: T | (() => T)) => { + const [state, setState] = useState(initialValue); + const setValueShallow = useCallback((newValue: T) => { + setState((prevValue) => { + return shallowEquals(prevValue, newValue) ? prevValue : newValue; + }); + }, []); + + return [state, setValueShallow] as const; }; diff --git a/packages/lib/src/hooks/useStorage.ts b/packages/lib/src/hooks/useStorage.ts index fdc97a6f..f620638c 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(storage.subscribe, storage.get); }; diff --git a/packages/lib/src/hooks/useStore.ts b/packages/lib/src/hooks/useStore.ts index acf3ad79..56fa8800 100644 --- a/packages/lib/src/hooks/useStore.ts +++ b/packages/lib/src/hooks/useStore.ts @@ -7,7 +7,6 @@ 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(store.subscribe, () => shallowSelector(store.getState())); }; diff --git a/packages/lib/src/utils/typeUtil.ts b/packages/lib/src/utils/typeUtil.ts new file mode 100644 index 00000000..045d97cd --- /dev/null +++ b/packages/lib/src/utils/typeUtil.ts @@ -0,0 +1,16 @@ +export const typeUtils = { + isPrimitive(value: unknown) { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" || + typeof value === "symbol" || + value === null || + value === undefined + ); + }, + isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); + }, +};