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
58 changes: 32 additions & 26 deletions packages/app/src/components/toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react";
import { createContext, useContext, useReducer, type PropsWithChildren } from "react";
import { useAutoCallback, useMemo } from "@hanghae-plus/lib";
import { createPortal } from "react-dom";
import { Toast } from "./Toast";
import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer";
Expand All @@ -8,45 +9,50 @@ import { debounce } from "../../utils";
type ShowToast = (message: string, type: ToastType) => void;
type Hide = () => void;

const ToastContext = createContext<{
message: string;
type: ToastType;
// 명령 context: show/hide만 제공
const ToastCommandContext = createContext<{
show: ShowToast;
hide: Hide;
}>({
...initialState,
show: () => null,
hide: () => null,
});

// 상태 context: message/type만 제공
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) => {
export const ToastProvider = ({ children }: PropsWithChildren) => {
const [state, dispatch] = useReducer(toastReducer, initialState);
const { show, hide } = createActions(dispatch);
const visible = state.message !== "";

const hideAfter = debounce(hide, DEFAULT_DELAY);

const showWithHide: ShowToast = (...args) => {
const show = useAutoCallback((...args: Parameters<ShowToast>) => createActions(dispatch).show(...args));
const hide = useAutoCallback(() => createActions(dispatch).hide());
const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]);
const showWithHide: ShowToast = useAutoCallback((...args: Parameters<ShowToast>) => {
show(...args);
hideAfter();
};
});

