useContext
useContext에 대해 이해하기 위해서는 React의 Context에 대해 알아야 한다.
Context란?
React 애플리케이션은 기본적으로 부모 컴포넌트와 자식 컴포넌트로 이뤄진 트리 구조를 갖고 있다. 따라서 부모가 가진 데이터를 자식에게 전달하려 한다면 props를 통해 데이터를 전달해야 한다. 이때 부모와 자식의 거리가 길어질 수 록 코드가 복잡해진다.
<A props={something}>
<B props={something}>
<C props ={something}>
<D props = {something}>
</D>
</C>
</B>
</A>
이러한 기법을 prop 내려주기(props drilling)라 한다. 이러한 prop 내려주기는 양쪽에서 모두 불편하다.
Context를 이용하면 명시적 props 없이도 하위 컴포넌트 모두에서 원하는 값을 사용할 수 있다.
import React, { createContext, useContext } from "react";
const Context = createContext<{ hello: string } | undefined>(undefined);
function ChildComponent() {
const value = useContext(Context);
return <>{value ? value.hello : ""}</>;
}
const MyComponent = () => {
return (
<>
<Context.Provider value={{ hello: "react" }}>
<Context.Provider value={{ hello: "javascript" }}>
<ChildComponent />
</Context.Provider>
</Context.Provider>
</>
);
};
/* STYLE */
export default MyComponent;
useContext는 상위 컴포넌트에서 만들어진 Context를 함수형 컴포넌트에서 사용할 수 있도록 만들어진 훅이다. useContext를 사용해 상위 컴포넌트의 어딘가에서 선언된 <Context.Provider/>에서 제공한 값을 사용할 수 있게 된다. 이때 Provider가 여러개면 가장 가까운 Provider를 가져오게 된다.
useContext로 원하는 값을 얻으려할 때, 이 Context가 존재하지 않으면 예상치 못한 에러가 발생할 수 없다.
이러한 에러를 방지하려면 useContext 내부에서 해당 콘텍스트가 한 번이라도 초기화되어 값을 내려주고 있는지 확인해 보면 된다.
import React, { createContext, useContext } from "react";
const MyContext = createContext<{ hello: string } | undefined>(undefined);
function ContextProvider({ children, text }: any) {
return (
<MyContext.Provider value={{ hello: text }}>{children}</MyContext.Provider>
);
}
function useMyContext() {
const context = useContext(MyContext);
if (context === undefined) {
throw new Error("useMyContext는 ContextProvider내부에서만 사용가능");
}
return context;
}
function ChildComponent() {
const { hello } = useMyContext();
return <>{hello}</>;
}
const MyComponent = () => {
return (
<>
<ContextProvider text="react">
<ChildComponent></ChildComponent>
</ContextProvider>
</>
);
};
/* STYLE */
export default MyComponent;
useContext사용시 주의할 점
단 useContext를 사용한 컴포넌트는 재활용이 어려워진다는 사실을 염두해 두자. Provider에 의존성을 갖고 있는 셈이 되기에 아무데서나 재활용하기에는 어려운 컴포넌트가 된다.
이러한 문제를 최상위 루트 컴포넌트에 넣어 사용하면 어떨까? 라는 생각은 에러는 줄어들 수 있지만, 콘텍스트가 많아질수록 루트 컴포넌트는 더 많은 콘텍스트로 둘러싸일 것이기에 불필요한 리소스가 낭비된다. 따라서 컨택스트가 미치는 범위는 최소로 만드는 것이 좋다.
마지막으로 콘텍스트와 useContext는 상태관리를 위한 API가 아니고 상태를 주입해주는 API이다.
라이브러리가 되기위해서는 아래의 조건을 만족해야 한다.
- 어떤 상태를 기반으로 다른 상태를 만들어 낼 수 있어야 한다.
- 필요에 따라 이러한 상태 변화를 최적화 할 수 있어야 한다.
콘텍스트는 둘 중 어느 것도 하지 못한다. 단순히 props 값을 하위로 전달해 줄 뿐이다.
만약 A B C 순서로 컴포넌트의 props를 전달한다면 A와 C에서만 props를 사용한다고 가정해보자. 이 경우에는 useContext를 사용한다고 B 컴포넌트가 렌더링 되지 않는 것이 아니다.
따라서 이 부분에서 최적화 하려면 React.memo를 사용해야 한다.
useContext 자체로는 렌더링 최적화에 도움이 되지 않기에 React.memo와 같이 메모이제이션을 하는 훅의 도입을 고려하자.
useReducer
useState의 심화 버전이라 할 수 있는 useReducer는 좀 더 복잡한 상태값을 미리 정의해 놓은 시나리오에 따라 관리할 수 있다.
- 반환값은 useState와 동일하게 길이가 2인 배열이다.
- state: 현재 useReducer가 갖고 있는 값을 의미
- dispatcher: state를 업데이트하는 함수
- useState의 이수와 달리 2개에서 3개의 인수를 필요로 한다.
- reducer: useReducer의 기본 action을 정의하는 함수
- initialState: 두 번째 인수로, useReducer의 초깃값을 의미
- init: 초깃값을 지연해서 생성시키고 싶을 때 사용하는 함수
useReducer로 구현한 간단한 카운터 예제를 보자.
import React, { useReducer } from "react";
type State = {
count: number;
};
type Action = { type: "up" | "down" | "reset"; payload?: State };
function init(count: State): State {
return count;
}
const initialState: State = { count: 0 };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "up":
return { count: state.count + 1 };
case "down":
return { count: state.count - 1 > 0 ? state.count - 1 : 0 };
case "reset":
return init(action.payload || { count: 0 });
default:
throw new Error(`Unexpected action type ${action.type}`);
}
}
const MyComponent = () => {
const [state, dispatcher] = useReducer(reducer, initialState, init);
function handleUpButtonClick() {
dispatcher({ type: "up" });
}
function handleDownButtonClick() {
dispatcher({ type: "down" });
}
function handleResetButtonClick() {
dispatcher({ type: "reset", payload: { count: 1 } });
}
return (
<>
<h1>{state.count}</h1>
<button onClick={handleUpButtonClick}>+</button>
<button onClick={handleDownButtonClick}>-</button>
<button onClick={handleResetButtonClick}>reset</button>
</>
);
};
/* STYLE */
export default MyComponent;
이러한 간단한 예제에서는 useReducer를 사용하면 복잡하게 보일 수 있지만 useReducer의 목적은 간단하다. 복잡한 형태의 state값에 대한 업데이트를 컴포넌트 밖에 미리 정의해두고 dispatcher로만 업데이터 하는 방법을 정의해주는 방식으로 시나리오들을 관리할 수 있을 것이다.
또한 useReducer를 사용해 state를 관리하면 state를 사용하는 로직과 관리하는 비즈니스 로직을 분리할 수 있어 state를 관리하기 한결 쉬워진다.
useState VS useReducer
Preact의 useState 코드를 보면 useReducer로 구현되어 있다.
preact/hooks/src/index.js at e201caf396f015a453542b7b9d1be6199582e119 · preactjs/preact
⚛️ Fast 3kB React alternative with the same modern API. Components & Virtual DOM. - preactjs/preact
github.com
/**
* @param {import('./index').StateUpdater<any>} [initialState]
*/
export function useState(initialState) {
currentHook = 1;
return useReducer(invokeOrReturn, initialState);
}
useReducer나 useState 둘 다 세부 작동과 쓰임에만 차이가 있을 뿐, 결국 클로저를 이용해 값을 가둬서 state를 관리한다는 것은 똑같다. 적재적소에 state와 reducer를 선택해서 사용하도록 하자.
useImperetiveHandle
useImpreativeHandle은 실제 개발 과정에서 자주 볼 수 없는 훅으로 널리 사용되지 않지만, 일부 사용 사례에서는 유용하게 활용될 수 있다. 그 전에 React.forwardRef에 대해 알아보자.
forwardRef 살펴보기
ref는 useRef에서 반환한 객체로 리액트 컴포넌트의 props인 ref에 넣어 HTMLElement에 접근하는 용도로 흔히 사용된다.
이러한 ref를 하위 컴포넌트로 전달하고 싶으면 어떻게 할 수 있을까? 만약 ref를 props로 전달하면 안된다.
ref가 예약어이기 때문에 props로 사용할 수 없다는 것이다.
import { useEffect, useRef } from "react";
function ChildComponent({ ref }: any) {
useEffect(() => {
console.log(ref); // undefined
}, [ref]);
return <div>안녕</div>;
}
export default function MyComponent() {
const inputRef = useRef();
return (
<>
<input ref={inputRef} />
<ChildComponent ref={inputRef}></ChildComponent>
</>
);
}
물론 ref가 아닌 다른 props로 받으면 정상적으로 동작하는 것처럼 보인다.
import { useEffect, useRef } from "react";
function ChildComponent({ minRef }: any) {
useEffect(() => {
console.log(minRef);
}, [minRef]);
return <div>안녕하시오</div>;
}
export default function MyComponent() {
const inputRef = useRef(null);
return (
<>
<input ref={inputRef} />
<ChildComponent minRef={inputRef}></ChildComponent>
</>
);
}
즉, 일반적인 props를 활용해 ref를 전달할 수 있다. forwardRef는 방금 작성한 코드와 동일한 작업을 하는 리액트 API이다. 그렇다면 왜 이 API가 굳이 있는 것일까?
그 이유는 ref를 전달하는데 네이밍의 일관성을 보장하기 위함이다.
import { forwardRef, useEffect, useRef } from "react";
const ChildComponent = forwardRef((props, ref) => {
useEffect(() => {
console.log(ref);
}, [ref]);
return <div>이제 안녕 안할레</div>;
});
export default function MyComponent() {
const inputRef = useRef(null);
return (
<>
<input ref={inputRef} />
<ChildComponent ref={inputRef}></ChildComponent>
</>
);
}
이처럼 forwardRef를 사용하면 사용하는 쪽에서도 받는 쪽에서도 ref를 받는 다는 것을 확실하게 이해할 수 있다.
useImperativeHasndle이란?
해당 훅은 부모에게서 받은 ref를 원하는 형태로 수정할 수 있게 해주는 훅이다.
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
const Input = forwardRef((props: any, ref) => {
useImperativeHandle(ref, () => ({ alert: () => alert(props.value) }), [
props.value,
]);
return <input ref={ref} {...props} />;
});
export default function MyComponent() {
const inputRef = useRef();
const [text, setText] = useState("");
function handleClick() {
// @ts-ignore
inputRef.current.alert();
}
function handleChange(e: any) {
setText(e.target.value);
}
return (
<>
<Input ref={inputRef} value={text} onChange={handleChange} />
<button onClick={handleClick}>Focus</button>
</>
);
}
해당 훅을 사용하면 자식 컴포넌트에서 새롭게 설정한 객체의 키와 값에 대해서도 접근할 수 있다.
useLayoutEffect
useEffect와 동일하지만, 모든 DOM의 변경 후에 동기적으로 발생하게 된다. 즉, 사용법 자체는 useEffect와 동일하다.
중요한 사실은 모든 DOM의 변경 후에 useLayoutEffect의 콜백 함수 실행이 동기적으로 발생한다는 점이다. 또한 DOM 변경이란 렌더링이지 브라우저에서 실제 해당 변경 사항이 반영되는 시점을 의미하는 것은 아니다.
- 리액트가 DOM 업데이트
- useLayoutEffect를 실행
- 브라우저에 변경사항 반영
- useEffect 실행
즉, useLayoutEffect는 오히려 useEffect 보다 실행 시점이 빠르다. 하지만 useLayoutEffect는 동기적으로 실행되기에 컴포넌트가 일시중지되는 것과 같은 일이 발생할 수 있다. 따라서 DOM이 계산되었음에도 화면이 반영되기 전에 할 작업이 있는 경우에만 조심스럽게 사용하자.
useDebugValue
일반적으로 프로덕션 웹 서비스에서 사용하는 훅이 아니고 개발하는 과정에서 사용되는 훅이다. 사용자 정의 훅 내부의 내용에 대한 정보를 남길 수 있다.
'React > 모던 리액트 Deep Dive' 카테고리의 다른 글
| 서버 사이드 렌더링 (1) | 2025.04.30 |
|---|---|
| 사용자 정의 훅과 고차 컴포넌트 (0) | 2025.04.28 |
| useMemo, useCallback와 useRef (0) | 2025.04.09 |
| useState와 useEffect (0) | 2025.04.03 |
| 리액트의 렌더링과 메모이제이션 (1) | 2024.11.08 |