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
5 changes: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,9 @@ export default tseslint.config([
...tseslint.configs.recommended,
eslintPluginPrettier,
eslintConfigPrettier,
{
rules: {
"@typescript-eslint/no-unused-vars": "off",
},
},
]);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "front-6th-chapter1-3",
"private": true,
"homepage": "https://q1Lim.github.io/front_6th_chapter1-3/",
"version": "0.0.0",
"engines": {
"node": ">=22",
Expand Down
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>
46 changes: 29 additions & 17 deletions packages/app/src/components/toast/ToastProvider.tsx

Choose a reason for hiding this comment

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

과제의 요구사항에 맞게 정확히 State와 Command 컨텍스트를 분리하신 것 같아요! 추가로 제가 평소에 즐겨 사용하는 createSafeContext라는 패턴을 소개해 드리고 싶어요.
createContext를 사용할 때마다 초기값을 반드시 설정해야 하거나 Provider 외부에서 컨텍스트를 사용하는 실수를 방지하기 위해 null 체크를 하고 값을 useMemo로 감싸는 등 반복적인 작업을 하나로 묶어줄 수 있어요.

import { createContext, createElement, useContext, useMemo } from "react";

export function createSafeContext<ContextValue extends object | null>(
  rootComponentName: string,
  defaultValue?: ContextValue,
) {
  const Context = createContext<ContextValue | undefined>(defaultValue);

  const Provider = ({ children, ...rest }: ContextValue & { children?: React.ReactNode }) => {
    // eslint-disable-next-line react-hooks/exhaustive-deps
    const value = useMemo(() => rest, Object.values(rest)) as ContextValue;

    return createElement(Context, { value }, children);
  };

  const useSafeContext = (consumerName: string) => {
    const ctx = useContext(Context);
    if (ctx) return ctx;
    if (defaultValue) return defaultValue;

    throw new Error(`\`${consumerName}\` must be used within \`${rootComponentName}\``);
  };

  Provider.displayName = rootComponentName + "Provider";

  return [Provider, useSafeContext] as const;
}

이 헬퍼를 적용하면, 현재 컨텍스트를 생성하는 부분을 아래처럼 바꿀 수 있습니다.

export const [ToastStateProvider, useToastState] =
  createSafeContext<ContextState>("ToastStateProvider");
export const [ToastCommandProvider, useToastCommand] =
  createSafeContext<ContextCommand>("ToastCommandProvider");

Copy link
Author

Choose a reason for hiding this comment

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

오,,, 새롭게 알게 됩니다!!! 감사합니다 bbb

Original file line number Diff line number Diff line change
@@ -1,52 +1,64 @@
/* 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 { debounce } from "../../utils";
import { useMemo, useAutoCallback } from "@hanghae-plus/lib/src/hooks";

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

const ToastContext = createContext<{
const ToastStateContext = createContext<{
message: string;
type: ToastType;
}>({
...initialState,
});

const ToastActionContext = createContext<{
show: ShowToast;
hide: Hide;
}>({
...initialState,
show: () => null,
hide: () => null,
});

const DEFAULT_DELAY = 3000;

const useToastContext = () => useContext(ToastContext);
export const useToastCommand = () => {
const { show, hide } = useToastContext();
return { show, hide };
};
const useToastStateContext = () => useContext(ToastStateContext);
const useToastActionContext = () => useContext(ToastActionContext);

export const useToastState = () => {
const { message, type } = useToastContext();
const { message, type } = useToastStateContext();
return { message, type };
};

export const useToastCommand = () => {
const { show, hide } = useToastActionContext();
return { show, hide };
};

export const ToastProvider = memo(({ children }: PropsWithChildren) => {
const [state, dispatch] = useReducer(toastReducer, initialState);
const { show, hide } = createActions(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) => {
const showWithHide: ShowToast = useAutoCallback((...args) => {
show(...args);
hideAfter();
};
});

const toastActionValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);
const toastStateValue = 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>
<ToastActionContext.Provider value={toastActionValue}>
<ToastStateContext.Provider value={toastStateValue}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastStateContext.Provider>
</ToastActionContext.Provider>
);
});
4 changes: 4 additions & 0 deletions packages/lib/src/createObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export const createObserver = () => {
// useSyncExternalStore 에서 활용할 수 있도록 subscribe 함수를 수정합니다.
const subscribe = (fn: Listener) => {
listeners.add(fn);
// 구독을 정리하는 함수를 반환해야함
return () => {
unsubscribe(fn);
};
};

const unsubscribe = (fn: Listener) => {
Expand Down
35 changes: 34 additions & 1 deletion packages/lib/src/equals/deepEquals.ts

Choose a reason for hiding this comment

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

shallowEqualsdeepEquals의 로직이 많이 유사해요! 코드 중복을 줄이 위해, deepEquals에 depth 매개변수를 추가하여 비교 깊이를 제어하는 것은 어떨까요? 이렇게 하면 shallowEquals는 단순히 deepEquals(a, b, 1)을 호출하는 방식으로 간결하게 구현할 수 있습니다.

Original file line number Diff line number Diff line change
@@ -1,3 +1,36 @@
export const deepEquals = (a: unknown, b: unknown) => {
return a === b;
// 기본 타입 값들을 정확히 비교
if (a === b) return true;

// a, b 하나라도 객체가 아닌 경우 false return
if (a == null || b == null || typeof a !== "object" || typeof b !== "object") return false;

// 객체가 string key로 접근 가능하다고 명시
const objA = a as Record<string, unknown>;
const objB = b as Record<string, unknown>;

if (Array.isArray(objA) && Array.isArray(objB)) {
if (objA.length !== objB.length) return false;

for (let i = 0; i < objA.length; i++) {
if (!deepEquals(objA[i], objB[i])) return false;
}
Comment on lines +15 to +17

Choose a reason for hiding this comment

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

객체의 모든 키가 특정 조건을 만족하는지 검사하는 로직은 every() 배열 메서드를 사용하면 더 간결하게 작성할 수 있습니다. 예를 들어, for 루프 대신 아래와 같이 한 줄로 표현할 수 있어 가독성을 높이는 데 도움이 될 것 같습니다.

return keysA.every(key => 
  Object.hasOwn(objB, key) && deepEquals(objA[key], objB[key])
);

Copy link
Author

Choose a reason for hiding this comment

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

인덱스를 사용해야한다고 생각해서 일반적인 for문을 썼는데, every를 까먹고 있었네요! 리마인드 된 것 같아서 감사합니다 ㅎㅎ

return true;
}
Comment on lines +12 to +19

Choose a reason for hiding this comment

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

배열과 객체를 비교하는 방식에 대해 궁금한 점이 있어요! 저는 처음에 배열도 '인덱스를 키로 갖는 객체'와 유사하다고 생각해서, 두 타입을 구분하지 않고 객체 비교 로직으로 한 번에 처리했었는데요. 규원님께서는 Array.isArray() 등으로 배열과 객체를 명확하게 구분해서 별도의 로직으로 처리해주셨더라고요. 이러한 방식이 비교의 정확성을 높이는 가장 확실한 방법이라고 생각하신 걸까요?

Copy link
Author

Choose a reason for hiding this comment

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

키로 갖는 객체로 유사하지만 배열은 인덱스 기반이기 때문에 배열의 순서도 체크해야할 것 같아서 배열은 따로 분리해서 체크했습니다!


const keysA = Object.keys(objA);
const keysB = Object.keys(objB);

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

for (let i = 0; i < keysA.length; i++) {
if (!Object.hasOwn(objB, keysA[i])) {
return false;
}
// 각 key에 매칭되는 값이 같은지 체크
if (!deepEquals(objA[keysA[i]], objB[keysA[i]])) {
return false;
}
}
return true;
};
22 changes: 21 additions & 1 deletion packages/lib/src/equals/shallowEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
export const shallowEquals = (a: unknown, b: unknown) => {
return a === b;
// 기본 타입 값들을 정확히 비교
if (a === b) return true;

// a, b 하나라도 객체가 아닌 경우 false return
if (a == null || b == null || typeof a !== "object" || typeof b !== "object") return false;

// 객체가 string key로 접근 가능하다고 명시
const objA = a as Record<string, unknown>;
const objB = b as Record<string, unknown>;
Comment on lines +9 to +10

Choose a reason for hiding this comment

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

개인적인 제 취향인데요!
저는 이렇게 타입이 길어지는 경우에는 type을 따로 선언해두는게 더 가독성에 좋게 느껴지더라구요!
이렇게 해봐도 좋을 것 같아요!

type ObjectRecord = Record<string, unknown>;

const objA = a as ObjectRecord;
const objB = b as ObjectRecord;

Copy link
Author

Choose a reason for hiding this comment

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

너무 간결하게 쓰고자했던 것 같아요ㅠ 확실히 타입 지정을 먼저 선언하는게 좋은 것 같습니다! 감사합니다


const keysA = Object.keys(objA);
const keysB = Object.keys(objB);

// key 개수 비교
if (keysA.length !== keysB.length) return false;

// 반복문으로 얕은 비교
for (const key of keysA) {
if (!(key in b) || objA[key] !== objB[key]) return false;
}
return true;
};
7 changes: 6 additions & 1 deletion packages/lib/src/hocs/deepMemo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { FunctionComponent } from "react";
import { memo } from "./memo.ts";
import { deepEquals } from "../equals";

// deepMemo HOC는 컴포넌트의 props를 깊은 비교하여 불필요한 리렌더링을 방지
export function deepMemo<P extends object>(Component: FunctionComponent<P>) {
return Component;
// deepEquals 함수를 사용하여 props 비교
// 앞에서 만든 memo도 활용
return memo(Component, deepEquals);
}
20 changes: 18 additions & 2 deletions packages/lib/src/hocs/memo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import { type FunctionComponent } from "react";
import { type FunctionComponent, type ReactNode } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "../hooks";

// memo HOC는 컴포넌트의 props를 얕은 비교하여 불필요한 렌더링을 방지
export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
return Component;
// 1. 이전 props를 저장할 ref 생성
// 2. 메모이제이션된 컴포넌트 생성
// 3. equals 함수를 사용하여 props 비교
// 4. props가 변경된 경우에만 새로운 렌더링 수행

return (props: P) => {
const prevPropsRef = useRef<P | null>(null);
const renderOutputRef = useRef<ReactNode | null>(null);

if (!equals(prevPropsRef.current, props)) {

Choose a reason for hiding this comment

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

초기 렌더링을 고려하는 로직이 추가되면 조금 더 안정적인 구조가 될 것 같아요!

if(!prevPropsRef.current || !equals(prevPropsRef.current, props));

prevPropsRef.current = props;
renderOutputRef.current = Component(props) as ReactNode;
}
return renderOutputRef.current;
};
}
10 changes: 9 additions & 1 deletion packages/lib/src/hooks/useAutoCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,13 @@ import { useCallback } from "./useCallback";
import { useRef } from "./useRef";

export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
return fn;
// useCallBack과 useRef를 이용하여 useAutoCallBack 구현
const prevRef = useRef(fn);
prevRef.current = fn;
Comment on lines +7 to +8

Choose a reason for hiding this comment

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

저는 if 문을 추가해서 이전 callback과 현재 callback을 비교하는 과정을 추가했었는데 지금와서 생각해보면 과한 로직인 것 같아요.


const autoCallBack = useCallback((...args: Parameters<T>): ReturnType<T> => {
return prevRef.current(...args);
}, []);

return autoCallBack as T;
};
6 changes: 3 additions & 3 deletions packages/lib/src/hooks/useCallback.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-unsafe-function-type */
import type { DependencyList } from "react";
import { useMemo } from "./useMemo.ts";

// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export function useCallback<T extends Function>(factory: T, _deps: DependencyList) {
// 직접 작성한 useMemo를 통해서 만들어보세요.
return factory as T;
return useMemo(() => factory, _deps);
}
1 change: 0 additions & 1 deletion packages/lib/src/hooks/useDeepMemo.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable react-hooks/exhaustive-deps */
import type { DependencyList } from "react";
import { useMemo } from "./useMemo";
import { deepEquals } from "../equals";
Expand Down
14 changes: 11 additions & 3 deletions packages/lib/src/hooks/useMemo.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { DependencyList } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "./useRef.ts";

