Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/app/404.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>상품 쇼핑몰</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/src/styles.css">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#3b82f6',
secondary: '#6b7280'
}
}
}
}
</script>
</head>
<body class="bg-gray-50">
<div id="root"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
59 changes: 34 additions & 25 deletions packages/app/src/components/toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,61 @@
/* 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 } from "./toastReducer";
import { debounce } from "../../utils";
import { useCallback, useMemo } from "@hanghae-plus/lib/src/hooks";

type ShowToast = (message: string, type: ToastType) => void;
type Hide = () => void;

const ToastContext = createContext<{
message: string;
type: ToastType;
const ToastCommandContext = createContext<{
show: ShowToast;
hide: Hide;
}>({
...initialState,
show: () => null,
hide: () => null,
});

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 hide = useCallback(() => {
dispatch({ type: "hide" });
}, []);

const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);

const show = useCallback(
(message: string, type: ToastType) => {
dispatch({ type: "show", payload: { message, type } });
hideAfter();
},
[hideAfter],
);

const showWithHide: ShowToast = (...args) => {
show(...args);
hideAfter();
};
const commandValue = useMemo(() => ({ show, hide }), [show, hide]);
const stateValue = useMemo(() => ({ message: state.message, type: state.type }), [state.message, state.type]);

return (
<ToastContext value={{ show: showWithHide, hide, ...state }}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastContext>
<ToastCommandContext.Provider value={commandValue}>
<ToastStateContext.Provider value={stateValue}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastStateContext.Provider>
</ToastCommandContext.Provider>
);
});
8 changes: 7 additions & 1 deletion packages/lib/src/createObserver.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
type Listener = () => void;

/**
* 옵저버 패턴을 사용하여 상태 변경을 관리하는 함수
* @returns 옵저버 객체
*/
export const createObserver = () => {
const listeners = new Set<Listener>();

// useSyncExternalStore 에서 활용할 수 있도록 subscribe 함수를 수정합니다.
const subscribe = (fn: Listener) => {
listeners.add(fn);
return () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subscribe에서 반환해주는 함수 로직이 unsubscribe랑 동일하니까 unsubscribe를 그대로 반환해주면 될 것 같아요!

listeners.delete(fn);
};
};

