프론트엔드 작업을 하다보면 꽤 많이 발생되는 비효율적인 구조 중 하나가 과도한 props drilling 이라고 생각한다.
props drilling 말 그대로 부모 컴포넌트에서 자식 컴포넌트로 데이터를 값을 전달해 주는 것을 말하는데, 너무 깊은 단계를 거쳐야 한다면 그 사이에 있는 모든 컴포넌트에서 작업을 해줘야하기 때문에 매우 불편하다.
이를 방지해주기 위해 Context라는 개념을 사용한다.
Context란?
Context는 리액트 컴포넌트간에 어떠한 값을 공유할수 있게 해주는 기능이다.
주로 Context는 전역적으로 필요한 값을 다룰 때 사용하지만, 단순히 "리액트 컴포넌트에서 Props가 아닌 또 다른 방식으로 컴포넌트간에 값을 전달하는 방법이다" 라고 접근을 하시는 것이 좋다고 참고한 블로그에 적혀있다.
createContext()
Context를 사용하기 위해서는 리액트 패키지에서 제공하는 createContext 메소드를 사용해야 한다.
createContext 메소드가 정의되어있는 index.d.ts 파일을 잠시 들여다보자.
function createContext<T>(
defaultValue: T,
): Context<T>;
타입스크립트를 사용한다면 위에서 보는바와 같이 공유하려는 데이터의 타입을 제네릭 타입 T에 넘겨야 하고, 같은 타입의 defaultValue 값을 넘겨주어야 해당 데이터와 알맞는 Context 객체가 생성된다.
interface Context<T> {
Provider: Provider<T>;
Consumer: Consumer<T>;
displayName?: string | undefined;
}
생성되는 Context 객체에서 중요한 것은 위의 Provider 컴포넌트 이다.
interface ProviderProps<T> {
value: T;
children?: ReactNode | undefined;
}
위는 Provider 컴포넌트가 받는 props를 정의해 놓은 interface이다.
Context 객체를 생성한 후 해당 객체의 Provider 컴포넌트 안에 value라는 prop에 컴포넌트 간 공유할 데이터를 넣어주면, Provider로 감싸진 컴포넌트들 즉, children 에서는 value에 해당하는 값을 props drilling을 거치지 않은 채 사용할 수 있게 된다.
Context로 상태관리가 필요할 때
자식 컴포넌트에서 공유 데이터의 값을 변경해야 하는 상황도 있다.
나의 경우 toast를 페이지에서 띄워야하는 경우가 많았고, toast 정보를 Context를 통해 어디에서든 접근 가능하고 변경도 가능하도록 만들어야 했다.
...
export const ToastValueContext = createContext<
{ open: boolean; text: string } | undefined
>(undefined);
export const ToastActionContext = createContext<
{ openToast: (text: string) => void } | undefined
>(undefined);
export default function ToastContextProvider({
children,
}: {
children: ReactNode;
}) {
const [toastValue, setToastValue] = useState({
open: false,
text: "",
});
const actions = useMemo(
() => ({
openToast(text: string) {
setToastValue({
open: true,
text,
});
},
}),
[]
);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (toastValue.open) {
timeoutId = setTimeout(() => {
setToastValue((prev) => {
return {
...prev,
open: false,
};
});
}, 1000);
}
return () => {
clearTimeout(timeoutId);
};
}, [toastValue.open]);
return (
<ToastActionContext.Provider value={actions}>
<ToastValueContext.Provider value={toastValue}>
{children}
</ToastValueContext.Provider>
</ToastActionContext.Provider>
);
}
위는 ToastContextProvider.tsx 파일의 코드이다.
위에서 본 것 처럼 데이터를 공유하기 위해 createContext 메소드로 Context 객체를 생성해 주었다.
그 후 위 코드처럼 Provider 컴포넌트에서 value로 각각 toast의 정보와 toast 정보를 바꿀 수 있는 action을 사용하고자 하는 컴포넌트들 최상위에 선언해주면 된다.
useMemo 훅을 사용하여 re-rendering 시 함수가 재생성 되는 것을 방지해주었다.
...
<ToastContextProvider>{children}</ToastContextProvider>
...
예를 들어 이런 식이다.
toast 특성 상 잠시 뒤 사라져야 하므로 useEffect로 해당 로직을 처리해주었다.
import { useContext } from "react";
import { ToastValueContext } from "./ToastContextProvider";
export default function useToastValue() {
const value = useContext(ToastValueContext);
if (value === undefined) {
throw new Error("No Provider");
}
return value;
}
import { useContext } from "react";
import { ToastActionContext } from "./ToastContextProvider";
export default function useToastActions() {
const value = useContext(ToastActionContext);
if (value === undefined) {
throw new Error("No Provider");
}
return value;
}
그 후 useContext 훅을 사용한 커스텀 훅을 생성한 뒤, 각 Context 객체의 Provider가 공유하는 값을 사용해야 하는 곳에서 알맞게 사용하면 된다!
Provider를 분리한 이유
왜 굳이 데이터와 관련된 Provider와 데이터의 상태를 바꾸는 Provider를 분리하였을까?
만약 Provider를 분리하지 않고 공유하려는 데이터와, 해당 데이터의 상태를 바꾸는 액션을 함께 묶어 value prop에 넣어준 뒤 커스텀 훅을 하나만 생성하여 사용한다고 가정해보자.
useContext 훅에서는 공유되는 데이터 값이 바뀔 때마다 이를 감지하여 해당 훅을 사용하는 컴포넌트의 re-rendering을 일으킨다.
즉, 데이터 값을 사용하지 않고 데이터 상태의 변화만을 위해 useContext 훅을 사용하는 컴포넌트에서도 데이터 값이 바뀔 때마다 re-rendering이 일어나게 되는 것이고, 이는 비효율적인 동작임에 틀림없다.
그런데 여기서 children에 대한 의문이 들 수가 있다.
아니 어짜피 데이터가 바뀔 때마다 Provider들이 정의되어있는 컴포넌트가 re-render 되니까 이걸로 감싸져있는 children 컴포넌트들은 부모 컴포넌트가 re-render 되었으니 전부 다 re-render되는게 맞는거 아닌가??
나도 처음에 얕은 생각으로 이에 대해 의문이 들었고, 구글링을 통해 children prop의 비밀을 알 수 있었다.
결론부터 말하자면 children에 해당하는 컴포넌트들은 부모가 re-render된다고 같이 re-render되지는 않는다. 이에 대한 내용은 추후에 리액트 렌더링 최적화 관련 부분에서 언급할 예정이다.
본격적으로 공유된 데이터 필요한 곳에서만 사용해보기
이제 준비는 끝났다. 가져다 사용하기만 하면 끝이다.
우선 나는 페이지의 공통된 레이아웃인 Footer에서 Toast 데이터 값을 불러왔다.
const toastValue = useToastValue();
<footer className="flex justify-between w-full bg-background-gray-1 px-[3.2rem] pt-[0.8rem] pb-[1.2rem] fixed bottom-0">
...
</footer>
<Toast open={toastValue.open} text={toastValue.text} />
다음과 같은 형식이다.
useToastValue 훅은 위에서 useContext를 통해 공유되고 있는 toast 데이터를 return하는 커스텀 훅이다.
이를 호출한 뒤 얻은 toast 데이터를 활용하여 만들어놓은 Toast 컴포넌트를 띄울지 말지 결정한다.
이제 toast를 띄워야 하는 부분에서 위에서 만든 useToastActions 커스텀 훅을 호출하고, 알맞게 toast 데이터를 변경만 해주면 된다.
...
const { openToast } = useToastActions();
...
const handleScrap = async (
e: React.MouseEvent<SVGSVGElement, MouseEvent>
) => {
e.preventDefault();
const token = await validateToken();
if (!token) {
location.replace("/login");
} else {
openModal();
openToast("저장할 큐레이션을 선택해주세요");
}
};
다음은 장소 스크랩 버튼을 누르면 실행되는 로직이다.
const actions = useMemo(
() => ({
openToast(text: string) {
setToastValue({
open: true,
text,
});
},
}),
[]
);
위에서 toast 데이터를 바꿀 때 필요한 openToast 메소드를 위와 같은 형식으로 객체 안에 작성해 줬었다.
이를 useToastActions 커스텀 훅에서 useContext 훅을 통해 반환하고 있고, 이를 호출한 뒤 객체 안에 있는 openToast 함수를 통해 toast 정보를 바꾸면 된다.
openToast를 통해 정보를 바꾸면, useContext 훅을 통해 toast 데이터를 반환해주는 useToastValue 커스텀 훅을 사용하는 컴포넌트의 re-rendering을 통해 toast가 띄워지게 된다!
위와 같이 toast가 잘 띄워지게 된다!!
Provider 단에서 toast가 띄워진 지 1초 후 사라지도록 useEffect를 활용하여 구현한 로직도 잘 적용된다.
toast 뿐만 아니라 모달 창 같이 여러 페이지에서 사용되는 데이터를 관리하는데 매우 유용할 것 같다.
다만 전역적인 값을 위해서가 아니라 코드의 가독성, 편리함 등을 높이기 위해서도 유용하게 사용되니 '전역적' 이라는 단어에 꽃히기 보다는 그저 props 가 아닌 데이터를 전달할 수 있는 또 하나의 방법이라고 접근하면 좋을 것 같다.
전역 상태 관리 라이브러리
여러가지 전역 상태 관리 라이브러리가 있지만, 대표적으로 recoil 이라는 라이브러리를 많이 사용한다.
물론 Context API 도 간단한 경우 유용하게 사용할 수 있다.
하지만 공유할 데이터가 여러개 있다고 가정해보자.
불필요한 re-rendering을 최소화 하기 위해 위처럼 Provider를 하나의 데이터당 2개씩 만들어야 한다.
그럼 공유할 데이터들이 조금만 많아져도 너무 많은 양의 Provider로 감싸주어야할 것이고, 이는 너무 불편하고 가독성에도 좋지 않을 것이다.
또한 불필요한 re-rendering을 위해 많은 신경을 써주어야 한다.
이러한 단점들을 해결하기 위해 recoil을 사용할 수 있다.
recoil은 훅의 형태로 데이터들을 관리할 수 있게 해주어 useState 훅에 익숙한 프론트 개발자들이 매우 편리하게 사용할 수 있다.
뿐만 아니라 selector를 사용하면 렌더링 최적화를 자동으로 해주는 기능도 덤으로 딸려온다.
이처럼 사용하면 매우 편리한 recoil에 대한 블로그를 조만간 써보려고 한다~
참고
https://velog.io/@velopert/react-context-tutorial
다른 사람들이 안 알려주는 리액트에서 Context API 잘 쓰는 방법
여러분, 리액트로 웹 애플리케이션 개발 하면서 Context API를 어떻게 사용하고 계신가요? 과거에도 관련 포스트를 작성한적이 있긴 하지만, 지난 몇 년간 Context를 사용하면서 습득하게된 팁들을
velog.io
https://legacy.reactjs.org/docs/context.html
Context – React
A JavaScript library for building user interfaces
legacy.reactjs.org
'FrontEnd > React' 카테고리의 다른 글
React 렌더링 최적화에 대한 고찰 (2) | 2024.04.10 |
---|---|
Context API 에서 Recoil로 마이그레이션 일지 (3) | 2024.04.06 |
프론트엔드(React) 클린 코드로 나아가기 (0) | 2024.03.29 |
Suspense 효율적으로 사용하기 (4) | 2024.03.21 |
서비스 내에 나만의 지도 띄우기 (Feat : NAVER MAP) (4) | 2024.02.27 |