Next 앱에서 리코일을 통해서 상태관리를 할 때, 특정 상태와 URL의 양방향 싱크를 맞춰야 하는 요구사항이 있다고 치자. Recoil 공식 문서에서는 이렇게 상태와 url, storage 등과 싱크를 맞추려면 recoil-sync 라이브러리를 사용하는 것을 추천하고 있고, 다양한 장점이 있다고 한다. 하지만 이건 atom effects만으로도 구현 가능한 부분인만큼, next/router - recoil state 간의 싱크를 맞출 수 있는 custom effect를 만들고, 이를 활용해서 싱크를 맞춰보자.
몇가지 이름이 있는 리스트 아이템들이 있고, 제일 최근에 클릭한 리스트 아이템을 볼드 표시해주는 간단한 페이지를 만들어 볼 예정이다. 단, 앱 내에서 왔다갔다 할때 상태가 유지되어야 하며, 새로고침을 했을 시에도 값이 유지되어야 하고, 해당 링크를 공유하여 다른 브라우저를 통해서 접속한 경우에도 링크를 공유한 시점의 상태가 유지되어야 한다.
(기본적으로 Next, Recoil에 대한 이해가 있다는 가정하에 작성했습니다. 각각에 대해 잘 모르는 상태라면, 이해가 되지 않을 수 있습니다.)
우선, 리스트 아이템이 될 수 있는 값들을 constant로 선언한다.
// constants.ts
export const ITEM_NAMES = [
"Baseball",
"Football",
"Basketball",
"Volleyball",
"Golf",
"Esports"
] as const;
그 다음 위 const를 기준으로 타입을 만들어주고, (ref : TypeScript array to string literal type)
// atoms/items.ts
import { ITEM_NAMES } from "../constants";
type ItemName = typeof ITEM_NAMES[number];
해당 타입을 기반으로하는 atom
을 선언해준다. (atom
은 리코일에서 사용하는 가장 작은 상태의 단위라고 보면 된다)
import { atom } from "recoil";
import { ITEM_NAMES } from "../constants";
type ItemName = typeof ITEM_NAMES[number];
const itemsAtom = atom<ItemName>({ key: "ITEMS", default: "Baseball" });
export default itemsAtom;
이렇게 선언해준 atom을 useRecoilState
훅에 넣어주면, 일반적인 useState
와 같은 형태로 사용할 수 있게 된다. 하지만 이 값은 RecoilRoot
기준 전역으로 공유되기 때문에, 앱 어디서든 접근 및 수정이 가능하다.
import { useRecoilState } from "recoil";
import itemsAtom from "../atoms/items";
import Link from "next/link";
import { ITEM_NAMES } from "../constants";
export default function IndexPage() {
const [currentItemName, setCurrentItemName] = useRecoilState(itemsAtom);
return (
<>
<Link href="/other">Other Page</Link>
<ul>
{ITEM_NAMES.map((itemName) => (
<li
style={{
cursor: "pointer",
fontWeight: itemName === currentItemName ? 600 : 300
}}
onClick={() => setCurrentItemName(itemName)}
>
{itemName}
</li>
))}
</ul>
</>
);
}
이렇게만 해줘도, 요구사항 대부분을 충족하게 된다.
하지만 아래의 요구사항들은 충족하지 못한다.
새로고침이라는건, 결국 페이지를 처음부터 다시 로드해오는 것과 같다. 따라서, 순수 recoil만으로 상태 유지를 하고 있다면 해당 상태가 기억되지 않고, 디폴트 값으로 돌아가는 것이 당연하다.
따라서, 이 값이 새로고침 시에도 유지되게 하려면 리코일 상태를 새로고침 시에도 유지되는 어떤 것에 연결하고 싱크를 맞춰줘야한다. 이럴때 브라우저의 Local Storage나 Session Storage를 활용하는 방법이 있다. 하지만 이 방법의 경우 해당 브라우저에만 저장이 되는 정보기 때문에, 사용자가 링크를 공유한 후 다른 브라우저에서 해당 링크를 열었을 때도 같은 화면이 보이길 기대한다면 기대와 다르게 디폴트값이 선택된 상태로 보이게 된다.
다른 방법으로, URL과 싱크를 맞추는 방법이 있다. 리코일 상태가 업데이트 될 때마다 URL에도 이 정보를 반영하고, 초기값을 설정할때는 URL 값을 참고하는 방법이다.
URL과 리코일 상태값 간의 싱크를 맞추기 위해서 useEffect
를 활용할 수 있겠지만, Recoil에서 제공하는 Atom Effects를 사용한다. 이는 Atom 마다 지정할 수 있는 것으로, 사이드 이펙트 관리 / 상태 싱크 맞추기 / 값 초기화 용도로 사용한다.
우리는 이 상태값을 url의 쿼리스트링에 저장할 예정이다. 간단하게 itemName
이라는 키로 지정한다고 가정하고, 아래와 같이 effect를 작성해볼 수 있다.
({ setSelf, onSet, resetSelf }) => {
if (typeof window !== "undefined" && Router.query["itemName"]) {
setSelf(Router.query["itemName"] as ItemName);
} else {
resetSelf();
}
onSet((newValue, prevValue, isReset) => {
if (isReset) {
Router.push({
pathname: Router.pathname,
query: _.omit(Router.query, "itemName")
});
} else {
Router.push({
pathname: Router.pathname,
query: { ...Router.query, itemName: newValue }
});
}
});
const handleRouteChange = (
url: string,
{ shallow }: { shallow: boolean }
) => {
const uriObject = new URI(url);
const query = uriObject.query(true);
if (uriObject.hasQuery("itemName")) {
setSelf(query["itemName"] as ItemName);
} else {
resetSelf();
}
};
Router.events.on("routeChangeComplete", handleRouteChange);
return () => Router.events.off("routeChangeComplete", handleRouteChange);
}
편의를 위해 lodash
패키지와 urijs
패키지를 사용했다. 처음부터 차근차근 뜯어보면,
if (typeof window !== "undefined" && Router.query["itemName"]) {
setSelf(Router.query["itemName"] as ItemName);
} else {
resetSelf();
}
여기서는 클라이언트 사이드임을 확인 하고, query param key 중에 itemName
이 있다면 해당 값으로 상태값을 초기화해준다. 하지만 서버사이드거나 query param이 없다면, 상태값을 리셋해준다.
onSet((newValue, prevValue, isReset) => {
if (isReset) {
Router.push({
pathname: Router.pathname,
query: _.omit(Router.query, "itemName")
});
} else {
Router.push({
pathname: Router.pathname,
query: { ...Router.query, itemName: newValue }
});
}
});
여기서는 setRecoilState
를 통해서 값이 업데이트 된 경우 실행할 사이드 이펙트가 들어갔다. next 전역 Router 객체를 이용해서 새로운 itemName
을 기준으로 라우팅해준다. 리셋의 경우, 해당 쿼리 파라미터를 제외해버린다.
const handleRouteChange = (
url: string,
{ shallow }: { shallow: boolean }
) => {
const uriObject = new URI(url);
const query = uriObject.query(true);
if (uriObject.hasQuery("itemName")) {
setSelf(query["itemName"] as ItemName);
} else {
resetSelf();
}
};
Router.events.on("routeChangeComplete", handleRouteChange);
return () => Router.events.off("routeChangeComplete", handleRouteChange);
여기서는 next router의 이벤트 발생시 실행할 로직이 들어갔다. 간단하게 routeChangeComplete
이벤트를 listen 하고 있다가, url 업데이트에 맞춰서 recoil 상태값도 업데이트해준다.
위와 같은 effect를 추가하게 되면, 아래와 같이 매 상태 업데이트마다 url이 업데이트 되고, 따라서 새로고침시에도 링크에 맞게 상태가 초기화되게 된다.
위 코드에서는 Router.push
를 활용했기 때문에 아이템을 누를때마다 새로운 브라우저 히스토리가 푸시되어서 기록이 남기 때문에 브라우저 뒤로가기 시에 이전 아이템을 눌렀을때의 상태로 돌아가게 된다.
이를 원하지 않는다면 Router.push
대신에 Router.replace
를 써주면 된다. 그때그때 요구사항에 맞게 지정해주면 되는 부분이다.