// 명령 context value
const commandValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]);
// 상태 context value
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}
{state.message !== "" && createPortal(<Toast />, document.body)}
</ToastStateContext.Provider>
</ToastCommandContext.Provider>
);
});
};
2 changes: 1 addition & 1 deletion packages/app/src/entities/carts/cartStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ const cartReducer = (state: typeof initialState, action: any) => {
case CART_ACTIONS.LOAD_FROM_STORAGE:
return {
...state,
...action.payload,
...(action.payload || {}),
};

default:
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/entities/carts/components/CartItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { removeFromCart, toggleCartSelect, updateCartQuantity } from "../cartUse
import { PublicImage } from "../../../components";
import type { Cart } from "../types";
import { useCartStoreSelector } from "../hooks";
import { useMemo } from "react";
import { useMemo } from "@hanghae-plus/lib";

export function CartItem({ id }: Readonly<Pick<Cart, "id">>) {
const selector = useMemo(
Expand Down
6 changes: 1 addition & 5 deletions packages/lib/src/createObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,9 @@ type Listener = () => void;
export const createObserver = () => {
const listeners = new Set<Listener>();

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

const unsubscribe = (fn: Listener) => {
listeners.delete(fn);
return () => listeners.delete(fn);
};

const notify = () => listeners.forEach((listener) => listener());
Expand Down
25 changes: 22 additions & 3 deletions packages/lib/src/createStorage.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
import { createObserver } from "./createObserver.ts";

export const createStorage = <T>(key: string, storage = window.localStorage) => {
let data: T | null = JSON.parse(storage.getItem(key) ?? "null");
let data: T | null;
try {
const storedValue = storage.getItem(key);
if (storedValue === null) {
console.log(`[createStorage] No stored value for ${key}, initializing as null`);
data = null;
} else {
data = JSON.parse(storedValue);
console.log(`[createStorage] Successfully loaded ${key}:`, data);
}
} catch (error) {
console.error(`[createStorage] Error parsing data for ${key}:`, error);
data = null;
}

const { subscribe, notify } = createObserver();

const get = () => data;
const get = () => {
return data;
};

const set = (value: T) => {
try {
data = value;
storage.setItem(key, JSON.stringify(data));
const serialized = JSON.stringify(data);
storage.setItem(key, serialized);
console.log(`[createStorage] Successfully stored ${key}`);
notify();
} catch (error) {
console.error(`Error setting storage item for key "${key}":`, error);
Expand All @@ -20,6 +38,7 @@ export const createStorage = <T>(key: string, storage = window.localStorage) =>
try {
data = null;
storage.removeItem(key);
console.log(`[createStorage] Successfully removed ${key} from storage`);
notify();
} catch (error) {
console.error(`Error removing storage item for key "${key}":`, error);
Expand Down
3 changes: 2 additions & 1 deletion packages/lib/src/createStore.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createObserver } from "./createObserver";
import { shallowEquals } from "./equals";

export const createStore = <S, A = (args: { type: string; payload?: unknown }) => S>(
reducer: (state: S, action: A) => S,
Expand All @@ -12,7 +13,7 @@ export const createStore = <S, A = (args: { type: string; payload?: unknown }) =

const dispatch = (action: A) => {
const newState = reducer(state, action);
if (!Object.is(newState, state)) {
if (!shallowEquals(newState, state)) {
state = newState;
notify();
}
Expand Down
33 changes: 33 additions & 0 deletions packages/lib/src/equals/baseEquals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { isNullish, isPrimitive, isArray, isObject, isSameType } from "../utils";

type ComparisonCallback = (a: unknown, b: unknown) => boolean;

export const baseEquals = (a: unknown, b: unknown, compareValues: ComparisonCallback): boolean => {
Copy link

Choose a reason for hiding this comment

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

같은 코드베이스 반복되지 않도록 빼두신거 넘 좋은 것 같습니다 👍

if (a === b) return true;
if (isNullish(a) || isNullish(b)) return false;
if (isPrimitive(a) || isPrimitive(b)) return false;
if (!isSameType(a, b)) return false;

if (isArray(a) && isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!compareValues(a[i], b[i])) return false;
}
return true;
}

if (isObject(a) && isObject(b)) {
const keysA = Object.keys(a);
const keysB = Object.keys(b);

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

for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
if (!compareValues(a[key], b[key])) return false;
}
return true;
}

return false;
};
7 changes: 5 additions & 2 deletions packages/lib/src/equals/deepEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export const deepEquals = (a: unknown, b: unknown) => {
return a === b;
import { baseEquals } from "./baseEquals";

export const deepEquals = (a: unknown, b: unknown): boolean => {
// baseEquals를 사용하여 공통 로직 처리, 값 비교는 깊은 비교(재귀 호출)
return baseEquals(a, b, (valueA, valueB) => deepEquals(valueA, valueB));
};
1 change: 1 addition & 0 deletions packages/lib/src/equals/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./baseEquals";
export * from "./shallowEquals";
export * from "./deepEquals";
4 changes: 3 additions & 1 deletion packages/lib/src/equals/shallowEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { baseEquals } from "./baseEquals";

export const shallowEquals = (a: unknown, b: unknown) => {
return a === b;
return baseEquals(a, b, (valueA, valueB) => valueA === valueB);
};
5 changes: 4 additions & 1 deletion packages/lib/src/hocs/deepMemo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { FunctionComponent } from "react";
import { memo } from "./memo";
import { deepEquals } from "../equals";

// deepMemo HOC는 컴포넌트의 props를 깊은 비교하여 불필요한 리렌더링을 방지합니다.
export function deepMemo<P extends object>(Component: FunctionComponent<P>) {
return Component;
return memo(Component, deepEquals);
}
19 changes: 18 additions & 1 deletion packages/lib/src/hocs/memo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { type FunctionComponent } from "react";
import { shallowEquals } from "../equals";
import { useRef } from "../hooks/useRef";

export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
return Component;
return function MemoizedComponent(props: P) {
// 1. 이전 props와 결과 저장 (커스텀 useRef 사용)
const prevPropsRef = useRef<P | null>(null);
const prevResultRef = useRef<ReturnType<typeof Component> | null>(null);

// 2. props가 변경됐는지 비교
const isSame = prevPropsRef.current !== null && equals(prevPropsRef.current, props);

if (!isSame) {
// 3. 변경된 경우: 새로 렌더링
prevPropsRef.current = props;
prevResultRef.current = Component(props);
}

// 4. 변경되지 않은 경우: 이전 결과 재사용
return prevResultRef.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;
const fnRef = useRef(fn);
fnRef.current = fn;

return useCallback(
((...args: unknown[]) => {
return fnRef.current(...args);
}) as T,
[],
);
};
17 changes: 13 additions & 4 deletions packages/lib/src/hooks/useCallback.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-unsafe-function-type */
import type { DependencyList } from "react";
import { useRef } from "./useRef";
import { shallowEquals } from "../equals";

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

// 의존성이 변경되었는지 확인
if (!shallowEquals(deps, depsRef.current)) {
callbackRef.current = factory;
depsRef.current = deps;
}

return callbackRef.current;
}
36 changes: 32 additions & 4 deletions packages/lib/src/hooks/useMemo.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,36 @@
/* 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();
type MemoState<T> = { deps: DependencyList | undefined; value: T } | null;
type EqualsFn = (a: unknown, b: unknown) => boolean;
type Factory<T> = () => T;

const isFirstRender = <T>(state: MemoState<T>): boolean => state === null;

const hasDepsChanged = <T>(state: MemoState<T>, newDeps: DependencyList, equals: EqualsFn): boolean =>
state !== null && !equals(state.deps, newDeps);

const shouldRecalculate = <T>(state: MemoState<T>, deps: DependencyList, equals: EqualsFn): boolean =>
isFirstRender(state) || hasDepsChanged(state, deps, equals);

const createMemoState = <T>(deps: DependencyList, value: T): MemoState<T> => ({
deps,
value,
});

const calculateAndCache = <T>(factory: Factory<T>, deps: DependencyList, stateRef: { current: MemoState<T> }): T => {
const value = factory();
stateRef.current = createMemoState(deps, value);
return value;
};

const getCachedValue = <T>(state: MemoState<T>): T => state!.value;

export function useMemo<T>(factory: Factory<T>, deps: DependencyList, equals: EqualsFn = shallowEquals): T {
const memoRef = useRef<MemoState<T>>(null);

return shouldRecalculate(memoRef.current, deps, equals)
? calculateAndCache(factory, deps, memoRef)
: getCachedValue(memoRef.current);
}
7 changes: 5 additions & 2 deletions packages/lib/src/hooks/useRef.ts
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;
}
8 changes: 6 additions & 2 deletions packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ 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);

return useSyncExternalStore(
(onStoreChange) => router.subscribe(onStoreChange),
() => shallowSelector(router),
() => shallowSelector(router),
);
};
18 changes: 15 additions & 3 deletions packages/lib/src/hooks/useShallowSelector.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { useRef } from "react";
import { useRef } from "./useRef";
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 prevValueRef = useRef<S | null>(null);

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

// 이전 값과 비교하여 변경되었는지 확인
if (prevValueRef.current !== null && shallowEquals(prevValueRef.current, newValue)) {
return prevValueRef.current;
}

// 값이 변경되었으면 새로운 값으로 업데이트
prevValueRef.current = newValue;
return newValue;
};
};
Loading