recoil state + next/router url 싱크 맞추기

Min Su Kwon·2022년 10월 3일
0
post-thumbnail
post-custom-banner

Next 앱에서 리코일을 통해서 상태관리를 할 때, 특정 상태와 URL의 양방향 싱크를 맞춰야 하는 요구사항이 있다고 치자. Recoil 공식 문서에서는 이렇게 상태와 url, storage 등과 싱크를 맞추려면 recoil-sync 라이브러리를 사용하는 것을 추천하고 있고, 다양한 장점이 있다고 한다. 하지만 이건 atom effects만으로도 구현 가능한 부분인만큼, next/router - recoil state 간의 싱크를 맞출 수 있는 custom effect를 만들고, 이를 활용해서 싱크를 맞춰보자.

만들어 볼 것

목표

몇가지 이름이 있는 리스트 아이템들이 있고, 제일 최근에 클릭한 리스트 아이템을 볼드 표시해주는 간단한 페이지를 만들어 볼 예정이다. 단, 앱 내에서 왔다갔다 할때 상태가 유지되어야 하며, 새로고침을 했을 시에도 값이 유지되어야 하고, 해당 링크를 공유하여 다른 브라우저를 통해서 접속한 경우에도 링크를 공유한 시점의 상태가 유지되어야 한다.

(기본적으로 Next, Recoil에 대한 이해가 있다는 가정하에 작성했습니다. 각각에 대해 잘 모르는 상태라면, 이해가 되지 않을 수 있습니다.)

Recoil State 활용

우선, 리스트 아이템이 될 수 있는 값들을 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 StorageSession Storage를 활용하는 방법이 있다. 하지만 이 방법의 경우 해당 브라우저에만 저장이 되는 정보기 때문에, 사용자가 링크를 공유한 후 다른 브라우저에서 해당 링크를 열었을 때도 같은 화면이 보이길 기대한다면 기대와 다르게 디폴트값이 선택된 상태로 보이게 된다.

URL과 싱크를 맞추자

다른 방법으로, 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.push 대신에 Router.replace를 써주면 된다. 그때그때 요구사항에 맞게 지정해주면 되는 부분이다.

router_replace

부족한 부분

  • 서버사이드에서 값 초기화가 url에 맞게 되지 않고, 항상 기본값으로 초기화된다. 서버에서 URL에 접근할 방법이 필요하다. -> 현재는 방법을 못 찾았음..
  • 비슷한 유즈케이스가 있을때 비슷한 코드를 또 작성하게 된다. 다른 atom에도 손쉽게 import해서 적용할 수 있도록 제너럴한 이펙트를 만들어서 분리해놓는게 좋다. -> 다음에 이어서 작성 예정

CodeSandbox Embed

profile
이제 막 커리어를 시작한 소프트웨어 엔지니어입니다. 배운 것을 정리하면서 조금 더 깊이 이해하려는 습관을 들이려고 합니다. 피드백은 언제나 환영입니다.
post-custom-banner

0개의 댓글