STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

STORES 予約 のReactで踏み抜いたアンチパターンと現在

最初に

この記事はhey Advent Calendarの2日目です。

STORES 予約 の開発をしているTak-Iwamotoです。

2021/11/27に行われたJSConfでSTORES 予約 を支えるフロントエンドの技術と題して発表しました。

この記事ではその中から抜粋して、 STORES 予約 のフロントエンドを開発する中で踏み抜いてきたアンチパターンと現在のアーキテクチャについて書かせていただきます。

背景

予約チームではオーナーさまが使用する管理画面(STORES 予約)とエンドユーザーが予約するサービス画面(Coubic)の2種類を開発しており、両方Next.jsが採用されています。

f:id:Tak-Iwamoto:20211130114337j:plain

Next.jsの導入時期がCoubicの方が古いこともあり、採用しているライブラリは異なります。

踏み抜いたアンチパターンと現在のアーキテクチャ

STORES 予約 と Coubic のフロントエンドをReactで開発していく中で、これまでさまざまなアンチパターンを踏んできました。

今回はその中からいくつかピックし、現在どのように改善しているのかと合わせてまとめました。

※ いくつかサンプルコードを書いていますが、疑似コードでそのまま動作するものではないことをご了承ください。

アンチパターン: PresenterとContainerを分けていない

以下のコードではReduxの状態とスタイルの情報が同じコンポーネントに含まれています。

副作用とスタイルの情報が同じコンポーネントに含まれているとテストしずらく、どこで副作用が発生しているのか分かりづらくなるため、コードの見通しが悪くなります。

また、以前はすべてのコンポーネントsrc/components配下に書かれており、コンポーネントの粒度もバラバラでした。

import { shallowEqual, useDispatch, useSelector } from 'react-redux';

const PresenterAndContainer: VFC<Props> = ({
  ...
}) => {
  // side effects. For example, redux state, API requests, etc.
  const dispatch = useDispatch();

  const {...} = useSelector(
    (state: ReduxState) => getHogeStoreState(state),
    shallowEqual,
  );


  return (
    // styles info
    <div className={styles.hoge}>
      <div className={styles.fuga}>
        <Component />
      </div>
    </div>
  );
};

改善後: 現在のディレクトリ構成

現在は以下のようにAtomic Designライクなディレクトリ構造となっています。

ただ、厳密なAtomic Designではなく、コンポーネントの粒度よりもアプリケーションの型や副作用を重視した構成になっています。

src
└───atoms
└───molecules
└───organisms
└───templates
└───hooks
└───contexts
└───interfaces
  • atoms: HTML標準のタグに固有のスタイルを持ち、アプリケーション固有の型は含まない
  • molecules: アプリケーション固有の型を含み、複数atomsを持つ
  • organisms: atomsやmoleculesに副作用を含む。(context, 状態管理のstate, APIリクエストなど)
  • templates: Next.jsの/pagesに配置するコンポーネント
  • hooks: APIリクエストやロジックのcustom hooks
  • contexts: ReactのContext APIを使用したグローバルな値
  • interfaces: アプリケーション共通で使用する型

アンチパターン: コンポーネントのpropsにclassNameがある

コンポーネントにmarginなどのスタイルを持たせたいために、propsにclassNameを持たせて外部からスタイルを渡しているケースです。

一見自由にスタイルを当てることができ、柔軟性があって良い気もします。

しかし、対象コンポーネントを使用する際に内部実装を意識しながらスタイルを当てる必要があるため、関心の分離ができていない状態で望ましくありません。

type Props = {
  className?: string;
};

const ClassNameAntiPattern: VFC<Props> = ({ className }) => {
  return <div className={className}>...</div>;
};

<ClassNameAntiPattern className={styles.hoge} />

現在: スタイル用のタグでラップする・variantを持たせる

マージンなどは対象コンポーネントの責務ではなく、そのコンポーネントを使用する側の責務なので使用する際にスタイル用のタグでラップしています。

const WrapperMargin = () => {
  return (
    // wrap by margin tag
    <div className="my-2">
      <Component />
    </div>
  );
};

const GridGap = () => {
  return (
    // use gap
    <div className="grid gap-4">
      <div>...</div>
      <div>...</div>
      <div>...</div>
      <div>...</div>
    </div>
  );
};

対象コンポーネントのスタイルを外部から変えたい場合はvariantやstatusを持たせることで、スタイルに制限を与えています。

以下はボタンの例です。

import { DetailedHTMLProps, ButtonHTMLAttributes, VFC } from 'react';

export type HTMLButtonProps = DetailedHTMLProps<
  ButtonHTMLAttributes<HTMLButtonElement>,
  HTMLButtonElement
>;

export type Props = HTMLButtonProps & {
  variant?: 'primary' | 'secondary' | 'tertiary';
  status?: 'default' | 'active' | 'negative';
};

export const Button: VFC<Props> = ({
  variant = 'tertiary',
  status = 'default',
  ...props
}) =>  {
  switch (variant) {
    case 'primary': {...}
    case 'secondary': {...}
    case 'tertiary': {...}
    default: {
      const invalidVariant: never = variant as never;
      throw new Error(`Invalid variant: ${invalidVariant}`);
    }
  }

  return {
    ...
  }
}

アンチパターン: 不必要にReduxを多用している

以下はバケツリレーでpropsを渡せばいいところをReduxのstateを親で初期化して、それを子コンポーネントが参照しているコードです。