const unsubscribe = (fn: Listener) => {
Expand Down
21 changes: 19 additions & 2 deletions packages/lib/src/equals/deepEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
export const deepEquals = (a: unknown, b: unknown) => {
return a === b;
import { compareObject, isObject } from "./util";

/**
* 두 값의 깊은 비교 결과를 반환
* @param {unknown} a 비교할 첫 번째 값
* @param {unknown} b 비교할 두 번째 값
* @returns {boolean} 두 값의 깊은 비교 결과
*/
export const deepEquals = (a: unknown, b: unknown): boolean => {
// 1. 기본 타입이거나 null인 경우 처리
if (a === b) return true;

// 2. 둘 다 객체인 경우:
// - 배열인지 확인
// - 객체의 키 개수가 다른 경우 처리
// - 재귀적으로 각 속성에 대해 deepEquals 호출
if (!isObject(a) || !isObject(b)) return false;

return compareObject(a, b, deepEquals);
};
16 changes: 15 additions & 1 deletion packages/lib/src/equals/shallowEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
import { compareObject, isObject } from "./util";

/**
* 두 값의 얕은 비교 결과를 반환
* @param {unknown} a 비교할 첫 번째 값
* @param {unknown} b 비교할 두 번째 값
* @returns {boolean} 두 값이 얕은 비교 결과
*/
export const shallowEquals = (a: unknown, b: unknown) => {
return a === b;
// 1. 두 값이 정확히 같은지 확인 (참조가 같은 경우)
if (Object.is(a, b)) return true;

// 2. 둘 중 하나라도 객체가 아닌 경우 처리
if (!isObject(a) || !isObject(b)) return false;

return compareObject(a, b);
};
29 changes: 29 additions & 0 deletions packages/lib/src/equals/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* 주어진 값이 객체인지 확인하는 함수
* @reference https://github.com/toss/es-toolkit/blob/main/src/compat/predicate/isObject.ts
* @param {unknown} value - 객체인지 확인할 값
* @returns {value is Record<string, unknown>} 객체인지 확인 결과
*/
export function isObject(value?: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}

/**
* 두 객체를 비교하는 함수
* @param a 비교할 첫 번째 객체
* @param b 비교할 두 번째 객체
* @returns 두 객체가 같은지 확인 결과
*/
export function compareObject(
a: Record<string, unknown>,
b: Record<string, unknown>,
compareFn: (a: unknown, b: unknown) => boolean = (a, b) => a === b,
): boolean {
// 객체의 키 개수가 다른 경우 처리
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;

// 모든 키에 대해 비교 수행
return keysA.every((key) => compareFn(a[key], b[key]));
}
Comment on lines +1 to +29

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 utils 함수로 분리 좋습니다!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compareObject 함수명만 봤을 때 상세 동작을 상상하기가 어려운 것 같아요! 참조값을 비교하는지, 키값을 비교하는지 더 명확하게 알 수 있으면 좋을 것 같아요!! compareObject로 분리하는 것 좋네요 반복되는 로직이었는데! 👍🏻

11 changes: 10 additions & 1 deletion packages/lib/src/hocs/deepMemo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { FunctionComponent } from "react";
import { memo } from "./memo";
import { deepEquals } from "../equals";

/**
* deepMemo HOC는 컴포넌트의 props를 깊은 비교하여 불필요한 리렌더링을 방지합니다.
* @param Component 메모이제이션할 컴포넌트
* @returns 메모이제이션된 컴포넌트
*/
export function deepMemo<P extends object>(Component: FunctionComponent<P>) {
return Component;
// deepEquals 함수를 사용하여 props 비교
// 앞에서 만든 memo를 사용
return memo(Component, deepEquals);
}
26 changes: 24 additions & 2 deletions packages/lib/src/hocs/memo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import { type FunctionComponent } from "react";
import { type FunctionComponent, type JSX } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "../hooks";

/**
* memo HOC는 컴포넌트의 props를 얕은 비교하여 불필요한 리렌더링을 방지합니다.
* @param Component 메모이제이션할 컴포넌트
* @param equals 비교 함수
* @returns 메모이제이션된 컴포넌트
*/
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
return Component;
const MemoizedComponent = (props: P) => {
// Hook을 컴포넌트 내부에서 호출
const prevPropsRef = useRef<P | null>(null);
const prevResultRef = useRef<JSX.Element | null>(null);

const shouldUpdate = prevPropsRef.current === null || !equals(prevPropsRef.current, props);

if (shouldUpdate) {
prevPropsRef.current = props;
prevResultRef.current = Component(props) as JSX.Element;
}

return prevResultRef.current!;
};

return MemoizedComponent;
}
14 changes: 13 additions & 1 deletion packages/lib/src/hooks/useAutoCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import type { AnyFunction } from "../types";
import { useCallback } from "./useCallback";
import { useRef } from "./useRef";

/**
* 콜백 함수를 메모이제이션하는 훅
* @param fn 메모이제이션할 콜백 함수
* @returns 메모이제이션된 콜백 함수
*/
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
return fn;
// 1. 콜백함수가 참조하는 값은 항상 렌더링 시점에 최신화 되어야한다. ← 이 부분을 어떻게 해결할 수 있을지 고민해보세요!
const fnRef = useRef(fn);
fnRef.current = fn;

// 2. 대신 항상 동일한 참조를 유지해야 한다 (useCallback 활용)
return useCallback((...args: unknown[]) => {
return fnRef.current(...args);
}, []) as T;
};
12 changes: 9 additions & 3 deletions packages/lib/src/hooks/useCallback.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
/* 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";

/**
* 콜백 함수를 메모이제이션하는 훅
* @param factory 메모이제이션할 콜백 함수
* @param _deps 의존성 배열
* @returns 메모이제이션된 콜백 함수
*/
export function useCallback<T extends Function>(factory: T, _deps: DependencyList) {
// 직접 작성한 useMemo를 통해서 만들어보세요.
return factory as T;
return useMemo(() => factory, _deps);
}
8 changes: 7 additions & 1 deletion packages/lib/src/hooks/useDeepMemo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import type { DependencyList } from "react";
import { useMemo } from "./useMemo";
import { deepEquals } from "../equals";

/**
* useDeepMemo 훅은 의존성이 변경되었을 때만 함수를 실행하여 깊은 비교를 수행한 메모이제이션된 값을 반환합니다.
* @param factory 메모이제이션할 함수
* @param deps 의존성 배열
* @returns 메모이제이션된 값
*/
export function useDeepMemo<T>(factory: () => T, deps: DependencyList): T {
// 직접 작성한 useMemo를 참고해서 만들어보세요.
// 1. useMemo를 사용하되, 비교 함수로 deepEquals를 사용
return useMemo(factory, deps, deepEquals);
}
24 changes: 21 additions & 3 deletions packages/lib/src/hooks/useMemo.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { DependencyList } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "./useRef";

