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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,5 @@ dist-ssr
/test-results/
/playwright-report/
/coverage/

/docs
9 changes: 5 additions & 4 deletions packages/app/src/components/modal/ModalProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ export const useModalContext = () => useContext(ModalContext);
export const ModalProvider = memo(({ children }: PropsWithChildren) => {
const [content, setContent] = useState<ReactNode>(null);

const open = (newContent: ReactNode) => setContent(newContent);

const close = () => setContent(null);
const [actions] = useState(() => ({
open: (newContent: ReactNode) => setContent(newContent),
close: () => setContent(null),
}));

return (
<ModalContext value={{ open, close }}>
<ModalContext value={actions}>
{children}
{content && createPortal(<Modal>{content}</Modal>, document.body)}
</ModalContext>
Expand Down
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.

우라까이하겠습니다

Original file line number Diff line number Diff line change
@@ -1,33 +1,36 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react";
import { createContext, memo, type PropsWithChildren, useContext, useReducer, useState } 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 } from "@hanghae-plus/lib/src/hooks";

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

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

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

const DEFAULT_DELAY = 3000;

const useToastContext = () => useContext(ToastContext);
const useActionToastContext = () => useContext(ToastActionContext);
const useToastStateContext = () => useContext(ToastStateContext);

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

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

Expand All @@ -36,17 +39,26 @@ export const ToastProvider = memo(({ children }: PropsWithChildren) => {
const { show, hide } = createActions(dispatch);
const visible = state.message !== "";

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

const showWithHide: ShowToast = (...args) => {
show(...args);
hideAfter();
};

const stateValue = useMemo(() => ({ ...state }), [state]);

const [actions] = useState(() => ({
show: showWithHide,
hide,
}));

return (
<ToastContext value={{ show: showWithHide, hide, ...state }}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastContext>
<ToastStateContext value={stateValue}>
<ToastActionContext value={actions}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastActionContext>
</ToastStateContext>
);
});
2 changes: 1 addition & 1 deletion packages/lib/src/createObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ type Listener = () => void;
export const createObserver = () => {
const listeners = new Set<Listener>();

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

const unsubscribe = (fn: Listener) => {
Expand Down
21 changes: 18 additions & 3 deletions 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.

저는 depth라는 인자를 추가해서 비교의 깊이를 조절할 수 있게 만들었어요. 이 depth 값을 재귀적으로 1씩 줄여나가다가 0이 되면 === 비교로 마무리하는 방식입니다. 이렇게 하면 deepEquals와 shallowEquals를 하나의 함수로 통합할 수 있는 장점이 있습니다. 예를 들어, shallowEquals(a, b)는 내부적으로 deepEquals(a, b, 1)을 호출하는 거죠. 이런 방식을 고려한다면 중복되는 구조를 깔끔하게 해결할 수 있을 것 같아요!

Copy link
Author

@ldhldh07 ldhldh07 Jul 25, 2025

Choose a reason for hiding this comment

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

좋은 아이디어인 것 같습니다!
그럴 경우 얇은 비교는 비교지만 depth는 1인 비교이다라는 의도 또한 전달할 수도 있다는 점도 좋네요.

개인적으로는 두개의 비교를 독립적으로 분리하고 싶어서 코드가 중복되더라도 별개의 로직으로 만드는 쪽이 좀 더 끌리긴 합니다. 코드의 중복을 줄이느냐, 아니면 하나의 코드 묶음에서 진행 과정을 명확히 하느냐의 밸런스에 대해 문제도 개인적으로 고민하던 점이었습니다.

처음에는 코드를 짧게 만들고 파편화 해서 재사용하는 쪽을 좋아했는데, 가면 갈수록 좀 더 명시적인 코드와 관심사를 분산시키지 않고 하나의 파일에 작성하는 것에 좀 더 무게를 두게 되더라구요. 이 맥락에서 deepEqualshallowEqual가 중복되는 부분의 복잡도가 높지도 않아 각각의 파일 내의 코드만 봐도 독립적으로 그 동작 흐름을 알 수 있게 하고 싶은 마음이 살짝 더 큽니다.

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,3 +1,18 @@
export const deepEquals = (a: unknown, b: unknown) => {
return a === b;
};
export function deepEquals(objA: unknown, objB: unknown): boolean {
if (objA === objB) return true;

if (objA == null || objB == null || typeof objA !== "object" || typeof objB !== "object") return false;

const keysOfA = Object.keys(objA);
const keysOfB = Object.keys(objB);

if (keysOfA.length !== keysOfB.length) return false;

for (const key of keysOfA) {
const valueA = (objA as Record<string, unknown>)[key];
const valueB = (objB as Record<string, unknown>)[key];
if (!deepEquals(valueA, valueB)) return false;
}
Comment on lines +11 to +15

Choose a reason for hiding this comment

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

저도 처음에는 두현님처럼 두 타입을 하나의 로직으로 묶어 처리하는 게 간결하다고 생각했습니다. 그런데 ['a']{ '0': 'a', length: 1 }이 같게 처리될 수 있지만, 실제로는 타입도 다르고 .map 같은 배열 메서드도 사용할 수 없다는 점에서 예상치 못한 부작용이 생길 수 있겠다는 걸 깨달았습니다.

혹시 이런 차이점에도 불구하고 코드를 통합해서 얻는 이점이 더 크다고 보시는지, 아니면 분리하는 게 더 낫다고 생각하시는지 그 설계에 대한 두현님의 생각이 궁금합니다!

Copy link
Author

Choose a reason for hiding this comment

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

말씀 들어보고 생각해보니 나눠서 처리하는게 더 정교하고 부작용도 없겠네요.


return true;
}
23 changes: 20 additions & 3 deletions packages/lib/src/equals/shallowEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
export const shallowEquals = (a: unknown, b: unknown) => {
return a === b;
};
export function shallowEquals(objA: unknown, objB: unknown): boolean {
if (objA === objB) return true;

if (objA == null || objB == null || typeof objA !== "object" || typeof objB !== "object") return false;

const keysOfA = Object.keys(objA);
const keysOfB = Object.keys(objB);

if (keysOfA.length !== keysOfB.length) return false;
Comment on lines +2 to +9

Choose a reason for hiding this comment

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

요런 중복은 별도의 유틸함수로 빼도 괜찮은 것 같아요!
실제로 제가 적용했던 유틸입니당

export const compareObjectProperties = (a: ObjectType, b: ObjectType, equals: Equals) => {
  const aKeys = Object.keys(a);
  const bKeys = Object.keys(b);

  if (aKeys.length !== bKeys.length) return false;

  return aKeys.every((key) => equals(a[key], b[key]));
};


for (const key of keysOfA) {
const valueA = (objA as Record<string, unknown>)[key];
const valueB = (objB as Record<string, unknown>)[key];
if (valueA !== valueB) {
return false;
}
}

return true;
}
5 changes: 3 additions & 2 deletions packages/lib/src/hocs/deepMemo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FunctionComponent } from "react";
import { memo, type FunctionComponent } from "react";
import { deepEquals } from "../equals";

export function deepMemo<P extends object>(Component: FunctionComponent<P>) {
return Component;
return memo<P>(Component, deepEquals);
}
18 changes: 16 additions & 2 deletions packages/lib/src/hocs/memo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { type FunctionComponent } from "react";
import { createElement, type FunctionComponent, type ReactElement } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "../hooks";

export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
return Component;
return function MemorizedComponent(props: P) {
const propsRef = useRef<P | null>(null);
const componentRef = useRef<ReactElement | null>(null);

const isInitialRender = propsRef.current === null;
const propsChanged = !equals(propsRef.current, props);

if (isInitialRender || propsChanged) {
propsRef.current = props;
componentRef.current = createElement(Component, props);
}

return componentRef.current;
};
}
9 changes: 8 additions & 1 deletion packages/lib/src/hooks/useAutoCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,12 @@ import { useCallback } from "./useCallback";
import { useRef } from "./useRef";

export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
return fn;
const fnRef = useRef<T>(fn);
fnRef.current = fn;
Comment on lines +6 to +7

Choose a reason for hiding this comment

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

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


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

return memoizedCallback as T;
};
11 changes: 6 additions & 5 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 */
import type { DependencyList } from "react";
/* eslint-disable react-hooks/exhaustive-deps */
/* 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;
export function useCallback<T extends Function>(factory: T, deps: DependencyList): T {
return useMemo(() => factory, deps);
}
21 changes: 17 additions & 4 deletions packages/lib/src/hooks/useMemo.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { DependencyList } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "./useRef";

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

export function useMemo<T>(factory: () => T, deps: DependencyList, equals = shallowEquals): T {
const ref = useRef<MemoCache<T> | null>(null);

if (ref.current?.deps && equals(ref.current.deps, deps)) return ref.current.value;

ref.current = {
deps: deps,
value: factory(),
};

return ref.current.value;
}
13 changes: 10 additions & 3 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.

값을 저장하고 자유롭게 수정하는 MutableRefObject를 완벽하게 재현하고 있는 것 같아요. 그리고 저도 리뷰를 달다가 생각난건데 useRef의 또 다른 주요 용도인 DOM 엘리먼트를 참조하는 경우(useRef<HTMLDivElement>(null))에 대해서는 어떻게 생각하시는지 궁금합니다. 이 경우에는 current가 읽기 전용인 RefObject<T> 타입을 사용하는 것이 일반적인데, 이런 부분까지 고려하여 타입을 확장해보는 것도 재미있을 것 같아요!

Copy link

@jun17183 jun17183 Jul 25, 2025

Choose a reason for hiding this comment

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

readonly 타입인 RefObject을 만들고 export function useRef<T>(initialValue: null): RefObject<T>;와 같이 작성하면 될까요?

Choose a reason for hiding this comment

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

오호이 지수님 좋은 의견이네요

Choose a reason for hiding this comment

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

두현님 멋집니다,, PR에서 useRef의 동작 원리에 대해 깊게 고민하신거 인상 깊게 읽었어요..!

useState로 useRef의 참조 안정성을 구현하신게 재밌었습니다. useState가 컴포넌트 라이프사이클 동안에 참조를 유지해준다는 걸 잘 활용하신 것 같아요. (저는 이런 생각 몬했을듯)

Choose a reason for hiding this comment

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

찾아보니 실제 useRef는 내부적으로 컴포넌트 인스턴스에 값을 저장하는 방식으로 동작한다고 합니다. 리렌더링이 발생해도 해당 컴포넌트 인스턴스는 유지돼서 저장된 ref 객체 또한 동일한 참조를 유지하게 된다고 하는데 현재 useState를 사용하신 구현 방식 외에 다른 방식으로도 useRef의 핵심 기능을 구현해볼 수 있지 않을까 싶어요!

Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
export function useRef<T>(initialValue: T): { current: T } {
// useState를 이용해서 만들어보세요.
return { current: initialValue };
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> {
Comment on lines +7 to +9

Choose a reason for hiding this comment

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

👍

return useState(() => ({ current: initialValue }))[0];
}
5 changes: 3 additions & 2 deletions packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { useShallowSelector } from "./useShallowSelector";
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);
const snapshot = () => shallowSelector(router);

return useSyncExternalStore(router.subscribe, snapshot);
};
13 changes: 11 additions & 2 deletions packages/lib/src/hooks/useShallowSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import { shallowEquals } from "../equals";
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 stateRef = useRef<S>(null);

return (state: T): S => {
const newState = selector(state);

if (stateRef.current === null || !shallowEquals(stateRef.current, newState)) {
stateRef.current = newState;
}

return stateRef.current;
};
};
16 changes: 12 additions & 4 deletions packages/lib/src/hooks/useShallowState.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { shallowEquals } from "../equals";

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

const setShallowState = useCallback((newValue: T) => {
setState((currentValue) => {
if (shallowEquals(currentValue, newValue)) return currentValue;
else return newValue;
});
Comment on lines +8 to +11

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];
};
3 changes: 1 addition & 2 deletions packages/lib/src/hooks/useStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,5 @@ import type { createStorage } from "../createStorage";
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);
};
5 changes: 3 additions & 2 deletions packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ type Store<T> = ReturnType<typeof createStore<T>>;
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());
const snapshot = () => shallowSelector(store.getState());

return useSyncExternalStore(store.subscribe, snapshot);
};