複数コンポーネントから参照されている状態 == Reduxに置くべきという誤った認識の結果、発生しがちなアンチパターンです。

import { useEffect, VFC } from 'react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
import { init } from '../redux/actions/..';

const ParentComponent: VFC<Props> = props => {
  const dispatch = useDispatch();

  useEffect(() => {
    // initialize redux state
    dispatch(init(props.hoge));
  }, [props.hoge]);

  return <ChildComponent />;
};

const ChildComponent = () => {
  // fetch redux state
  const reduxState = useSelector(
    (state: ReduxState) => getReduxState(state),
    shallowEqual,
  );

  return <Component hoge={reduxState.hoge} />;
};

現在: バケツリレーは悪いことではない・状態管理にはドメインデータの責務を持たせる

先ほどの例では以下のようにバケツリレーで渡すだけで問題ないです。

const ParentComponent: VFC<Props> = props => {
  return (
    <ChildComponent hoge={props.hoge} />
  );
};

type ChildProps = {
  hoge: Hoge;
};

const ChildComponent: VFC<ChildProps> = ({ hoge }) => {
  return (
    <Component hoge={hoge} />
  );
};

また、Reduxなどの状態管理のstateにはコンポーネントそのものの状態ではなく、モデリングしたデータ構造を格納することが重要です。

Reduxの公式ドキュメントでも言及されていますね。

Organize State Structure Based on Data Types, Not Components

アンチパターン: クライアントのタイムゾーンで時刻を算出する

JavaScriptDateを使用するとクライアントのタイムゾーンで時刻が算出されるので注意が必要です。

クライアントのタイムゾーンと予約サービスのオーナーさまのタイムゾーンが異なる場合に不整合が発生します。

// calculate by client timezone
const invalidDate = new Date();

現在: オーナーさまのタイムゾーンで時刻を計算する

// need to consider the owner timezone
import { DateTime } from 'luxon';

const correctDate = DateTime.now().setZone(owner.timezone);

LuxonDay.jsなどのライブラリを使用してタイムゾーンを考慮した時刻にする必要があります。

ECMAScript標準の日付APITemporalもまだStage 3ではありますが、意識しておくとよいかもしれません。

アンチパターン: 認証情報が必要なページでSSRする

認証状況が必要なページでSSRする場合、CSRとは異なり自動で認証情報がリクエストに付与されません。

なので、nookiesなどを使用して明示的にCookieを付与する必要があります。

import { GetServerSideProps, NextPageContext } from 'next';
import { parseCookies } from 'nookies';

export const getServerSideProps: GetServerSideProps = async (
  ctx: NextPageContext,
) => {
  const cookie = parseCookies(ctx);

  const res = await fetch('https://...', {
    credentials: 'include',
    // set cookie
    headers: {
      Cookie: cookie,
    },
  });
  ...
};

SSRしたいモチベーションとして、CDNでキャッシュしたい・SEO対策したいといったことがありますが、認証情報を含むページはいずれにも当てはまらないのでSSRする恩恵は少ないです。

Next.jsの公式docでもユーザー特有の情報はCSRで取得することが推奨されています。

If your page contains frequently updating data, and you don’t need to pre-render the data, you can fetch the data on the client side. An example of this is user-specific data.

現在: データ取得のポリシー

管理画面はSWRを使用したCSRでのデータ取得、サービス画面は認証情報が含まないページのみSSR、それ以外はCSRという方針にしています。

f:id:Tak-Iwamoto:20211130120237j:plain

SWRを使用しているサンプルコードは以下に示します。

APIごとにuseSWRをラップしたcustom hooksをorganismsのコンポーネントで使用しています。

また、APIスキーマ定義にはOpenAPIを使用しているので、スキーマから自動生成されたAPIクライアントを使用しています。

import useSWR from 'swr';
import { OpenApiService } from '/openapi';

export const userSwrKey = (userId: string) => {
  return ['api/users', userId];
};

// custom hook using SWR
export const useUser = (userId: string) => {
  const swrKey = userSwrKey(userId);

  const fetcher = async () => {
    const result = await OpenApiService.getUserDetail(userId);
    return result;
  };

  const { data, error, isValidating } = useSWR(swrKey, fetcher);

  return { data, error, isValidating };
};

また、更新処理も同様にcustom hooksを使用しています。

画面への反映はSWRのキャッシュを更新することで行うため、更新のAPIリクエストとキャッシュの更新をひとまとめにしています。

import { useCallback, useState } from 'react';
import { mutate } from 'swr';
import { OpenApiService } from '/openapi';

type UpdateUser = (params: {
  userId: string;
  values: { name: string; age: number };
}) => Promise<{ error?: ApiError }>;

export const useUpdateUser = () => {
  const [isValidating, setIsValidating] = useState(false);

  const updateUser = useCallback<UpdateUser>(async ({ userId, values }) => {
    setIsValidating(true);
    try {
      await OpenApiService.updateUser(userId, values);
      await mutate(userSwrKey(userId));
      return {};
    } catch (error) {
      return { error };
    } finally {
      setIsValidating(false);
    }
  }, []);
  return { isValidating, updateUser };
};

最後に

予約チーム で踏み抜いたReactのアンチパターンと現在について書かせていただきました。

明日のアドベントカレンダーkomiさんと@tomohirosonさんです!

heyでは全ポジション積極採用中なので、Reactを書きたい方もそれ以外の方もご気軽にご応募、カジュアル面談お待ちしています!!

hello.hey.jp