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
49 changes: 25 additions & 24 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.

useToastCommand 와 useToastState 안에서도 useMemo 처리를 해주신 이유가 궁금합니다.

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
Expand Up @@ -2,51 +2,52 @@
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 { createActions, initialState, toastReducer, type ToastState, type ToastType } from "./toastReducer";
import { debounce } from "../../utils";
import { useAutoCallback, useMemo } from "@hanghae-plus/lib/src/hooks";

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

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

const DEFAULT_DELAY = 3000;

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

return useMemo(() => ({ show, hide }), [show, hide]);
};
export const useToastState = () => {
const { message, type } = useToastContext();
return { message, type };
const { message, type } = useToastStateContext();

return useMemo(() => ({ message, type }), [message, type]);
};

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

return (
<ToastContext value={{ show: showWithHide, hide, ...state }}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastContext>
<ToastStateContext.Provider value={value}>
<ToastCommandContext.Provider value={commands}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastCommandContext.Provider>
</ToastStateContext.Provider>
);
});
4 changes: 3 additions & 1 deletion packages/lib/src/createObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export const createObserver = () => {
// useSyncExternalStore 에서 활용할 수 있도록 subscribe 함수를 수정합니다.
const subscribe = (fn: Listener) => {
listeners.add(fn);

return () => unsubscribe(fn);
};

const unsubscribe = (fn: Listener) => {
Expand All @@ -14,5 +16,5 @@ export const createObserver = () => {

const notify = () => listeners.forEach((listener) => listener());

return { subscribe, notify };
return { subscribe, notify, unsubscribe };
};
21 changes: 20 additions & 1 deletion packages/lib/src/equals/deepEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
export const deepEquals = (a: unknown, b: unknown) => {
import { getObjectKeys, isArray, isObject, isPrimitive } from "./utils";

const comparePrimitive = (a: unknown, b: unknown) => Object.is(a, b);

export const deepEquals = (a: unknown, b: unknown): boolean => {
if (isPrimitive(a) && isPrimitive(b)) {
return comparePrimitive(a, b);
}

if (isArray(a) && isArray(b)) {
return a.length === b.length && a.every((item, index) => deepEquals(item, b[index]));
}

if (isObject(a) && isObject(b)) {
const keysA = getObjectKeys(a);
const keysB = getObjectKeys(b);

return keysA.length === keysB.length && keysA.every((key) => deepEquals(a[key], b[key]));
}

return a === b;
};
19 changes: 19 additions & 0 deletions packages/lib/src/equals/shallowEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
import { getObjectKeys, isArray, isObject, isPrimitive } from "./utils";

const comparePrimitive = (a: unknown, b: unknown) => Object.is(a, b);

export const shallowEquals = (a: unknown, b: unknown) => {
if (isPrimitive(a) && isPrimitive(b)) {
return comparePrimitive(a, b);
}

if (isArray(a) && isArray(b)) {
return a.length === b.length && a.every((item, index) => comparePrimitive(item, b[index]));
}

if (isObject(a) && isObject(b)) {
const keysA = getObjectKeys(a);
const keysB = getObjectKeys(b);

return keysA.length === keysB.length && keysA.every((key) => comparePrimitive(a[key], b[key]));
}

return a === b;
};
14 changes: 14 additions & 0 deletions packages/lib/src/equals/utils.ts

Choose a reason for hiding this comment

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

utils로 타입 유틸 함수들을 잘 분리해두셔서 전체적인 가독성과 재사용성이 좋아진 것 같아요!

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type Primitive = string | number | boolean | null | undefined | bigint | symbol;

export const isArray = (value: unknown): value is unknown[] => Array.isArray(value);

export const isObject = (value: unknown): value is object => typeof value === "object" && value !== null;
Copy link

@minjaeleee minjaeleee Jul 26, 2025

Choose a reason for hiding this comment

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

객체를 판단하는 로직에서 엣지 케이스가 있을 것 같아요!
엣지 케이스에서 이 로직을 통과하면 getObjectKeys 함수 내부에서 런타임 에러가 발생할 수 있을 것 같은데, 이 부분 조금 더 고민해보면 어떨까요??

Copy link
Author

Choose a reason for hiding this comment

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

민재님 말씀대로 Plain object로 의도하고 작성해야겠네요.
isArray가 앞에 있어서 배열 정도는 걸러준다고 생각했는데 그렇게 되면 isObject는 isArray에 의존성을 갖게 돼요. 이거도 문제네요.
독립적인 함수로 보고 Regex나 Function과 같은 엣지 케이스도 잡는게 좋아보입니다.

혹시 제가 얘기한게 민재님 리뷰의 의도와 맞을까요?


export const isPrimitive = (value: unknown): value is Primitive => typeof value !== "object" || value === null;

export const getObjectKeys = <T extends object>(o: T): (keyof T)[] => Object.keys(o) as (keyof T)[];

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isFunction(value: any): value is (...args: any[]) => any {
return typeof value === "function";
}

Choose a reason for hiding this comment

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

util 파일로 함수화시켜서 적용하신 것 정말 좋네요! 저도 이렇게 별도 파일을 생성해서 가독성을 높여 리팩토링 해봐야겠습니다. 👍

20 changes: 18 additions & 2 deletions packages/lib/src/hocs/deepMemo.ts
Copy link

@suhyeon57 suhyeon57 Jul 25, 2025

Choose a reason for hiding this comment

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

용준님이 구현하신 memo를 통해서 deepmemo를 간단하게 구현하는 방법으로 수정하면 좋을 것 같아요.
저도 처음에 이렇게 작성했다가 수정했어요.

Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
import type { FunctionComponent } from "react";
import { useRef, type FunctionComponent } from "react";
import { deepEquals } from "../equals";

export function deepMemo<P extends object>(Component: FunctionComponent<P>) {
return Component;
const MemoizedComponent = (props: P) => {
const prevPropsRef = useRef<P | null>(null);
const prevResultRef = useRef<ReturnType<typeof Component> | null>(null);

if (prevPropsRef.current && deepEquals(prevPropsRef.current, props)) {
return prevResultRef.current!;
}

const result = Component(props);
prevPropsRef.current = props;
prevResultRef.current = result;

return result;
};

return MemoizedComponent;
}
19 changes: 17 additions & 2 deletions packages/lib/src/hocs/memo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { type FunctionComponent } from "react";
import { useRef, type FunctionComponent } from "react";
import { shallowEquals } from "../equals";

export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
return Component;
const MemoizedComponent = (props: P) => {
const prevPropsRef = useRef<P | null>(null);
const prevResultRef = useRef<ReturnType<typeof Component> | null>(null);

if (prevPropsRef.current && equals(prevPropsRef.current, props)) {
return prevResultRef.current!;
}

const result = Component(props);

Choose a reason for hiding this comment

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

이 부분에서 prevReulstRef.current를 return 시키지 않고 result를 return 시키시는 이유가 궁금합니다!

prevPropsRef.current = props;
prevResultRef.current = result;

return result;
};

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

export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
return fn;
const fnRef = useRef(fn);
fnRef.current = fn;

return useCallback((...args: Parameters<T>) => fnRef.current(...args), []) as T;
};
11 changes: 7 additions & 4 deletions packages/lib/src/hooks/useCallback.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-unsafe-function-type */
/* eslint-disable react-hooks/exhaustive-deps */
import type { DependencyList } from "react";
import { useMemo } from "./useMemo";
import type { AnyFunction } from "../types";

export function useCallback<T extends Function>(factory: T, _deps: DependencyList) {
// 직접 작성한 useMemo를 통해서 만들어보세요.
return factory as T;
export function useCallback<T extends AnyFunction>(factory: T, deps: DependencyList): T {
const memoized = useMemo(() => factory, [...deps]);

return memoized;
}
1 change: 0 additions & 1 deletion packages/lib/src/hooks/useDeepMemo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,5 @@ import { useMemo } from "./useMemo";
import { deepEquals } from "../equals";

export function useDeepMemo<T>(factory: () => T, deps: DependencyList): T {
// 직접 작성한 useMemo를 참고해서 만들어보세요.
return useMemo(factory, deps, deepEquals);
}
17 changes: 13 additions & 4 deletions packages/lib/src/hooks/useMemo.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { DependencyList } from "react";
import { useRef } from "./useRef";
import { shallowEquals } from "../equals";

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

if (isInitial.current || !_equals(prevDeps.current, deps)) {
isInitial.current = false;
memoized.current = factory();
prevDeps.current = deps;
}
Comment on lines +6 to +14

Choose a reason for hiding this comment

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

memoized가 undefined 값이면 최초 렌더링 값이지 않을까요?
isInitial 제거하는 방향은 어떠신가요?

Copy link
Author

@dkile dkile Jul 25, 2025

Choose a reason for hiding this comment

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

useMemo에서 undefined를 반환하면 실제 리액트에서는 아마 에러가 날텐데 일단 factory에도 undefined를 반환하는 경우를 생각해서 isInitial.current로 체킹하고 있어요. 반환값이 undefined면 아무래도 순수한 로직이 아니라는 뜻일테니 isInitial을 지우고 factory 반환값이 undefined면 에러를 뱉도록 하는게 더 맞는 것 같네요.

Choose a reason for hiding this comment

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

null 값을 넣어주는 방법도 있습니다!


return memoized.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 @@
import { useState } from "react";

export function useRef<T>(initialValue: T): { current: T } {
// useState를 이용해서 만들어보세요.
return { current: initialValue };
const [ref] = useState(() => ({
current: initialValue,
}));

return ref;
}
4 changes: 3 additions & 1 deletion packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@ 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 state = useSyncExternalStore(router.subscribe, () => shallowSelector(router));

return state;
};
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 prevResultRef = useRef<S | null>(null);

return (state: T): S => {
const currentResult = selector(state);
if (shallowEquals(prevResultRef.current, currentResult)) {
return prevResultRef.current!;
}

prevResultRef.current = currentResult;
return currentResult;
};
};
20 changes: 16 additions & 4 deletions packages/lib/src/hooks/useShallowState.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { useState } from "react";
/* eslint-disable react-hooks/exhaustive-deps */
import { useCallback, useState } from "react";
import { shallowEquals } from "../equals";
import { isFunction } from "../equals/utils";

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

const setShallowState = useCallback((value: T | ((prev: T) => T)) => {
const nextValue = isFunction(value) ? value(state) : value;
if (shallowEquals(state, nextValue)) {
return;
}

setState(nextValue);
}, []);

return [state, setShallowState] as const;
};
5 changes: 3 additions & 2 deletions packages/lib/src/hooks/useStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { createStorage } from "../createStorage";
type Storage<T> = ReturnType<typeof createStorage<T>>;

export const useStorage = <T>(storage: Storage<T>) => {
// useSyncExternalStore를 사용해서 storage의 상태를 구독하고 가져오는 훅을 구현해보세요.
return storage.get();
const state = useSyncExternalStore(storage.subscribe, storage.get);

return state;
};
4 changes: 3 additions & 1 deletion packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ 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 state = useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState()));

return state;
};