export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T {
// 직접 작성한 useRef를 통해서 만들어보세요.
return factory();
const prevDepsRef = useRef<DependencyList | undefined>(undefined);
const memoizedValueRef = useRef<T | undefined>(undefined);

// 이전 deps가 없거나 이전 deqs가 있고, 현재 deps와 다르다면 다시 계산하기
if (prevDepsRef.current === undefined || !_equals(prevDepsRef.current, _deps)) {
memoizedValueRef.current = factory();
prevDepsRef.current = _deps;
}

return memoizedValueRef.current as T;
}
7 changes: 5 additions & 2 deletions packages/lib/src/hooks/useRef.ts

Choose a reason for hiding this comment

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

타입 안정성을 더 높일 수 있는 방법이 있어서 공유드려요! 실제 React의 useRefuseRef()처럼 초기값 없이 호출하는 경우와 useRef(0)처럼 초기값을 주는 경우의 타입이 다르게 추론돼요.

TypeScript의 **함수 오버로딩(Function Overloading)**을 사용하면 이런 케이스들을 더 정교하게 처리할 수 있습니다. 예를 들어, 아래처럼 여러 시그니처를 선언하는 거죠.

import { useState } from "react";

interface MutableRefObject<T> {
  current: T;
}

export function useRef<T = undefined>(): MutableRefObject<T | undefined>;
export function useRef<T>(initialValue: T): MutableRefObject<T>;
export function useRef<T>(initialValue?: T): MutableRefObject<T | undefined> | MutableRefObject<T> {
  return useState(() => ({ current: initialValue }))[0];
}

