-
Notifications
You must be signed in to change notification settings - Fork 56
[9팀 임규원] Chapter 1-3. React, Beyond the Basics #39
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
c51ff0a
8e88ac7
ca6559d
9170da7
e03febf
11688c6
21af6a3
d9e019f
17e64c6
1192862
5f0c4ad
a14fc56
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 |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| <!doctype html> | ||
| <html lang="ko"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>상품 쇼핑몰</title> | ||
| <script src="https://cdn.tailwindcss.com"></script> | ||
| <link rel="stylesheet" href="/src/styles.css"> | ||
| <script> | ||
| tailwind.config = { | ||
| theme: { | ||
| extend: { | ||
| colors: { | ||
| primary: "#3b82f6", | ||
| secondary: "#6b7280" | ||
| } | ||
| } | ||
| } | ||
| }; | ||
| </script> | ||
| </head> | ||
| <body class="bg-gray-50"> | ||
| <div id="root"></div> | ||
| <script type="module" src="/src/main.tsx"></script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,52 +1,64 @@ | ||
| /* eslint-disable react-refresh/only-export-components */ | ||
| 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 { debounce } from "../../utils"; | ||
| import { useMemo, useAutoCallback } from "@hanghae-plus/lib/src/hooks"; | ||
|
|
||
| type ShowToast = (message: string, type: ToastType) => void; | ||
| type Hide = () => void; | ||
|
|
||
| const ToastContext = createContext<{ | ||
| const ToastStateContext = createContext<{ | ||
| message: string; | ||
| type: ToastType; | ||
| }>({ | ||
| ...initialState, | ||
| }); | ||
|
|
||
| const ToastActionContext = createContext<{ | ||
| show: ShowToast; | ||
| hide: Hide; | ||
| }>({ | ||
| ...initialState, | ||
| show: () => null, | ||
| hide: () => null, | ||
| }); | ||
|
|
||
| const DEFAULT_DELAY = 3000; | ||
|
|
||
| const useToastContext = () => useContext(ToastContext); | ||
| export const useToastCommand = () => { | ||
| const { show, hide } = useToastContext(); | ||
| return { show, hide }; | ||
| }; | ||
| const useToastStateContext = () => useContext(ToastStateContext); | ||
| const useToastActionContext = () => useContext(ToastActionContext); | ||
|
|
||
| export const useToastState = () => { | ||
| const { message, type } = useToastContext(); | ||
| const { message, type } = useToastStateContext(); | ||
| return { message, type }; | ||
| }; | ||
|
|
||
| export const useToastCommand = () => { | ||
| const { show, hide } = useToastActionContext(); | ||
| return { show, hide }; | ||
| }; | ||
|
|
||
| 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 = (...args) => { | ||
| const showWithHide: ShowToast = useAutoCallback((...args) => { | ||
| show(...args); | ||
| hideAfter(); | ||
| }; | ||
| }); | ||
|
|
||
| const toastActionValue = useMemo(() => ({ show: showWithHide, hide }), [showWithHide, hide]); | ||
| const toastStateValue = 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> | ||
| <ToastActionContext.Provider value={toastActionValue}> | ||
| <ToastStateContext.Provider value={toastStateValue}> | ||
| {children} | ||
| {visible && createPortal(<Toast />, document.body)} | ||
| </ToastStateContext.Provider> | ||
| </ToastActionContext.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.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,36 @@ | ||
| export const deepEquals = (a: unknown, b: unknown) => { | ||
| return a === b; | ||
| // 기본 타입 값들을 정확히 비교 | ||
| if (a === b) return true; | ||
|
|
||
| // a, b 하나라도 객체가 아닌 경우 false return | ||
| if (a == null || b == null || typeof a !== "object" || typeof b !== "object") return false; | ||
|
|
||
| // 객체가 string key로 접근 가능하다고 명시 | ||
| const objA = a as Record<string, unknown>; | ||
| const objB = b as Record<string, unknown>; | ||
|
|
||
| if (Array.isArray(objA) && Array.isArray(objB)) { | ||
| if (objA.length !== objB.length) return false; | ||
|
|
||
| for (let i = 0; i < objA.length; i++) { | ||
| if (!deepEquals(objA[i], objB[i])) return false; | ||
| } | ||
|
Comment on lines
+15
to
+17
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. 객체의 모든 키가 특정 조건을 만족하는지 검사하는 로직은 every() 배열 메서드를 사용하면 더 간결하게 작성할 수 있습니다. 예를 들어, for 루프 대신 아래와 같이 한 줄로 표현할 수 있어 가독성을 높이는 데 도움이 될 것 같습니다. return keysA.every(key =>
Object.hasOwn(objB, key) && deepEquals(objA[key], objB[key])
);
Author
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. 인덱스를 사용해야한다고 생각해서 일반적인 for문을 썼는데, every를 까먹고 있었네요! 리마인드 된 것 같아서 감사합니다 ㅎㅎ |
||
| return true; | ||
| } | ||
|
Comment on lines
+12
to
+19
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. 배열과 객체를 비교하는 방식에 대해 궁금한 점이 있어요! 저는 처음에 배열도 '인덱스를 키로 갖는 객체'와 유사하다고 생각해서, 두 타입을 구분하지 않고 객체 비교 로직으로 한 번에 처리했었는데요. 규원님께서는 Array.isArray() 등으로 배열과 객체를 명확하게 구분해서 별도의 로직으로 처리해주셨더라고요. 이러한 방식이 비교의 정확성을 높이는 가장 확실한 방법이라고 생각하신 걸까요?
Author
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 keysA = Object.keys(objA); | ||
| const keysB = Object.keys(objB); | ||
|
|
||
| if (keysA.length !== keysB.length) return false; | ||
|
|
||
| for (let i = 0; i < keysA.length; i++) { | ||
| if (!Object.hasOwn(objB, keysA[i])) { | ||
| return false; | ||
| } | ||
| // 각 key에 매칭되는 값이 같은지 체크 | ||
| if (!deepEquals(objA[keysA[i]], objB[keysA[i]])) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,23 @@ | ||
| export const shallowEquals = (a: unknown, b: unknown) => { | ||
| return a === b; | ||
| // 기본 타입 값들을 정확히 비교 | ||
| if (a === b) return true; | ||
|
|
||
| // a, b 하나라도 객체가 아닌 경우 false return | ||
| if (a == null || b == null || typeof a !== "object" || typeof b !== "object") return false; | ||
|
|
||
| // 객체가 string key로 접근 가능하다고 명시 | ||
| const objA = a as Record<string, unknown>; | ||
| const objB = b as Record<string, unknown>; | ||
|
Comment on lines
+9
to
+10
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. 개인적인 제 취향인데요! type ObjectRecord = Record<string, unknown>;
const objA = a as ObjectRecord;
const objB = b as ObjectRecord;
Author
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 keysA = Object.keys(objA); | ||
| const keysB = Object.keys(objB); | ||
|
|
||
| // key 개수 비교 | ||
| if (keysA.length !== keysB.length) return false; | ||
|
|
||
| // 반복문으로 얕은 비교 | ||
| for (const key of keysA) { | ||
| if (!(key in b) || objA[key] !== objB[key]) return false; | ||
| } | ||
| return true; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,10 @@ | ||
| import type { FunctionComponent } from "react"; | ||
| import { memo } from "./memo.ts"; | ||
| import { deepEquals } from "../equals"; | ||
|
|
||
| // deepMemo HOC는 컴포넌트의 props를 깊은 비교하여 불필요한 리렌더링을 방지 | ||
| export function deepMemo<P extends object>(Component: FunctionComponent<P>) { | ||
| return Component; | ||
| // deepEquals 함수를 사용하여 props 비교 | ||
| // 앞에서 만든 memo도 활용 | ||
| return memo(Component, deepEquals); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,22 @@ | ||
| import { type FunctionComponent } from "react"; | ||
| import { type FunctionComponent, type ReactNode } from "react"; | ||
| import { shallowEquals } from "../equals"; | ||
| import { useRef } from "../hooks"; | ||
|
|
||
| // memo HOC는 컴포넌트의 props를 얕은 비교하여 불필요한 렌더링을 방지 | ||
| export function memo<P extends object>(Component: FunctionComponent<P>, equals = shallowEquals) { | ||
| return Component; | ||
| // 1. 이전 props를 저장할 ref 생성 | ||
| // 2. 메모이제이션된 컴포넌트 생성 | ||
| // 3. equals 함수를 사용하여 props 비교 | ||
| // 4. props가 변경된 경우에만 새로운 렌더링 수행 | ||
|
|
||
| return (props: P) => { | ||
| const prevPropsRef = useRef<P | null>(null); | ||
| const renderOutputRef = useRef<ReactNode | null>(null); | ||
|
|
||
| if (!equals(prevPropsRef.current, props)) { | ||
|
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(!prevPropsRef.current || !equals(prevPropsRef.current, props)); |
||
| prevPropsRef.current = props; | ||
| renderOutputRef.current = Component(props) as ReactNode; | ||
| } | ||
| return renderOutputRef.current; | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,5 +3,13 @@ import { useCallback } from "./useCallback"; | |
| import { useRef } from "./useRef"; | ||
|
|
||
| export const useAutoCallback = <T extends AnyFunction>(fn: T): T => { | ||
| return fn; | ||
| // useCallBack과 useRef를 이용하여 useAutoCallBack 구현 | ||
| const prevRef = useRef(fn); | ||
| prevRef.current = fn; | ||
|
Comment on lines
+7
to
+8
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 autoCallBack = useCallback((...args: Parameters<T>): ReturnType<T> => { | ||
| return prevRef.current(...args); | ||
| }, []); | ||
|
|
||
| return autoCallBack as T; | ||
| }; | ||
| 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.ts"; | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type | ||
| export function useCallback<T extends Function>(factory: T, _deps: DependencyList) { | ||
| // 직접 작성한 useMemo를 통해서 만들어보세요. | ||
| return factory as T; | ||
| return useMemo(() => factory, _deps); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,16 @@ | ||
| /* eslint-disable @typescript-eslint/no-unused-vars */ | ||
| import type { DependencyList } from "react"; | ||
| import { shallowEquals } from "../equals"; | ||
| import { useRef } from "./useRef.ts"; | ||
|
|
||
| export function useMemo<T>(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T { | ||
| // 직접 작성한 useRef를 통해서 만들어보세요. | ||
| return factory(); | ||
| const prevDepsRef = useRef<DependencyList | undefined>(undefined); | ||
| const memoizedValueRef = useRef<T | undefined>(undefined); | ||
|
|
||
| // 이전 deps가 없거나 이전 deqs가 있고, 현재 deps와 다르다면 다시 계산하기 | ||
| if (prevDepsRef.current === undefined || !_equals(prevDepsRef.current, _deps)) { | ||
| memoizedValueRef.current = factory(); | ||
| prevDepsRef.current = _deps; | ||
| } | ||
|
|
||
| return memoizedValueRef.current as T; | ||
| } |
|
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];
}이렇게 하면
Author
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,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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,14 @@ | ||
| import { useState } from "react"; | ||
| import { shallowEquals } from "../equals"; | ||
| import { useCallback } from "./useCallback.ts"; | ||
|
|
||
| export const useShallowState = <T>(initialValue: Parameters<typeof useState<T>>[0]) => { | ||
| // useState를 사용하여 상태를 관리하고, shallowEquals를 사용하여 상태 변경을 감지하는 훅을 구현합니다. | ||
| return useState(initialValue); | ||
| export const useShallowState = <T>(initialValue: T | (() => T)) => { | ||
| // 임의로 타입 조정 initialValue: T | (() => T) | ||
| const [state, setState] = useState<T>(initialValue); | ||
|
|
||
| const setShallowState = useCallback((newValue: T) => { | ||
|
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. 한 가지 개선할 점을 제안하자면, useState의 set 함수처럼 setShallowState도 함수를 인자로 받을 수 있도록 기능을 확장하는 것은 어떨까요? 예를 들어, setShallowState(prev => ({ ...prev, count: prev.count + 1 }))와 같은 패턴을 지원하면 사용자가 더 유연하게 상태를 관리할 수 있게 되어 훅의 완성도가 높아질 것 같습니다. |
||
| // shallowEquals를 통해 상태 변경을 감지 | ||
| setState((prev) => (shallowEquals(prev, newValue) ? prev : newValue)); | ||
|
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, setShallowState] as const; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,5 +9,6 @@ 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())); | ||
|
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 subscribeToStore = store.subscribe;
const getCurrentState = () => shallowSelector(store.getState());
return useSyncExternalStore(
subscribeToStore,
getCurrentState,
);
Author
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. 확실히 다시 보니까 의도가 명확하게 보이지 않는 것 같아요,, 의견 주셔서 감사합니다! |
||
| }; | ||
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로 감싸는 등 반복적인 작업을 하나로 묶어줄 수 있어요.이 헬퍼를 적용하면, 현재 컨텍스트를 생성하는 부분을 아래처럼 바꿀 수 있습니다.
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.
오,,, 새롭게 알게 됩니다!!! 감사합니다 bbb