
3주차 과제는 리액트의 훅을 직접 구현해보는 거였다.
사실 메모이제이션 훅을 사용해본 경험이 많지 않기에 너무 걱정스러웠다...
일단 해
저번 주의 나: 이게 무슨 말이지...
const cachedFn = useCallback(fn, dependencies);
useCallback은 리렌더링 간에 함수 정의를 캐싱해 주는 훅이다.
이 훅은 fn 함수가 의존하는 배열인 dependencies를 넘겨 주어야 하는데, 값을 빠뜨릴 경우 너무 오래된 값을 사용하게 되거나 너무 많이 넣을 경우 과도한 리렌더링을 유발하는 문제가 생길 수 있다.
이러한 문제를 덜기 위해 의존성 배열을 넘겨주지 않아도 항상 최신값을 유지하도록 하는 훅이 바로 useAutoCallback이다!
// lib/src/hooks/useAutoCallback.ts
// 참조가 변경되지 않으면 항상 새로운 값을 참조
export const useAutoCallback = <T extends AnyFunction>(fn: T): T => {
const ref = useRef(fn);
const callback = useCallback((...args: Parameters<T>) => {
return ref.current(...args);
}, []);
ref.current = fn;
return callback as T;
};
callback은 의존성 배열이 비어있기 때문에 최초 한 번만 생성되고, 최신 ref의 함수를 호출한다. ref.current = fn;에서 매 렌더마다 ref의 함수가 갱신되기 때문에 고정된 callback 내부에서는 항상 최신 상태의 함수를 참조하게 된다!
이로 인해 의존성 배열을 신경쓸 필요 없이, 매 렌더링 시점의 함수 로직을 유지하면서 불필요한 함수 재생성을 방지할 수 있다. 🤩
useSyncExternalStore이라는 훅을 사용하여 나보고 직접 훅을 만들라는데...
진짜 처음 보는 훅이었다. 이게 뭔질 알아야 사용할 수 있으니... 한번 알아보자...🤦♀️
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
이는 컴포넌트 최상위 레벨에 호출하여 외부 저장소(store)의 상태를 구독하고 읽는 훅이다.
subscribe
외부 저장소의 변경을 구독하는 함수
getSnapShot
현재 저장소 데이터 상태를 반환하는 함수
getServerSnapShot
서버 사이드 렌더링(SSR) 환경에서 사용되는 데이터의 초기 상태를 반환하는 함수 (선택)
그럼 store은 또 무엇인가...
// lib/src/createStore.ts
export const createStore = <S, A = (args: { type: string; payload?: unknown }) => S>(
reducer: (state: S, action: A) => S,
initialState: S,
) => {
const { subscribe, notify } = createObserver();
let state = initialState;
const getState = () => state;
const dispatch = (action: A) => {
const newState = reducer(state, action);
if (!Object.is(newState, state)) {
state = newState;
notify();
}
};
return { getState, dispatch, subscribe };
};
store로 전역 상태를 만들고, 변경, 구독할 수 있는 관리 시스템을 구성하는 함수이다. 상태를 변경하는 함수인 reducer과 초기 상태인 initialState를 인자로 받는다.
state는 현재 store의 상태를 저장해두는 변수이며, dispatch를 호출할 때마다 바뀐다. 그리고 subscribe는 store의 상태 변화를 감지하는 함수, getState는 현재 store의 상태를 반환한다.
이렇게 구성된 createStore 함수로 만든 store을 useSyncExternalStore와 함께 사용하면 안전하고 일관되게 전역 상태를 구독할 수 있다!
개인적으로 가장 막막했던 부분이 useShallowSelector 코드였다.
// lib/src/hooks/useShallowSelector.ts
type Selector<T, S = T> = (state: T) => S;
export const useShallowSelector = <T, S = T>(selector: Selector<T, S>) => {
const ref = useRef<S | null>(null);
return (state: T): S => {
const result = selector(state);
if (ref.current && shallowEquals(ref.current, result)) {
return ref.current;
}
ref.current = result;
return result;
};
};
위 코드는 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅이다.
type Selector<T, S = T> = (state: T) => S;
인자로 받을 selector 함수의 타입 정의이다.
T 타입의 state를 받아 S 타입의 결과를 반환한다는 뜻!!
이전 결과를 useRef를 통해 기억한 후, 현재 상태에서의 값(const result = selector(state);)과 비교하여 값을 상태를 업데이트한다.
shallowEquals 함수를 통해 얕은 비교를 진행한 후에 값이 같으면 ref의 값을 그대로 반환하고, 값이 바뀌었다면 ref를 새로운 값인 result로 업데이트해준 뒤 result를 반환한다!
// lib/src/hooks/useStore.ts
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>) => {
const shallowSelector = useShallowSelector(selector);
return useSyncExternalStore(store.subscribe, () => shallowSelector(store.getState()));
};
위의 useShallowSelector 를 사용하여 store의 상태를 구독하고 가져오는 훅이다.
shallowSelector은 selector가 반환하는 값이 얕은 비교로 동일하면 이미 캐시된 값을 반환하여 불필요한 리렌더링을 방지하는 역할을 한다. 👏
// 예시)
type AppState = {
count: number;
user: { name: string };
};
const store = createStore<AppState>({ count: 0, user: { name: 'areumH' } });
function UserName() {
const name = useStore(store, (state) => state.user.name);
return <p>User: {name}</p>;
}
state.count 값이 바뀌어도 name은 state.name 값만을 비교하기 때문에 UserName은 리렌더링되지 않는다! 👍
e2e 테스트 중 장바구니를 추가하거나 삭제했을 때, 토스트 호출로 인하여 리렌더링이 되지 않도록 한다를 통과하기 위해선 이미 주어진 ToastProvider.tsx 코드를 수정해야 했다..!
// app/src/components/toast/ToastProvider.tsx
const ToastContext = createContext<{
message: string;
type: ToastType;
show: ShowToast;
hide: Hide;
}>({
...initialState,
show: () => null,
hide: () => null,
});
export const ToastProvider = memo(({ children }: PropsWithChildren) => {
// 생략
return (
<ToastContext value={{ show: showWithHide, hide, ...state }}>
{children}
{visible && createPortal(<Toast />, document.body)}
</ToastContext>
);
});
위의 코드는 기본적으로 주어진 내가 수정해야할 ToastProvider 코드의 일부분이다.
// 예시)
import { createContext } from 'react';
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('light');
// ...
return (
<ThemeContext value={theme}>
<Page />
</ThemeContext>
);
}
우선 createContext는 위와 같은 형태로 컨텍스트를 생성하고, 여러 컴포넌트 간에 데이터를 전역적으로 공유할 수 있게 해준다!
그리고 Provider를 사용해 context 값을 하위 컴포넌트에 전달한다. 그런데 이미 주어진 코드를 보면 ToastContext.Provider가 아닌 ToastContext를 사용했다..!??
// 3주차 학습 자료) 3-3. React 프로파일링 및 기본 최적화
// Provider 컴포넌트
const ThemeProvider: React.FC<PropsWithChildren> = ({ children }) => {
const [theme, setTheme] = useState<Theme>('light');
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'lht');
}, []);
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
이건 과제 시작 전 훑어봤던 학습 자료인데, 이 코드에선 ThemeContext가 아닌 ThemeContext.Provider로 감싸서 리턴해주는 걸 볼 수 있다.
Provider을 붙인 것과 안 붙인 것의 차이가 궁금해져서 한번 서치해봤다.
리액트 19부터는 Context 뒤에 Provider을 붙인 것과 안 붙인 것이 기능적으로 동일하게 작동한다고 한다. 따봉리액트야 고마워..~~ 👍
배포를 위해 pnpm run gh-pages를 실행했더니 터미널에 'cp'은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는 배치 파일이 아닙니다. 와 같은 에러가 떴다.
// app/package.json
"scripts": {
"build": "vite build && cp ./dist/index.html ./dist/404.html",
// ...
},
package.json의 스크립트 명령어인데, cp는 Unix 기반 환경에서의 shell 명령어이기 때문에 윈도우에서는 작동하지 않는 것이었다... 관련하여 찾아보니 Unix shell 명령어를 Node.js 스크립트에서 사용할 수 있게 해주는 유틸리티 패키지 shx 라는게 있었다! 너무 다행...
해당 패키지 설치 후, 명령어를 "build": "vite build && shx cp ./dist/index.html ./dist/404.html" 로 수정하여 페이지 배포에 성공했다. 과제와 직접적인 연관이 있는 건 아니지만 그래도 하나 더 알게 되었다..!! 😶
(cp 대신 copy로 실행하면 된다는 걸 나중에 알았다......)
학습 자료를 적극 활용하자...
테스트를 통과했다고 코드를 방치하지 말자...
겁먹지 말자... (다음 과제 너무 겁남)