/**
* useMemo 훅은 의존성이 변경되었을 때만 함수를 실행하여 메모이제이션된 값을 반환합니다.
* @param factory 메모이제이션할 함수
* @param _deps 의존성 배열
* @param _equals 비교 함수
* @returns 메모이제이션된 값
*/
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
// 직접 작성한 useRef를 통해서 만들어보세요.
return factory();
// 1. 이전 의존성과 결과를 저장할 ref 생성
const ref = useRef<{ deps: DependencyList; result: T } | null>(null);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석이 없어도 이 ref값이 잘 이해되도록 구체적인 변수명으로 해주면 더 좋을 것 같아요!

if (!ref.current) ref.current = { deps: _deps, result: factory() };

// 2. 현재 의존성과 이전 의존성 비교
if (!_equals(ref.current.deps, _deps)) {
// 3. 의존성이 변경된 경우 factory 함수 실행 및 결과 저장
ref.current.result = factory();
ref.current.deps = _deps;
}

// 4. 메모이제이션된 값 반환
return ref.current.result;
}
16 changes: 14 additions & 2 deletions packages/lib/src/hooks/useRef.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
import { useState } from "react";

/**
* 렌더링 사이에 값을 유지하는 가변 ref 객체를 생성
* - 반환된 ref 객체는 컴포넌트의 전체 생명주기 동안 유지됩니다.
* - ref 객체의 .current 속성을 변경해도 리렌더링이 트리거되지 않습니다.
* - DOM 요소에 접근하거나 이전 상태를 저장하는 등 다양한 용도로 사용될 수 있습니다.
* @param initialValue 초기값
* @returns ref 객체
*/

export function useRef<T>(initialValue: T): { current: T } {
// useState를 이용해서 만들어보세요.
return { current: initialValue };
//useState를 사용하여 초기값을 저장하고, 이후 값을 변경할 때 리렌더링을 트리거하지 않도록 합니다.
const [refObject] = useState(() => ({ current: initialValue }));
return refObject;
}
13 changes: 11 additions & 2 deletions packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,17 @@ import { useShallowSelector } from "./useShallowSelector";

const defaultSelector = <T, S = T>(state: T) => state as unknown as S;

/**
* useSyncExternalStore를 사용해서 router의 상태를 구독하고 가져오는 훅
* @param router 라우터
* @param selector 선택자 함수
* @returns 라우터의 상태
*/
export const useRouter = <T extends RouterInstance<AnyFunction>, S>(router: T, selector = defaultSelector<T, S>) => {
// useSyncExternalStore를 사용하여 router의 상태를 구독하고 가져오는 훅을 구현합니다.
const shallowSelector = useShallowSelector(selector);
return shallowSelector(router);
return useSyncExternalStore(
router.subscribe,
() => shallowSelector(router),
() => shallowSelector(router),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getServerSnapshot 파라미터는 useRouter 파라미터에 옵션으로 넣어줘도 될 것 같아요

export const useRouter = <T extends RouterInstance<AnyFunction>, S>(
  router: T,
  selector = defaultSelector<T, S>,
  getServerSnapshot?: () => S, // 옵셔널 파라미터
) => {
  // useSyncExternalStore를 사용하여 router의 상태를 구독하고 가져오는 훅을 구현합니다.
  const shallowSelector = useShallowSelector(selector);
  return shallowSelector(router);
  return useSyncExternalStore(
    router.subscribe,
    () => shallowSelector(router),
    getServerSnapshot // 변경

// 생략

);
};
14 changes: 12 additions & 2 deletions packages/lib/src/hooks/useShallowSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@ import { shallowEquals } from "../equals";

type Selector<T, S = T> = (state: T) => S;

/**
* 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅
* @reference https://github.com/pmndrs/zustand/blob/main/src/react/shallow.ts
* @param selector 선택자 함수
* @returns 상태가 변경되었을 때만 선택자 함수의 결과를 반환
*/
export const useShallowSelector = <T, S = T>(selector: Selector<T, S>) => {
// 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅을 구현합니다.
return (state: T): S => selector(state);
const prev = useRef<S | undefined>(undefined);

return (state: T) => {
const next = selector(state);
return shallowEquals(prev.current, next) ? (prev.current as S) : (prev.current = next);
};
};
Loading