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.tsx"></script>
</body>
</html>
50 changes: 33 additions & 17 deletions packages/app/src/components/toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,68 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react";
import { createContext, memo, type PropsWithChildren, useContext, useReducer, useMemo } from "react";
import { createPortal } from "react-dom";
import { Toast } from "./Toast";
import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer";
import { debounce } from "../../utils";

import { useAutoCallback } from "@hanghae-plus/lib";
type ShowToast = (message: string, type: ToastType) => void;
type Hide = () => void;

const ToastContext = createContext<{
message: string;
type: ToastType;
//action 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();
const { show, hide } = useContext(ToastCommandContext);
return { show, hide };
};
export const useToastState = () => {
const { message, type } = useToastContext();
const { message, type } = useContext(ToastStateContext);
return { message, type };
};

// ContextContext를 분리해도, value로 넘기는 함수의 참조가 바뀌면 하위 컴포넌트가 리렌더링됨.
// console를 통해서 리렌더링이 어디서 발생하는지 확인해 수정해야 함
// show, hide, showWithHide, hideAfter 모두 useCallback/useMemo로 감싸서 참조 고정
// Context value에 바로 객체 리터럴 넘기지 말고, useMemo로 만든 값 넘기기
export const ToastProvider = memo(({ children }: PropsWithChildren) => {
const [state, dispatch] = useReducer(toastReducer, initialState);
const { show, hide } = createActions(dispatch);
// dispatch가 변경될 때만 되도록
const { show, hide } = useMemo(() => createActions(dispatch), [dispatch]);
const visible = state.message !== "";

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

const showWithHide: ShowToast = (...args) => {
// useAutoCallback으로 함수 고정
const showWithHide: ShowToast = useAutoCallback((...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 (
<ToastContext value={{ show: showWithHide, hide, ...state }}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastContext>
<ToastCommandContext value={commandValue}>
<ToastStateContext value={stateValue}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastStateContext>
</ToastCommandContext>
Comment on lines +61 to +66
Copy link
Author

Choose a reason for hiding this comment

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

<궁금해요>
Context를 사용할 때 ToastCommandContextToastCommandContext.Provider 중 어떤 방식을 사용해야 하는지 궁금합니다. 저는 공식문서 보고 ToastCommandContext만 사용했는데, .provider를 붙여서 하신 분들이 많으셔서.. 어떻게 하셨고 왜 사용하셨는지 궁금합니다.

);
});
21 changes: 16 additions & 5 deletions packages/lib/src/createObserver.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
// TypeScript에서 리스너 함수의 타입을 정의하는 코드
type Listener = () => void;

// - subscribe 함수가 useSyncExternalStore 에서 잘 쓰일 수 있도록 수정해야됩니다.
// - 다른곳에서 useSyncExternalStore 호출할 때 createObserver를 사용하는거지 이 안에 두면 안 되는거다..;;
// - 공식문서를 참고해서 수정해주세요!

export const createObserver = () => {
const listeners = new Set<Listener>();

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

const unsubscribe = (fn: Listener) => {
listeners.delete(fn);
};
// 구독 등록
// subscribe: 저장소 변경을 구독하는 함수, store를 구독하고 구독을 취소하는 함수를 반환
// store가 변경될 때, 제공된 callback이 호출되어 React가 getSnapshot을 다시 호출하고 (필요한 경우) 컴포넌트를 다시 렌더링하도록 해야 합니다.
const subscribe = (fn: Listener) => {
listeners.add(fn); // 리스너 등록
// 구독 취소 함수 반환
return () => {
unsubscribe(fn);
};
};

// 상태 변경 시 알림
const notify = () => listeners.forEach((listener) => listener());

return { subscribe, notify };
Expand Down
41 changes: 38 additions & 3 deletions packages/lib/src/equals/deepEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,38 @@
export const deepEquals = (a: unknown, b: unknown) => {
return a === b;
};
// deepEquals 함수는 두 값의 깊은 비교를 수행합니다.
export function deepEquals(objA: unknown, objB: unknown): boolean {
// 1. 기본 타입이거나 null인 경우 처리
if (Object.is(objA, objB)) return true;
if (typeof objA !== "object" || objA === null || typeof objB !== "object" || objB === null) return false;

// 2. 둘 다 객체인 경우:
// 배열인지 확인
// 재귀적으로 각 속성에 대해 deepEquals
const isArrayA = Array.isArray(objA);
const isArrayB = Array.isArray(objB);

if (isArrayA !== isArrayB) return false;
if (isArrayA && isArrayB) {
if (objA.length !== objB.length) return false;
for (let i = 0; i < objA.length; i++) {
if (!deepEquals(objA[i], objB[i])) return false;
}

return true;
}

// 객체의 키 개수가 다른 경우 처리
const keysA = Object.keys(objA as Record<string, unknown>);
const keysB = Object.keys(objB as Record<string, unknown>);

if (keysA.length !== keysB.length) return false;

for (let i = 0; i < keysA.length; i++) {
const currentKey = keysA[i];
if (!Object.prototype.hasOwnProperty.call(objB, currentKey)) return false;

if (!deepEquals((objA as Record<string, unknown>)[currentKey], (objB as Record<string, unknown>)[currentKey]))
return false;
}

return true;
}
33 changes: 30 additions & 3 deletions packages/lib/src/equals/shallowEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
export const shallowEquals = (a: unknown, b: unknown) => {
return a === b;
};
// shallowEquals 함수는 두 값의 얕은 비교를 수행합니다.
// 다양한 타입(기본값, 배열, 객체 등)을 받아야 하므로 unknown 타입으로 선언
export function shallowEquals(objA: unknown, objB: unknown): boolean {
// 1. 두 값이 정확히 같은지 확인 (참조가 같은 경우)
if (Object.is(objA, objB)) return true;
Copy link
Author

Choose a reason for hiding this comment

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

<자랑하고 싶어요>
처음 요구사항을 접했을 때는 단순한 비교 연산자(===)를 떠올렸지만, 주어진 자료들을 찾아보며 Object.is() 메서드를 알게 되었습니다. 이 메서드를 활용하여 NaN이나 -0과 같은 특수한 값들까지 정확하게 비교하도록 구현했습니다.

Copy link

@BangDori BangDori Jul 29, 2025

Choose a reason for hiding this comment

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

저는 shallowEquals를 구현할 때 단순히 일치 연산자(===)를 사용하는 방식을 선택했는데, 단순한 기능을 구현하는 과정에서도 끊임없이 더 나은 방법이 있는지를 탐색하고 이를 적용하려는 모습이 상당히 짱짱걸입니다 지혜님 👏 (멋있음)

아 그리구 첨언해보자면, shallowEquals 함수 내부에서 objA, objB에 대한 타입 에러가 발생해서 as 단언문을 사용해주신 것으로 보이는데 타입 단언문을 사용하지 않고 해결하는 방법도 한 번 고려해보시면 어떨까요!?


// 2. 둘 중 하나라도 객체가 아닌 경우 처리 - 진짜 객체만 분류
// null과 객체를 구분하려면 null은 객체가 아니어야 하고, 진짜 객체만 객체로 취급해야 함
if (typeof objA !== "object" || objA === null || typeof objB !== "object" || objB === null) return false;

// 3. 객체의 키 개수가 다른 경우 처리
// 타입상 object임을 TypeScript에 명확히 알려주기 위해 타입 단언이 필요
const keysA = Object.keys(objA as object);
const keysB = Object.keys(objB as object);

if (keysA.length !== keysB.length) return false;

// 4. 모든 키에 대해 얕은 비교 수행
for (let i = 0; i < keysA.length; i++) {
const currentKey = keysA[i];
// Object.prototype.hasOwnProperty.call()로 객체의 고유한 속성인지 확인
if (
!Object.prototype.hasOwnProperty.call(objB, currentKey) ||
!Object.is((objA as Record<string, unknown>)[currentKey], (objB as Record<string, unknown>)[currentKey])
) {
return false;
}
}
return true;
}
17 changes: 16 additions & 1 deletion packages/lib/src/hocs/deepMemo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
import type { FunctionComponent } from "react";
import { deepEquals } from "../equals";
// import { ComponentType } from "react";
import { memo } from "./memo.ts";

// deepMemo HOC는 컴포넌트의 props를 깊은 비교하여 불필요한 리렌더링을 방지합니다.
// FunctionComponent: 함수형 컴포넌트만 type FunctionComponent<P = {}> = (props: P) => ReactElement | null;
// ComponentType: 함수형 + 클래스형 컴포넌트 모두 type ComponentType<P = {}> = FunctionComponent<P> | ComponentClass<P>;
// ? 요구사항 복붙하니까 되었다 왜..?
export function deepMemo<P extends object>(Component: FunctionComponent<P>) {
return Component;
// Component : 첫번째 인자 그대로 전달 / deepEquals : 두번째 인자 shallowEquals 대신 사용

// 테스트 조건
// props로 전달하는 값이 모두 변경되어야 리렌더링 된다.
// 깊은 객체 비교를 수행해야 한다.
// 깊은 배열 비교를 수행해야 한다.
// deepEquals 함수를 사용하여 props 비교
// 앞에서 만든 memo를 사용
return memo(Component, deepEquals);
}
29 changes: 28 additions & 1 deletion packages/lib/src/hocs/memo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
import { type FunctionComponent } from "react";
import { shallowEquals } from "../equals";

// HoC (High Order Component) : 고차 컴포넌트
// 함수가 컴포넌트를 받아서 새로운 컴포넌트를 반환하는 패턴 / 컴포넌트를 "감싸는" 함수라고 생각하면 됩니다
// memo HOC는 컴포넌트의 props를 얕은 비교하여 불필요한 리렌더링을 방지합니다.
// 3-2. React 메모이제이션 발제자료 참고 중
// P = Props의 줄임말, 제네릭 타입 매개변수
// 컴포넌트가 받는 props의 타입을 나타냄
// <P extends object>는 "P는 object를 확장하는 타입"이라는 제약
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
return Component;
// 1. 이전 props를 저장할 ref 생성
let prevProps: P | null = null; // 클로저로 상태 저장

// 2. 메모이제이션된 컴포넌트 생성
// React.ReactElement : React 컴포넌트가 렌더링한 결과의 타입 / JSX 요소의 타입을 나타냄 / <div>Hello</div>, <MyComponent /> 등의 타입
let memoizedResult: React.ReactElement | null = null;

return function (props: P) {
// 3. equals 함수를 사용하여 props 비교
// equals을 사용하여 이전 props와 현재 props를 비교
if (prevProps === null || !equals(prevProps, props)) {
console.log("Props changed, re-rendering");
// 4. props가 변경된 경우에만 새로운 렌더링 수행
memoizedResult = Component(props) as React.ReactElement; // 타입 단언 추가
} else {
console.log("Props unchanged, using memoized result");
}

prevProps = props;
return memoizedResult;
} as FunctionComponent<P>;
}
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";

// useCallback과 useRef를 이용하여 useAutoCallback
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
return fn;
// 최신 콜백 저장
const fnRef = useRef(fn);
fnRef.current = fn;

// 항상 같은 함수 반환(useCallback 활용)
// 어떤 인자가 올지 모르니 (...args: unknown[])로 모두 받아서 전달
const stableCallback = useCallback((...args: unknown[]) => {
return fnRef.current(...args);
}, []);

// 반환 타입을 T로 맞춰주기 위해 as T 사용
return stableCallback as T;
};
9 changes: 5 additions & 4 deletions packages/lib/src/hooks/useCallback.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* 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";

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

// useDeepMemo 훅은 깊은 비교를 사용하여 값을 메모이제이션합니다.
export function useDeepMemo<T>(factory: () => T, deps: DependencyList): T {
// 직접 작성한 useMemo를 참고해서 만들어보세요.
// useMemo를 사용하되, 비교 함수로 deepEquals를 사용
return useMemo(factory, deps, deepEquals);
}
20 changes: 16 additions & 4 deletions packages/lib/src/hooks/useMemo.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { DependencyList } from "react";
import { useRef, type DependencyList } from "react";
// DependencyList는 React의 useEffect, useMemo, useCallback 등에서 사용하는 "의존성 배열"의 타입 의미
import { shallowEquals } from "../equals";

// useMemo 훅은 계산 비용이 높은 값을 메모이제이션 합니다.
export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
// 직접 작성한 useRef를 통해서 만들어보세요.
return factory();
// 1. 이전 의존성과 결과를 저장할 ref 생성
const prevDeps = useRef<DependencyList>([]);
const prevResult = useRef<T | null>(null);

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

// 4. 메모이제이션된 값 반환
return prevResult.current;
}
9 changes: 7 additions & 2 deletions packages/lib/src/hooks/useRef.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
// useRef 훅은 렌더링 사이에 값을 유지하는 가변 ref 객체를 생성합니다.
import { useState } from "react";

export function useRef<T>(initialValue: T): { current: T } {
// useState를 이용해서 만들어보세요.
return { current: initialValue };
// 리렌더링이 되어도 useRef의 참조값이 유지된다.
// 렌더링 간에 ref 값을 유지하고, 값 변경 시 리렌더링을 트리거하지 않아야 한다.
const [ref] = useState(() => ({ current: initialValue }));
return ref;
}
3 changes: 2 additions & 1 deletion packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ const defaultSelector = <T, S = T>(state: T) => state as unknown as S;
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));
};
8 changes: 7 additions & 1 deletion packages/lib/src/hooks/useShallowSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,11 @@ type Selector<T, S = T> = (state: T) => S;

export const useShallowSelector = <T, S = T>(selector: Selector<T, S>) => {
// 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅을 구현합니다.
return (state: T): S => selector(state);
// zustand 내부 참고

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