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
32 changes: 32 additions & 0 deletions .github/ISSUE_TEMPLATE/task-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
name: task template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: 0miiii

---

## 📌 제목
[Component] 로그인 페이지 구현
[Page] 상품 상세 페이지 UI/UX 개발
[Refactor] useProduct 훅 모듈 분리 등

---

## 🎯 구현 목표
- [ ] 버튼, 인풋 등 컴포넌트 UI 구현
- [ ] 상태에 따라 submit 가능 여부 제어
- [ ] 로그인 요청 API 연동 (`POST /auth/login`)
- [ ] 성공 시 토큰 저장 및 홈 리디렉션
- [ ] 실패 시 에러 메시지 표시

---

## 🧩 설명
- 해당 이슈에서 구현하거나 리팩토링할 프론트엔드 기능의 개요를 작성해주세요.
- 해당 기능이 어떤 사용자 시나리오(UX)에 대응하는지도 명시해주세요.

예:
- 사용자가 이메일/비밀번호 입력 후 로그인 요청을 보낼 수 있도록 로그인 페이지 UI를 구성합니다.
- 상태에 따라 버튼 상태 변화 및 에러 메시지를 처리합니다.
60 changes: 29 additions & 31 deletions packages/app/src/components/toast/ToastProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react";
import { createContext, memo, type PropsWithChildren, useCallback, useContext, useMemo, useReducer } from "react";
import { createPortal } from "react-dom";
import { Toast } from "./Toast";
import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer";
Expand All @@ -8,45 +8,43 @@ import { debounce } from "../../utils";
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<{ message: string; type: ToastType }>(initialState);
const ToastCommandContext = createContext<{ show: ShowToast; hide: Hide }>({ show: () => null, hide: () => null });

const DEFAULT_DELAY = 3000;
export const useToastState = () => useContext(ToastStateContext);
export const useToastCommand = () => useContext(ToastCommandContext);

const useToastContext = () => useContext(ToastContext);
export const useToastCommand = () => {
const { show, hide } = useToastContext();
return { show, hide };
};
export const useToastState = () => {
const { message, type } = useToastContext();
return { message, type };
};
const DEFAULT_DELAY = 3000;

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) => {
show(...args);
hideAfter();
};
const showWithHide: ShowToast = useCallback(
(...args) => {
show(...args);
hideAfter();
},
[hideAfter, show],
);

const command = useMemo(
() => ({
show: showWithHide,
hide,
}),
[hide, showWithHide],
);

return (
<ToastContext value={{ show: showWithHide, hide, ...state }}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastContext>
<ToastCommandContext value={command}>
<ToastStateContext value={state}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastStateContext>
</ToastCommandContext>
);
});
4 changes: 1 addition & 3 deletions packages/lib/src/createObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ export const createObserver = () => {
// 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
23 changes: 22 additions & 1 deletion packages/lib/src/equals/deepEquals.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
export const deepEquals = (a: unknown, b: unknown) => {
import { isObject } from "./utils";

export const deepEquals = (a: unknown, b: unknown): boolean => {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) {
return false;
}

return a.every((item, index) => deepEquals(item, b[index]));
}

if (isObject(a) && isObject(b)) {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);

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

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

return a === b;
};

Choose a reason for hiding this comment

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

1depth 비교만 해도 충분한 함수 또는 객체가 재귀적으로 deepEquals 함수로 호출될 것 같은데, 성능최적화를 고려해서 1depth일 때는 early return 또는 shallowEquals 함수로 비교하고, 2depth 이상 일 때만 재귀적으로 deepEquals 함수를 호출하는 방안으로 추후 개선해보시면 어떨까요~?

21 changes: 21 additions & 0 deletions packages/lib/src/equals/shallowEquals.ts

Choose a reason for hiding this comment

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