이렇게 하면 useRef를 초기값 없이 호출하는 케이스도 지원할 수 있고, 타입스크립트가 ref.current의 타입을 더 정확하게 추론해주는 장점이 있습니다!

Copy link
Author

Choose a reason for hiding this comment

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

오 이거 두현님 코드에서 유사한 걸 봤습니다! 지수님이 공유주신 것이 더 상세하네요~ 공유 감사합니다!

Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { useState } from "react";

export function useRef<T>(initialValue: T): { current: T } {
// useState를 이용해서 만들어보세요.
return { current: initialValue };
// 초기값을 함수로 전달해서 한번만 실행될 수 있도록 함
const [ref] = useState(() => ({ current: initialValue }));
return ref;
}
2 changes: 1 addition & 1 deletion packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ 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));
};
10 changes: 9 additions & 1 deletion packages/lib/src/hooks/useShallowSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,13 @@ 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);
const ref = useRef<S>(null);
return (state: T): S => {
const newValue = selector(state);

if (ref.current === null || !shallowEquals(ref.current, newValue)) {
ref.current = newValue;
}
return ref.current;
};
};
13 changes: 10 additions & 3 deletions packages/lib/src/hooks/useShallowState.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { useState } from "react";
import { shallowEquals } from "../equals";
import { useCallback } from "./useCallback.ts";

