-
Notifications
You must be signed in to change notification settings - Fork 56
[9팀 박상수] Chapter 1-3. React, Beyond the Basics #59
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9315e3e
2058fbc
1ba494a
5790905
1c4a236
02ccc02
d9a1ba1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, useContext, useReducer, useMemo, useCallback } from "react"; | ||
| import { createPortal } from "react-dom"; | ||
| import { Toast } from "./Toast"; | ||
| import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer"; | ||
|
|
@@ -9,44 +9,65 @@ 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 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(ToastContext); | ||
| export const useToastState = () => useContext(ToastStateContext); | ||
|
|
||
| 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 = useCallback( | ||
| (...args) => { | ||
| show(...args); | ||
| hideAfter(); | ||
| }, | ||
| [show, hideAfter], | ||
| ); | ||
|
|
||
| const showWithHide: ShowToast = (...args) => { | ||
| show(...args); | ||
| hideAfter(); | ||
| }; | ||
| const commandContextValue = useMemo( | ||
| () => ({ | ||
| show: showWithHide, | ||
| hide, | ||
| }), | ||
| [showWithHide, hide], | ||
| ); | ||
|
|
||
| const stateContextValue = 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> | ||
| <ToastContext.Provider value={commandContextValue}> | ||
| <ToastStateContext.Provider value={stateContextValue}> | ||
| {children} | ||
| {visible && createPortal(<MemoizedToast />, document.body)} | ||
| </ToastStateContext.Provider> | ||
| </ToastContext.Provider> | ||
| ); | ||
| }); | ||
|
|
||
| // Toast는 외부 상태 변화와 무관하게 고정되도록 메모이제이션 | ||
| const MemoizedToast = memo(Toast); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 토스트 자체를 메모이제이션하는 건 생각못했는데 리렌더링 문제를 해결하기 위한 새로운 관점을 배웠습니다! 다만 이 프로젝트의 토스트를 봤을 때 토스트의 state는 전부 provider로 제공되는 값이여서 토스트 컴포넌트에 대한 메모이제이션은 현재로서는 불필요하지 않나 생각이 듭니다. |
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)을 호출하는 거죠. 이런 방식을 고려한다면 중복되는 구조를 깔끔하게 해결할 수 있을 것 같아요! |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,28 @@ | ||
| export const deepEquals = (a: unknown, b: unknown) => { | ||
| return a === b; | ||
| export const deepEquals = (a: unknown, b: unknown): boolean => { | ||
| if (a === b) return true; | ||
|
|
||
| // null 검사 + 타입 비교 | ||
| if (typeof a !== typeof b || a === null || b === null) return false; | ||
|
|
||
| // 배열 비교 | ||
| if (Array.isArray(a) && Array.isArray(b)) { | ||
| if (a.length !== b.length) return false; | ||
| return a.every((val, i) => deepEquals(val, b[i])); // 재귀 (DFS) | ||
| } | ||
|
Comment on lines
+8
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저는 처음에 배열도 '인덱스를 키로 갖는 객체'와 유사하다고 생각해서, 두 타입을 구분하지 않고 객체 비교 로직으로 한 번에 처리했었는데요. 다른 분들 코드를 보고 있다보니 ['a']와 { '0': 'a', length: 1 }이 같게 처리될 수 있지만, 실제로는 타입도 다르고 .map 같은 배열 메서드도 사용할 수 없다는 점에서 예상치 못한 부작용이 생길 수 있겠다는 걸 깨달았습니다. |
||
|
|
||
| // 객체 비교 | ||
| if (typeof a === "object" && typeof b === "object") { | ||
| const aObj = a as Record<string, unknown>; | ||
| const bObj = b as Record<string, unknown>; | ||
| const aKeys = Object.keys(aObj); | ||
| const bKeys = Object.keys(bObj); | ||
| if (aKeys.length !== bKeys.length) return false; | ||
|
|
||
| return aKeys.every( | ||
| (key) => Object.prototype.hasOwnProperty.call(bObj, key) && deepEquals(aObj[key], bObj[key]), // 재귀 (DFS) | ||
| ); | ||
|
Comment on lines
+21
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 객체의 속성 존재 여부를 확인할 때 Object.prototype.hasOwnProperty.call() 대신 최신 자바스크립트(ES2022)에 추가된 Object.hasOwn()을 사용하면 어떨까요? Object.hasOwn(b, key) 형태로 사용하면 call 없이도 안전하게 속성을 확인할 수 있어서 코드가 조금 더 간결하고 직관적으로 변하는 효과가 있을 것 같아요! |
||
| } | ||
|
|
||
| // 그 외 값은 === | ||
| return false; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,19 @@ | ||
| export const shallowEquals = (a: unknown, b: unknown) => { | ||
| if (Array.isArray(a) && Array.isArray(b)) { | ||
| if (a.length !== b.length) return false; | ||
|
|
||
| return a.every((val, index) => val === b[index]); | ||
| } | ||
|
Comment on lines
+2
to
+6
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. deepEqual이랑 비슷한 처리를 하다보니 중복 로직이 생기는 것 같은데, 유틸로 분리해보면 좋을 것 같아요~ |
||
|
|
||
| if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) { | ||
| const aObj = a as Record<string, unknown>; | ||
| const bObj = b as Record<string, unknown>; | ||
| const aKeys = Object.keys(aObj); | ||
| const bKeys = Object.keys(bObj); | ||
|
|
||
| if (aKeys.length !== bKeys.length) return false; | ||
|
|
||
| return aKeys.every((key) => Object.prototype.hasOwnProperty.call(bObj, key) && aObj[key] === bObj[key]); | ||
| } | ||
| return a === b; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import type { FunctionComponent } from "react"; | ||
| import { type FunctionComponent, memo as reactMemo } from "react"; | ||
| import { deepEquals } from "../equals"; | ||
|
|
||
| export function deepMemo<P extends object>(Component: FunctionComponent<P>) { | ||
| return Component; | ||
| return reactMemo(Component, deepEquals); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import { type FunctionComponent } from "react"; | ||
| import { type FunctionComponent, memo as reactMemo } from "react"; | ||
| import { shallowEquals } from "../equals"; | ||
|
|
||
| export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) { | ||
| return Component; | ||
| return reactMemo(Component, equals); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,5 +3,18 @@ import { useCallback } from "./useCallback"; | |
| import { useRef } from "./useRef"; | ||
|
|
||
| export const useAutoCallback = <T extends AnyFunction>(fn: T): T => { | ||
| return fn; | ||
| const ref = useRef(fn); | ||
|
|
||
| // 매 렌더마다 최신 함수로 갱신 | ||
| ref.current = fn; | ||
|
Comment on lines
+6
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저는 if 문을 추가해서 이전 callback과 현재 callback을 비교하는 과정을 추가했었는데 지금와서 생각해보면 과한 로직인 것 같아요. |
||
|
|
||
| // 참조는 고정되지만, 내부 로직은 항상 최신 | ||
| const stableCallback = useCallback( | ||
| ((...args: unknown[]) => { | ||
| return ref.current(...args); | ||
| }) as T, | ||
| [], | ||
| ); | ||
|
|
||
| return stableCallback; | ||
| }; | ||
| 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"; // ✅ 직접 만든 useMemo 사용 | ||
|
|
||
| export function useCallback<T extends Function>(factory: T, _deps: DependencyList) { | ||
| // 직접 작성한 useMemo를 통해서 만들어보세요. | ||
| return factory as T; | ||
| export function useCallback<T>(factory: T, _deps: DependencyList): T { | ||
| // ✅ useMemo를 이용해 메모이제이션된 콜백 함수 반환 | ||
| return useMemo(() => factory, _deps); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,19 @@ | ||
| /* 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(); | ||
| const ref = useRef<{ | ||
| deps: DependencyList; | ||
| value: T; | ||
| } | null>(null); | ||
|
|
||
| if (!ref.current || !_equals(ref.current.deps, _deps)) { | ||
| ref.current = { | ||
| deps: _deps, | ||
| value: factory(), | ||
| }; | ||
| } | ||
|
|
||
| return ref.current.value; | ||
| } |
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 타입 안정성을 더 높일 수 있는 방법이 있어서 공유드려요! 실제 React의 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];
}이렇게 하면 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,8 @@ | ||
| import { useState } from "react"; | ||
|
|
||
| export function useRef<T>(initialValue: T): { current: T } { | ||
| // useState를 이용해서 만들어보세요. | ||
| return { current: initialValue }; | ||
| const [ref] = useState(() => ({ current: initialValue })); | ||
|
|
||
| return ref; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,5 +8,12 @@ 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, () => { | ||
| const selected = shallowSelector(router); | ||
| // shallowSelector가 null을 반환할 수 있으므로, fallback 처리 | ||
| if (selected === null) { | ||
| return selector(router); | ||
| } | ||
| return selected; | ||
| }) as S; | ||
|
Comment on lines
+11
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
const subscribeToRouter = router.subscribe;
const getRouterState = () => {
const selected = shallowSelector(router);
// shallowSelector가 null을 반환할 수 있으므로, fallback 처리
if (selected === null) {
return selector(router);
}
return selected;
};
return useSyncExternalStore(
subscribeToRouter,
getRouterState,
) as S; |
||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,11 @@ import { shallowEquals } from "../equals"; | |
| type Selector<T, S = T> = (state: T) => S; | ||
|
|
||
| export const useShallowSelector = <T, S = T>(selector: Selector<T, S>) => { | ||
| const prev = useRef<S | null>(null); | ||
| // 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅을 구현합니다. | ||
| return (state: T): S => selector(state); | ||
|
|
||
| return (state: T) => { | ||
| const next = selector(state); | ||
| return shallowEquals(prev.current, next) ? prev.current : (prev.current = next); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 상수님 코드 보니까 왜 전 삼항자를 쓰지 않았는지 바로 제 코드가 생각났습니다ㅎㅎ 감사합니다. |
||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,18 @@ | ||
| 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, (next: T) => void] => { | ||
| const [state, setState] = useState(initialValue); | ||
|
|
||
| // ✅ 항상 같은 함수를 반환해야 하므로 useCallback | ||
| const setShallow = useCallback((next: T) => { | ||
| setState((prev) => { | ||
| if (shallowEquals(prev, next)) { | ||
| return prev; // 동일하므로 상태 변경 없이 리렌더 방지 | ||
| } | ||
| return next; // shallow하지 않으면 상태 변경 | ||
| }); | ||
|
Comment on lines
+9
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저는 setState 바깥에서 새로운 값을 계산했는데, setState 콜백 안에서 prevValue를 사용해야 여러 번의 상태 업데이트가 연달아 일어날 때도 stale state 문제 없이 항상 최신 상태를 기반으로 값을 계산할 수 있겠네요. 제가 놓쳤던 부분인데 다른 분들은 다 꼼꼼히 챙기셔서 약간 슬퍼졌어요... |
||
| }, []); | ||
|
|
||
| return [state, setShallow]; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,21 @@ | ||
| import type { createStore } from "../createStore"; | ||
| import { useSyncExternalStore } from "react"; | ||
| import type { createStore } from "../createStore"; | ||
| import { useShallowSelector } from "./useShallowSelector"; | ||
|
|
||
| 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의 상태를 구독하고 가져오는 훅을 구현해보세요. | ||
| export const useStore = <T, S = T>(store: Store<T>, selector: (state: T) => S = defaultSelector<T, S>): S => { | ||
| const shallowSelector = useShallowSelector(selector); | ||
| return shallowSelector(store.getState()); | ||
|
|
||
| // shallowSelector가 null을 반환할 수 있으므로, null일 경우 store의 현재 상태를 selector로 변환하여 반환 | ||
| return useSyncExternalStore(store.subscribe, () => { | ||
| const selected = shallowSelector(store.getState()); | ||
| // shallowSelector가 null을 반환할 수 있으므로, fallback 처리 | ||
| if (selected === null) { | ||
| return selector(store.getState()); | ||
| } | ||
| return selected; | ||
| }) as S; | ||
| }; |
There was a problem hiding this comment.
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로 감싸는 등 반복적인 작업을 하나로 묶어줄 수 있어요.이 헬퍼를 적용하면, 현재 컨텍스트를 생성하는 부분을 아래처럼 바꿀 수 있습니다.