それSWRじゃなくてgetServerSidePropsでいいよねっていう場面の話

21594 ワード

Next.jsでフロントエンドを作っているプロジェクトにSWRを導入した際に「SWRかgetServerSidePropsのどちらでデータ取得をすればいいのか迷う」みたいな意見があったので、チームで方針を作ったときに考えたことの話。

フロントエンドの構成については、バックエンドのREST APIを叩きに行ってデータを取得・更新する至って普通の構成。ただユーザーの認証をする必要があり、sessionの有無を見つつページの出し分けとかAPIの取得をする形になっている。認証部分はAWSのCognitoでクライアントにはnext-authを使用した。

データ取得の方針

データ取得・更新について、基本的にSWRを使用する方針にした。SWRについては周知の通りキャッシュ機構で高速にデータの表示ができるので使わない手は無いと思う。

また、next-authのuseSessionを使ってsession(に含まれるtoken)の有無を検証してからAPIコールするように fetcher を実装したので、SWRと合わせるとクライアントサイドで完結する(getServerSidePropsにデータ取得中と後のコンポーネントの出し分けをグダグダ書かないで済む)のも良い。

じゃあ全部SWRでやろうね!となるのだが...

SWRが向いてない場面

以下のような認証後のページの出し分けはクライアントサイドで処理しないほうがいいので getServerSideProps を使う。

  • ユーザーがアプリの /auth からログイン、Cognitoのページにリダイレクトして認証
  • アプリの /auth にリダイレクト
    • sessionがあるかつユーザーのプロフィールがあれば /
    • sessionがあるかつプロフィールがなければ /auth/signup

以上のフローの際に Cognito からのリダイレクトで/authを開き直すので、ページが再度読み込まれる。

ここで以下のようにSWRによってプロフィールを取得、有無を検証してリダイレクトを実装すると一瞬 /auth ページが見えたり、profileがまだ取得できずrevalidateを待つ前にリダイレクトが走って予期せぬUIを表示したりする。

// 🙅‍♂️
const AuthPage: NextPage = () => {
  const { data: session } = useSession();
  const router = useRouter();
  
  const { data: profile, error: profileError } = useSWR(['process.env.ENDPOINT_URL' + '/users/profile', session?.accessToken], fetcher);
  
  useEffect(() => {
    if (!profile) router.push('/auth/signup');
    if (session && profile) router.push('/')
  }, [session, profile, router]);
  
  // ~~~

}

そこで以下のように getServerSideProps でリダイレクトするようにする。またredirectの分岐を増やすのが面倒だったので、Cognitoページからのredirect先は /auth/signup に変更している。

// 🙆‍♂️
const Signup: NextPage = () => {
  return <SignupPage />;
};

export default Signup;

export const getServerSideProps: GetServerSideProps = async (context) => {
  const session = await getSession(context);
  if (!session) {
    return {
      props: {},
    };
  }

  const profile = await fetchProfile(
    process.env.ENDPOINT_URL + '/users/profile',
    session.accessToken
  );

  // プロフィールがある場合は / へ
  if (profile.status === 200) {
    return {
      redirect: {
        permanent: false,
        destination: '/',
      },
    };
  }

  return {
    props: {},
  };
};

以上まとめると全部SWRでとりあえず書くのではなく、リダイレクトなどHTMLが表示されてしまうとまずい場合のデータ取得はgetServerSideProps、表示された後の取得はSWRでするように使い分けようねという方針を立てておくと迷わないで進められる話。

おまけ - fetcher の設計

データ取得時のエラーと、取得したデータの内容がエラーなのかで切り分けたいのでこういう設計にした。

レスポンスが snake_case で返ってくるので、camelcase-keystype-festで値と型をパースしている。

export type ReturnFetchProfileType = {
  body: {
    id: number;
    cognito_user_id: string;
    user_name: string;
    age: number;
    gender: string;
    email: string;
    created_at: string;
    updated_at: string;
    profile_image: string;
    profile_description: string;
    following: number;
    followers: number;
  };
} & Status;

export const fetchProfile = async (
  url: string,
  jwtToken?: string
): Promise<CamelCasedPropertiesDeep<ReturnFetchProfileType>> => {
  if (!jwtToken) throw new NoJwtTokenError();

  const res = await getFetcher({ url, jwtToken }).catch((err) => {
    throw new ReadSchemaError(err);
  });

  const json = await res.json();
  return Promise.resolve({ status: res.status, body: camelcaseKeys(json) });
};

// getFetcher.ts
type GetFetcherParams = {
  url: string;
  jwtToken: string;
};

const getFetcher = ({ url, jwtToken }: GetFetcherParams) => {
  return fetch(url, {
    headers: {
      Authentication: jwtToken,
    },
  });
};

useSWR で呼び出すと datastatus が入るので返ってきたデータが正常か異常か判断が出来る。

また、エラーも種類によって Error クラスをextendsしたものにしておくと便利。

export class ReadSchemaError extends Error {
  public info: ExtendableObject;
  public status: number;

  constructor(...params: ErrorParamsTuple) {
    super(...params);

    this.info = {};
    this.status = 0;
  }
}

export class NoJwtTokenError extends Error {
  public info: ExtendableObject;
  public status: number;

  constructor(...params: ErrorParamsTuple) {
    super(...params);

    this.info = { message: 'No jwt token.' };
    this.status = 0;
  }
}