export const useShallowState = <T>(initialValue: Parameters<typeof useState<T>>[0]) => {
// useState를 사용하여 상태를 관리하고, shallowEquals를 사용하여 상태 변경을 감지하는 훅을 구현합니다.
return useState(initialValue);
export const useShallowState = <T>(initialValue: T | (() => T)) => {
// 임의로 타입 조정 initialValue: T | (() => T)
const [state, setState] = useState<T>(initialValue);

const setShallowState = useCallback((newValue: T) => {

Choose a reason for hiding this comment

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

한 가지 개선할 점을 제안하자면, useState의 set 함수처럼 setShallowState도 함수를 인자로 받을 수 있도록 기능을 확장하는 것은 어떨까요?

예를 들어, setShallowState(prev => ({ ...prev, count: prev.count + 1 }))와 같은 패턴을 지원하면 사용자가 더 유연하게 상태를 관리할 수 있게 되어 훅의 완성도가 높아질 것 같습니다.

// shallowEquals를 통해 상태 변경을 감지
setState((prev) => (shallowEquals(prev, newValue) ? prev : newValue));

Choose a reason for hiding this comment

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

저는 setState 바깥에서 새로운 값을 계산했는데, setState 콜백 안에서 prevValue를 사용해야 여러 번의 상태 업데이트가 연달아 일어날 때도 stale state 문제 없이 항상 최신 상태를 기반으로 값을 계산할 수 있겠네요. 제가 놓쳤던 부분인데 다른 분들은 다 꼼꼼히 챙기셔서 약간 슬퍼졌어요...

}, []);
return [state, setShallowState] as const;
};
2 changes: 1 addition & 1 deletion packages/lib/src/hooks/useStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ type Storage<T> = ReturnType<typeof createStorage<T>>;

export const useStorage = <T>(storage: Storage<T>) => {
// useSyncExternalStore를 사용해서 storage의 상태를 구독하고 가져오는 훅을 구현해보세요.
return storage.get();
return useSyncExternalStore(storage.subscribe, storage.get);
};
3 changes: 2 additions & 1 deletion packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ const defaultSelector = <T, S = T>(state: T) => state as unknown as S;
export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>) => {
// useSyncExternalStore와 useShallowSelector를 사용해서 store의 상태를 구독하고 가져오는 훅을 구현해보세요.
const shallowSelector = useShallowSelector(selector);
return shallowSelector(store.getState());

return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState()));

Choose a reason for hiding this comment

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

작성해주신 useSyncExternalStore 인터페이스만 봤을 때는 구체적으로 어떤 일을 하는지 직관적이지 않은 것 같아요!
함수에 이름을 붙여주는건 어떨까요?!

const subscribeToStore = store.subscribe;
const getCurrentState = () => shallowSelector(store.getState());

return useSyncExternalStore(
  subscribeToStore,
  getCurrentState,
);

Copy link
Author

Choose a reason for hiding this comment

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

확실히 다시 보니까 의도가 명확하게 보이지 않는 것 같아요,, 의견 주셔서 감사합니다!

};