이전 블로그에서 toast 기능을 Context API 를 통해 구현하였다. (https://doyourbestcode.tistory.com/140)
물론 당장은 toast 기능 하나만 구현하였기 때문에 불필요한 rendering만 좀 신경 써주면 Context API를 사용해도 큰 문제가 없다.
하지만 여러 기능을 Context API를 통해 구현하고자 하면 문제가 발생할 수 있다.
불필요한 rendering을 처리하는 cost
이전 블로그를 보면
...
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>
);
}
이런 코드를 통해 값과 값을 변경해주는 함수를 Provider 컴포넌트의 value prop을 통해 공유해주었다.
그리고 toast 값을 useContext 훅을 통해 받아온 뒤 return 해주는 로직을 커스텀 훅으로 생성한 바 있다.
이 때 toast값이 바뀐다고 생각해보자.
그러면 내부의 state가 바뀌었으므로 ToastContextProvider 컴포넌트가 re-render 될 것이다.
사실 지금은 re-rendering 되는 Provider 컴포넌트가 children만 감싸고 있고, 그렇기에 자식 컴포넌트는 ToastContextProvider 컴포넌트가 re-rendering 된다고 똑같이 re-render되지는 않는다
(children prop 자체는 왜 부모가 re-rendering 되어도 똑같이 re-rendering 되지 않는지는 다음 렌더링 최적화 블로그에서 소개할 예정...).
<ToastActionContext.Provider value={actions}>
<ToastValueContext.Provider value={toastValue}>
<TestComponent />
{children}
</ToastValueContext.Provider>
</ToastActionContext.Provider>
그래서 위와 같이 TestComponent를 하나 추가하여 ToastContextProvider 컴포넌트가 return하는 것을 수정해보았다. TestComponent는 단순히 콘솔에 'rendered' 문자열을 출력하는 컴포넌트이다.
위와 같이 부모가 re-rendering 됨에 따라 toast 동작과 전혀 관련이 없는 컴포넌트가 re-rendering 된다.
물론 지금은 children만 감싸고 있어 문제가 없지만 만약 다른 컴포넌트를 직접 Provider 안에 감싸야하는 상황이라면 해당 컴포넌트의 불필요한 re-rendering을 막기 위해 React.memo 지옥에 빠질지도 모른다.
또한 현재 useMemo 훅의 dependency에 빈 배열을 넘겨줌으로써 toast 값이 바뀔 때마다 toast 값을 바꾸는 함수가 재생성 되는 것을 막아주고 있다.
이 덕분에 이전 블로그에서 만든 toast 값을 변경하는 함수와 관련된 커스텀 훅을 사용하는 곳에서는 re-rendering이 일어나지 않는다.
이처럼 불필요한 re-rendering을 막기 위해 useMemo 훅을 사용하여 한번 더 처리를 해줬다는 점에서 개발 cost가 늘어나게 된다. 기능이 복잡해지면 부가적일 처리를 해줘야 할 수도 있을 것이다.
Provider hell
말 그대로 감싸주는 Provider가 너무 많아질 수 있다.
이전 블로그에서 불필요한 re-rendering 을 방지하기 위해 데이터 값과 해당 데이터 값을 변경하는 함수의 Context를 따로 생성하여 Provider를 분리했었다.
즉, 만약 3개의 기능을 효율적으로 사용하기 위해서는 적어도 6개의 Provider를 루트 경로에 감싸주어야 한다는 것이다.
여러개의 Provider가 감싸져 있는 구조는 딱봐도 너무 비효율적이다.
Recoil의 도입
이러한 Context API의 단점들을 보완해주기 위한 여러 상태 관리 라이브러리들이 있다.
나는 그 중에서 대표적은 Recoil을 도입하였다.
Recoil은 React를 위한 상태관리 라이브러리로, atoms(공유 상태)에서 selectors(순수 함수)를 거쳐 React 컴포넌트로 내려가는 data-flow graph를 만들 수 있다.
또한 useState 훅의 사용법과 매우 유사하여, 간편하고 익숙하게 사용할 수 있다.
recoil 사용하기 위해서는 RecoilRoot로 프로젝트를 감싸주어야 한다.
function App() {
return (
<RecoilRoot>
<Routes />
</RecoilRoot>
);
}
CRA를 활용한다면 위처럼 전체 Route를 RecoilRoot로 감싸주면 된다.
만약 Next를 사용하고 있다면
"use client";
import { ReactNode } from "react";
import { RecoilRoot } from "recoil";
export default function RecoilRootLayout({
children,
}: {
children: ReactNode;
}) {
return <RecoilRoot>{children}</RecoilRoot>;
}
위와 같이 use client를 선언한 파일에 children을 RecoilRoot로 감싸준 뒤
...
return (
<html lang="en" className="width-[100%] height-[100%]">
<body className="w-[100%] h-[100%]">
<main className="w-[100%] h-[100%] fixed overflow-hidden">
<RecoilRootLayout>
{children}
</RecoilRootLayout>
</main>
</body>
</html>
);
...
위와 같이 layout에 선언해주면 모든 파일에서 recoil을 사용할 수 있게 된다.
그럼 이제 하나씩 알아보자!!
atom 이란?
recoil에서 제공하는 atom을 사용하면 손쉽게 공유하는 데이터를 세팅할 수 있다.
atom은 컴포넌트에서 구독할 수 있고, 만약 atom의 상태가 변한다면 해당 atom을 구독하고 있는 컴포넌트에서만 re-rendering이 일어나게 된다.
atom의 정의를 보면 다음과 같이 정의되어있다.
function atom<T>({
key: string,
default?: T | ...
}): RecoilState<T>
중요하게 사용되는 부분만 추려보았다.
우선 다른 atom들과 구별되기 위해 unique한 key 값을 정해주어야 한다.
다음으로 공유할 데이터 타입을 지정해주고, 해당 타입에 맞게 default 값을 지정해주면 끝이다.
매우 간단한 예제를 봐보자.
const countState = atom<number>({
key: 'counter',
default: 0,
});
위에서 countState라는 number타입의 atom을 생성하는데 성공했다.
이제 atom을 필요한 곳에서 사용하기만 하면 끝이다.
Selector란?
어려운 정의들이 많지만, 쉽게 말하자면 이미 만들어진 atom을 입력받으면 그로부터 변경된 상태를 돌려주는 순수함수라고 말하면 될 것 같다.
그러면 순수함수는 대체 뭘까??
순수함수의 정의를 찾아보았다.
함수의 전달인자로 참조 자료형이 전달되면(자바스크립트에서는 예를 들어 배열, 객체, 함수 등...) 의도치 않게 해당 객체 자체를 바꾸는 사이드 이펙트를 만들 수 있고, 이는 해당 데이터의 불변성을 손상시킨다고 한다.
한마디로 Selector는 이미 만들어진 atom의 상태를 수정하지 않고도 변경된 상태를 돌려줄 수 있고, 사용시 의도치않은 사이드 이펙트에 대해 걱정하지 않아도 된다.
Selector의 정의를 보면 다음과 같이 되어있다.
function selector<T>({
key: string,
get: ({
get: GetRecoilValue,
getCallback: GetCallback,
}) => T | ...
set?: (
{
get: GetRecoilValue,
set: SetRecoilState,
reset: ResetRecoilState,
},
newValue: T | DefaultValue,
) => void,
...
})
좀 중요한 부분만 추려보았다.
마찬가지로 간단한 예제를 한번 봐보자.
export const countStateSelector = selector<number>({
key: "countStateSelector",
get: ({ get }) => {
const count = get(countState);
return count + 1;
},
});
atom과 마찬가지로 unique한 키 값을 설정해주어야 한다.
get 속성은 필수로 지정되어야 하는 함수이다.
이 때 get 함수의 파라미터의 콜백객체 안에 같은 이름의 get 함수가 하나 더 있어서 헷갈릴 수 있다.
한번 파라미터 콜백객체 안의 get 타입으로 정의되었는 GetRecoilValue를 들여다보자.
export type GetRecoilValue = <T>(recoilVal: RecoilValue<T>) => T;
recoilVal의 타입은 RecoilValue<T> 인데 이는 인자로 atom 혹은 selector를 전달할 수 있다는 뜻이다.
즉, 인자로 atom 혹은 selector를 전달하면, 파라미터 콜백객체 안의 get함수의 인자로 다른 atom 데이터의 값이나 다른 selector에서 파생된 데이터의 값을 그대로 가져올 수 있다.
예제에서도 파라미터 콜백객체안에 정의된 get 함수의 인자로 위에서 만들어놓은 countState의 atom 값을 그대로 가져왔다.
그 후 count+1을 return 해줌으로써 해당 selector는 이미 있는 atom 에서 변경된 데이터를 return해 줄 수 있는 것이다.
get 함수의 인자로 전달된 atom 혹은 selector 는 해당 selector의 dependency의 리스트로 추가되게 된다.
dependency 중 하나라도 변경이 감지되면 해당 selector는 re-evaluate된다.
set 속성은 선택적으로 지정할 수 있는 함수이다.
set 속성을 지정하게 되면 selector는 writeable 한 상태를 return 해주게 된다.
이 때 set 함수의 첫번째 파라미터에는 get, set, reset 함수가 들어있는 콜백 객체가 있고, 두번째 파라미터에는 T | DefaultValue 타입의 값을 전달해 주어야 한다.
export const countStateSelector = selector<number>({
key: "countStateSelector",
get: ({ get }) => {
const count = get(countState);
return count + 1;
},
set: ({ set, reset }, newValue) => {
if (newValue instanceof DefaultValue) {
reset(countState);
} else {
set(countState, newValue);
}
},
});
selector의 첫번째 매개변수인 콜백 객체 안에 있는 함수들을 살펴보자.
먼저 get 함수는 위와 마찬가지로 다른 atom 데이터의 값이나 다른 selector에서 파생된 데이터의 값을 그대로 가져올 수 있다.
다만 여기서 인자로 전달된 atom 혹은 selector를 구독하지는 않는다.
set 함수의 첫번째 파라미터로 atom 혹은 selector를, 두번째 파라미터로 인자로 전달했던 T | DefaultValue 타입의 값을 전달해줘야 한다.
이렇게 해주면 인자로 전달한 atom 혹은 selector가 두번째 인자로 T | DefaultValue 타입의 전달한 값으로 변경되는 것이다.
그럼 DefaultValue는 대체 뭘까?
뒤에서 필요한 곳에서 atom과 selector를 불러 사용하는 법을 알아볼텐데, 가끔가다가 selector의 상태를 useResetRecoilState훅으로 초기화 시켜야 할 때가 있을 수 있다.
이 때 useResetRecoilState 훅을 통해 해당 selector의 상태를 초기화시키면 set 속성의 두번째 파라미터 값으로 DefaultValue 타입의 값이 전달되게 된다.
해당 타입을 통해 resetter 가 호출되었는지 판단하여 어떠한 로직을 수행할 수 있는 것이다.
마지막으로 reset 함수가 콜백 객체의 마지막에 포함되어있다.
reset 함수는 인자로 atom 혹은 selector를 전달해주면 해당 atom 혹은 selector가 말 그대로 초기화된다.
이제 atom과 selector를 설정하는 방법은 얼추 알아보았다.
드디어 그럼 필요한 곳에서 사용하는 방법을 알아볼 차례이다!!
useRecoilState()
const [number,setNumber] = useRecoilState(countState);
인자에 atom 혹은 writable한 selector를 전달(set 속성이 정의된 selector)해야 한다.
이 훅을 사용하는 컴포넌트는 인자로 전달된 atom 혹은 selector를 구독하게 된다.
즉, 구독을 하고 있으므로 atom 및 selector 상태가 변경되면 이 훅을 사용하고 있는 컴포넌트는 re-rendering 된다.
useRecoilValue()
const number = useRecoilValue(countState);
인자에 atom 혹은 selector를 전달해야한다.
이 때 selector는 read-only 상태여도 되고 writable 상태여도 된다. 즉 set 속성의 유무와 상관 없이 사용 가능하다.
해당 훅을 사용하는 컴포넌트 역시 인자로 전달된 atom 혹은 selector를 구독하게된다.
즉, 구독을 하고 있으므로 atom 및 selector 상태가 변경되면 이 훅을 사용하고 있는 컴포넌트는 re-rendering 된다.
useSetRecoilState()
const setNumber = useSetRecoilState(countStateSelector);
인자에 atom 혹은 writable한 selector를 전달(set 속성이 정의된 selector)해야 한다.
위의 훅들과 다르게 해당 훅을 사용하는 컴포넌트는 인자로 전달된 atom 혹은 selector를 구독하지 않는다.
즉, 구독을 하고 있지 않으므로 atom 및 selector 상태가 변경되어도 이 훅을 사용하고 있는 컴포넌트는 re-rendering 되지 않는다!
setNumber(10) 을 호출하면 위 selector 예제의 set(countState, newValue) 코드에서 newValue 값에 10이 들어가게 된다. 그러면 countState 값이 10으로 변경되는 것이다.
useResetRecoilState()
const resetNumber = useResetRecoilState(countStateSelector);
인자에 atom 혹은 writable한 selector를 전달(set 속성이 정의된 selector)해야 한다.
해당 훅을 사용하면 인자로 전달된 atom 혹은 selector를 처음의 default value로 돌아가게 할 수 있다.
위에서 DefaultValue타입을 설명할 때 언급한 훅이 이 훅이다.
해당 훅을 사용하는 컴포넌트는 인자로 전달된 atom 혹은 selector를 구독하지 않는다.
즉, 구독을 하고 있지 않으므로 atom 및 selector 상태가 변경되어도 이 훅을 사용하고 있는 컴포넌트는 re-rendering 되지 않는다!
이처럼 아무 훅이다 사용하면 쓸데없는 re-rendering을 초래할 수 있다.
예를 들어 useSetRecoilState 훅만 사용해서 해결될 수 있는 문제를 useRecoilState를 써서 해결한다면, 관련된 atom 혹은 selector를 구독하게 되므로 비효율적인 동작을 만들어낼 수 있다는 것이다.
또한 selector 없이 useSetRecoilState 훅을 활용하여 atom을 직접 컨트롤 할 수도 있겠지만, atom을 직접 변경하는 것은 의도치 않은 사이드 이펙트를 만들어 낼 수 있다는 점을 유의해서 사용해야 할 것 같다.
atomFamily란?
recoil에서는 특정 값에 따라서 atom의 상태를 다르게 생성할 수 있는 atomFamily 라는 것을 제공해준다.
function atomFamily<T, P: Parameter> ...
export const countStateFamily = atomFamily<number, number>({
key: "countStateFamily",
default: (num) => {
return num;
},
});
atom의 사용법과 거의 비슷하지만 살짝 달라졌다.
위의 정의처럼 타입의 첫번째에는 atom이 공유하는 데이터의 타입이 들어가고, 두번째에는 외부에서 전달할 인자의 타입이 들어간다.
default의 매개변수로 외부에서 인자가 전달되면, 해당 인자의 값에 따른 atom을 여러개 생성해 낼 수 있다.
export default function TestPage() {
const number0 = useRecoilValue(countStateFamily(0));
const number1 = useRecoilValue(countStateFamily(1));
return (
<div>
<h1 className="body1-medium">{number0}</h1>
<h1 className="body1-medium">{number1}</h1>
</div>
);
}
위처럼 간단한 테스트 페이지를 만든 후 결과를 확인해보면
인자의 값에 따라 다른 atom 값이 생성되었다는 것을 확인할 수 있다.
selectorFamily란?
atom과 마찬가지로 selector도 인자에 따라 다르게 생성해낼 수 있는 selectorFamily가 있다.
function selectorFamily<T, Parameter>({
...
get: Parameter => ({get: GetRecoilValue}) => Promise<T> | RecoilValue<T> | T,
set: Parameter => (
{
get: GetRecoilValue,
set: SetRecoilValue,
reset: ResetRecoilValue,
},
newValue: T | DefaultValue,
) => void,
...
})
atomFamily와 받는 타입은 동일하다.
get 속성과 set 속성은 기존 selector와 비슷하지만 역시 살짝 달라졌다.
다소 복잡하지만 그저 밖에서 전달되는 인자를 위한 파라미터가 있는 함수로 한번 더 래핑되었다고 이해하면 될 것 같다.
export const countStateSelectorFamily = selectorFamily<number, number>({
key: "countStateSelectorFamily",
get:
(num) =>
({ get }) => {
const atomNum = get(countStateFamily(num));
return atomNum * num;
},
});
atomFamily와 마찬가지로 관리하는 데이터의 타입과 외부에서 전달받는 인자의 타입을 선언해줘야 한다.
전달받는 인자의 값은 get 속성 함수에서 한번 더 래핑된 함수의 매개변수가 받게된다.
해당 매개변수의 값에 따라 파생되는 값이 달라지게 되는 것이다.
export default function TestPage() {
const number0 = useRecoilValue(countStateSelectorFamily(1));
const number1 = useRecoilValue(countStateSelectorFamily(2));
return (
<div>
<h1 className="body1-medium">{number0}</h1>
<h1 className="body1-medium">{number1}</h1>
</div>
);
}
위처럼 테스트 페이지를 짜서 확인해보면
위의 결과를 확인할 수 있다.
num에 전달한 인자 값이 들어가게 되고, 해당 인자를 사용해 atomFamily에 접근하여 atom의 값을 다르게 가져왔다.
이처럼 atomFamily와 selectorFamily를 활용하면 외부에서 인자를 넘겨서 해당 인자 값에 따라 여러가지 로직을 수행할 수 있을 것이다.
recoil을 활용하여 toast 기능 마이그레이션
import { atom, selector } from "recoil";
interface ToastInfoProps {
open: boolean;
text: string;
}
export const toastInfo = atom<ToastInfoProps>({
key: "toastInfo",
default: {
open: false,
text: "",
},
});
export const toastInfoSelector = selector<ToastInfoProps>({
key: "toastInfoSelector",
get: ({ get }) => {
return get(toastInfo);
},
set: ({ set }, newValue) => {
set(toastInfo, newValue);
},
});
위와 같이 공유할 toast 정보를 atom으로 생성하였고, 해당 atom을 변경해야할 때를 위해 selector도 하나 생성해주었다.
atom에 직접 접근하여 useSetRecoilState 같은 훅으로도 toast 정보를 바꿀 수도 있겠지만 의도치 못한 사이드 이펙트를 방지하기 위해서 selector를 생성하였다.
'use client'
export default function ToastProvider() {
const [toast, setToast] = useRecoilState(toastInfoSelector);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (toast.open) {
timeoutId = setTimeout(() => {
setToast((prev) => {
return { ...prev, open: false };
});
}, 1000);
}
return () => {
clearTimeout(timeoutId);
};
}, [toast.open, setToast]);
return <Toast open={toast.open} text={toast.text} />;
}
toast는 여러 페이지에서 사용해야 하므로, 모든 페이지에서 공통적인 컴포넌트에 Toast 컴포넌트를 선언해준 뒤 다른 파일에서 toast정보만 변경해주면 언제든지 UI에 뜰 수 있도록 해야했다.
난 Next를 사용하고 있고, recoil은 클라이언트 컴포넌트에서만 사용할 수 있으므로 위처럼 Toast 컴포넌트를 관리하는 별개의 ToastProvider 클라이언트 컴포넌트를 생성해준 뒤,
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="width-[100%] height-[100%]">
<body className="w-[100%] h-[100%]">
<main className="w-[100%] h-[100%] fixed overflow-hidden">
<RecoilRootLayout>
{children}
<ToastProvider />
</RecoilRootLayout>
</main>
</body>
</html>
);
}
위와 같이 루트 레이아웃에 포함시켜주었다.
ToastProvider 컴포넌트에서 atom 값을 파악하여 toast가 open상태가 되면 1초뒤에 open이 false가 되도록 useEffect 안에 로직을 구현해주었다.
예를 들어 장소 카드에서 스크랩을 누르면 '저장할 큐레이션을 선택해주세요' 라는 toast를 띄워줘야 했다.
const setToast = useSetRecoilState(toastInfoSelector);
const handleScrap = async (
e: React.MouseEvent<SVGSVGElement, MouseEvent>
) => {
e.preventDefault();
const token = await validateToken();
if (!token) {
location.replace("/login");
} else {
openModal();
setToast({
open: true,
text: "저장할 큐레이션을 선택해주세요",
});
}
};
스크랩 아이콘을 누르면 해당 로직을 수행한다.
여기서는 Toast 컴포넌트를 직접 사용하는 것이 아니므로 ToastProvider에 있는 toast를 띄우기 위해 상태만 변경해주면 된다.
인자로 넘겨지는 atom혹은 selector를 구독하는 훅을 사용하게 되면 ToastProvider에 있는 Toast가 띄워질 때 해당 장소 카드 컴포넌트도 re-rendering 되는 불필요한 동작이 수행될 수 있다.
따라서 나는 useSetRecoilState를 통해 selector를 구독하지 않고 toast atom의 상태만 변경해 줌으로써 toast 상태가 변해도 장소 카드 컴포넌트는 re-rendering 되지 않도록 처리하였다.
장소 카드 컴포넌트에 console.log('place card rendered') 코드를 추가한 후 스크랩 버튼을 눌러보았다.
콘솔에 찍히는 것은 현재 모달창으로 인해 장소카드가 re-rendering 되는 것이라 그렇다(리팩토링 전^^).
selector를 구독하는 useRecoilState훅을 사용한 후 콘솔창을 봐보자.
구독을 하는 순간 toast의 상태가 변경됨에 따라 장소 카드가 re-render되는 것을 확인할 수 있다.
이처럼 단 하나의 RecoilRoot 로만 감싸주면 어디서든지 활용할 수 있고, useState와 유사한 사용 방법으로 매우 편리하고 가볍게 사용할 수 있다.
다만 기능 구현만 되면 끝이 아니라 어떻게 사용하느냐에 따라 사이드 이펙트도 없앨 수 있고, 불필요한 re-rendering도 손쉽게 방지할 수 있으니 항상 정확한 공부가 필요한 것 같다!!
참고
Recoil
A state management library for React.
recoiljs.org
https://blog.nextinnovation.kr/tech/Recoil/
상태관리 라이브러리 Recoil
상태관리 라이브러리와 Recoil에 대해 알아보겠습니다.
blog.nextinnovation.kr
https://dev-ellachoi.tistory.com/54
순수함수란 무엇인가요? 불변성과 사이드 이펙트와 연결하여 설명해 주세요.
👩🏻💻 순수함수란 무엇인가요? 불변성과 사이드 이펙트와 연결하여 설명해 주세요. 💁🏻♀️ 요약하자면 , 순수함수란 사이드 이펙트가 없는 함수, 즉 함수의 실행이 외부에 영향을 끼
dev-ellachoi.tistory.com
'FrontEnd > React' 카테고리의 다른 글
아토믹 디자인(Atomic Design) 도입하기 (0) | 2024.05.08 |
---|---|
React 렌더링 최적화에 대한 고찰 (2) | 2024.04.10 |
React 에서 Context API 효율적으로 사용하기 (0) | 2024.04.04 |
프론트엔드(React) 클린 코드로 나아가기 (0) | 2024.03.29 |
Suspense 효율적으로 사용하기 (4) | 2024.03.21 |