오 every()를 사용하셨네요 ! 전 for 루프를 통해 구현했는데 이 방법도 잘 알아갑니다. ㅎㅎ

Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
import { isObject } from "./utils";

export const shallowEquals = (a: unknown, b: unknown) => {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) {
return false;
}

return a.every((el, index) => el === b[index]);
}

if (isObject(a) && isObject(b)) {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);

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

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

return a === b;
};
3 changes: 3 additions & 0 deletions packages/lib/src/equals/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const isObject = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};

Choose a reason for hiding this comment

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

isObject를 별도의 메소드 없이 직접 구현하셨는데 엣지 케이스가 존재할 수 있을 것 같아요! 이 부분 리팩토링 하실 떄 한번 더 생각해 보시는 건 어떨까요??

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

export function deepMemo<P extends object>(Component: FunctionComponent<P>) {
return Component;
return memo(Component, deepEquals);
}
14 changes: 13 additions & 1 deletion packages/lib/src/hocs/memo.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.

useRef를 사용하면 인스턴스별로 props 상태를 안전하게 유지할 수 있을 것 같아요.

Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,17 @@ import { type FunctionComponent } from "react";
import { shallowEquals } from "../equals";

export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) {
return Component;
let prevProps: P | null = null;
let memoizedElement: ReturnType<typeof Component> | null = null;

const MemoizedComponent = (props: P) => {
if (!prevProps || !equals(prevProps, props)) {
prevProps = props;
memoizedElement = Component(props);
}

return memoizedElement;
};

return MemoizedComponent;
}
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;

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

return stableFn 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 */
/* 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;
return useMemo(() => factory, _deps);
}
21 changes: 18 additions & 3 deletions packages/lib/src/hooks/useMemo.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.

initialized로 초기 렌더 여부를 명시해준 점이 가독성과 추후 유지보수에 도움이 될 것 같네요.

Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
/* 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();
const ref = useRef<{
deps: DependencyList;
value: T | undefined;
initialized: boolean;
}>({
deps: [],
value: undefined,
initialized: false,
});

if (!ref.current.initialized || !_equals(ref.current.deps, _deps)) {
ref.current.value = factory();
ref.current.deps = _deps;
ref.current.initialized = true;
}
Comment on lines 5 to +20
Copy link

@j2h30728 j2h30728 Jul 25, 2025

Choose a reason for hiding this comment

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

value의 초기값으로 factory 함수를 넣게 된다면 initialized 값이 없어져도 되지않을까 생각이 듭니다.
영민님은 어떻게 생각하시나요?!


return ref.current.value as T;
}
11 changes: 8 additions & 3 deletions packages/lib/src/hooks/useRef.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export function useRef<T>(initialValue: T): { current: T } {
// useState를 이용해서 만들어보세요.
return { current: initialValue };
import { useState } from "react";

type Ref<T> = { current: T };

export function useRef<T>(initialValue: T): Ref<T> {
const [ref] = useState<Ref<T>>(() => ({ current: initialValue }));

return ref;
}
4 changes: 2 additions & 2 deletions packages/lib/src/hooks/useRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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(router.subscribe, () => shallowSelector(router));
};
15 changes: 13 additions & 2 deletions packages/lib/src/hooks/useShallowSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ 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 prevSelectedRef = useRef<S>(null);
const hasInitialized = useRef(false);

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

if (!hasInitialized.current || !shallowEquals(prevSelectedRef.current, selected)) {
hasInitialized.current = true;
prevSelectedRef.current = selected;
}

return prevSelectedRef.current as S;
};
};
18 changes: 15 additions & 3 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";
import { shallowEquals } from "../equals";
import { useCallback } from "./useCallback";

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((next: T) => {
setState((prev) => {
if (shallowEquals(prev, next)) {
return prev;
}

return next;
});
}, []);

return [state, setShallowState] as const;
};
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, storage.get);
};
4 changes: 2 additions & 2 deletions packages/lib/src/hooks/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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());

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