GraphQL + Apollo Client + TypeScript + React で型安全なフロントエンド開発を実現した話

はじめに

こんにちは。レバレジーズ株式会社の大滝です。

私は、レバレジーズのHRテック事業部に所属し、新規SaaSサービスのフロントエンド開発を行っております。

今回は雑然としがちな新規開発、とりわけフロントエンド開発で避けたかった4つの課題を、技術的な観点から回避していった点を紹介したいと思います。

新規開発で回避したかった問題

私たちの開発は新規開発でしたので、できるだけ技術負債を作らないように、かつスピード感を持って開発を行う必要がありました。 そこでフロントエンド開発を行う上で回避したかったポイントがいくつかあります。

  1. バックエンドとフロントエンド間でAPI仕様確認と管理に時間がかかる
  2. 型安全ではない
  3. 画面によってコンポーネントのデザインがバラバラ
  4. 入力動作が遅い

また前提として、開発中のサービス全体がマイクロサービスアーキテクチャを採用しており複数のサービス間がGraphQLで通信されていると言う特徴がありました。

図1 マイクロサービスアーキテクチャ構成図

フロントエンドはBFF(Backends For Frontends)に接続し、BFFではバックエンドのマイクロサービスのAPIの集約を行っています。

問題の回避方法と技術選定

上記した問題をクリアするには適切な技術選定を行う必要がありました。

しかし技術選定の難易度が高かったため、弊社のテックリードや開発メンバーと協力し調査を行いました。

結果的に下記の他のマイクロサービスで使用している技術と近く、かつ社内ナレッジがある程度蓄積されていると言う観点から、Apollo Client(graphql-codegen)/TypeScript/Reactを採用し、フォームライブラリとして、React Hook Formを利用しました。

これらの技術により、1の課題に対して、フロントエンドはBFFのエンドポイントからschema(APIの型定義)を取り込みそこからコードを生成することで回避しました。 また、schemaから生成したコードをもとに静的型付き言語であるTypeScriptを用いて実装を行うことで2の課題を回避しました。

3の課題に対しては、デザインの再利用性を高められるようにAtomic designを採用し、それに相性の良いReactを用いました。 さらに、動作速度向上のためにReact Hook Formという依存関係が少なく、軽量なライブラリを用いることで動作速度を向上させることで4の課題を回避しました。

画面実装までのフロントエンド開発フロー

上記の課題をクリアした実際の開発の様子を紹介します。 実際の開発では下記のようなフローで開発を行っております。

図2 実際の開発の手順

この開発フローに沿って、下記の画像のような簡単なユーザーの住所を変更する画面を実際に作ってみます。

図3 ユーザーの住所を変更する画面

GraphQL schemaの実装

サンプルのGraphQLスキーマを用意しました。 今回取り込むschemaはこちらです

type User {
  firstName: String 
  lastName: String 
  address: String 
}

type Query {
  user:  User
}

氏名、住所を持っているUser情報を取得するQuery型に入れます。 今回はサンプルなので1名分のUserを取得する形にします。

フロントエンドでのschemaの取り込み

次に、このスキーマをフロントから取り込みます。 まずはQuery情報を記載するgraphqlファイルを作成します

query userSearch {
  user {
    firstName
    lastName
    address
  }
}

これをGraphQL Code Generatorという機能を使用して、上記のgraphqlファイルのスキーマ情報を取り込みます。 GraphQL Code Generatorはcodegen.yamlにエンドポイントやgraphqlファイルのディレクトリ等を記載してスキーマ情報を読み込みます。

React Hook Formを用いたFormの実装

取り込んだスキーマを使用できるFormを実装します。 React Hook Formを用いてテキストボックスを実装してみます。

今回はMaterial-UIのMuiTextFieldを使います。

textFields.tsxに下記のようにMuiTextFieldをReact Hook Formでラップします。

仕様としてはFormのデフォルト値、ヘルパーテキスト、エラーメッセージが表示でき、nameをキー、入力値をバリューとしてsubmitできるものとしておきます

export type FormTextProps = TextFieldProps & {
  name: string;
  defaultValue?: string;
  showError?: boolean;
  rules?: Exclude<RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>;
};

const TextFields: React.FC<FormTextProps> = ({
  name,
  rules,
  defaultValue = '',
  error,
  showError = true,
  ...textFieldProps
}) => {
  const { control, errors } = useFormContext();

  return (
    <>
      <Controller
        control={control}
        name={name}
        defaultValue={defaultValue}
        rules={rules}
        error={!!(get(errors, name) || error)}
        render={({ onChange, onBlur, value }) => (
          <MuiTextField onChange={onChange} onBlur={onBlur} value={value} {...textFieldProps} />
        )}
      />
      {showError && (
        <ErrorMessage
          errors={errors}
          name={name}
          render={({ message }) => (
            <FormHelperText error={true}>
              {message}
            </FormHelperText>
          )}
        />
      )}
    </>
  );
};
export default memo<FormTextProps>(TextFields);

コンポーネントを実装する際のポイントですが、下記の5点を意識しています。

  • Material-UIのTextFieldPropsの型定義を拡張してReact Hook Formで扱いやすくする。
  • nameタグはReact Hook Formでsubmitした際のkeyにあたるので必ずpropsとして注入するように必須にする。
  • rulesはReact Hook FormのRegisterOptionsの型定義から必要なものを集めてくる。
  • defaultValueは指定していないとwarningになるので空文字を初期値として設定する。
  • メモ化して無駄なレンダリングを減らす。

Form値に入力した値の表示テスト

最後に新住所を入力して入力値をconsoleで確認できるところまで作ってみます。

ユーザーの氏名を表示して、住所を新しく登録する画面を作成していきます。

見栄えをよくするためにスタイルも当てていきます。

const SamplePage: React.FC<{}> = () => {
  const methods = useForm<{ testTextFields: string }>({
    mode: 'onBlur',
  });
  const { handleSubmit, getValues } = methods;

  const onSubmit = () => {
     console.log('submit:', getValues());
  };

  const { loading, data } = useUserSearchQuery();

  useEffect(() => {
     console.log(data);
  }, [data]);

  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit(onSubmit)}>
        {!!loading && <>loading...</>}
        {!!data && (
          <>
            <Box display="flex" justifyContent="center" mb={2} mt={2}>
              ユーザーの住所情報を変更してください
            </Box>
            <Grid container alignItems="center" justify="center">
              <Grid item xs={8} style={{ backgroundColor: '#668bcd0f' }}>
                <Box m={2}>
                  <Grid container justify="center">
                    <Grid item xs={4}>
                      姓: {data?.user?.firstName}
                    </Grid>
                    <Grid item xs={4}>
                      名: {data?.user?.lastName}
                    </Grid>
                    <Grid item xs={8}>
                      現在の住所: {data?.user?.address}
                    </Grid>
                  </Grid>
                </Box>
              </Grid>
              <Grid item xs={6}>
                <Box mt={4}>
                  <TextFields
                    name={'newAddress'}
                    label={'新住所'}
                    rules={{
                      required: {
                        message: 'この項目は必須です',
                        value: true,
                      },
                    }}
                    defaultValue={data?.user?.address || ''}
                    helperText={'新しい住所を入力してください'}
                    variant={'outlined'}
                    fullWidth={true}
                  />
                </Box>
              </Grid>
            </Grid>
            <Box mt={3} display="flex" justifyContent="center">
              <Button type="submit" variant="contained" color="primary">
                更新
              </Button>
            </Box>
          </>
        )}
      </form>
    </FormProvider>
  );
};

export default SamplePage;

APIで取得したデータを表示する際は、codegenでgenerateしたファイルからAPIをfetchするuseUserSearchQueryをインポートして使用します。

ここで使用しているqueryのhookはGraphQL Code Generatorにtypescript-react-apolloのpluginを入れて生成されるもので、手間のかかるAPIのエラーのハンドリング部分の実装をせずにhookをimportするだけですぐにAPIを使用することができます。

useUserSearchQueryの実態をgenerateされたファイルで確認してみます

export function useUserSearchQuery(baseOptions?: Apollo.QueryHookOptions<UserSearchQuery, UserSearchQueryVariables>) {
        const options = {...defaultOptions, ...baseOptions}
        return Apollo.useQuery<UserSearchQuery, UserSearchQueryVariables>(UserSearchDocument, options);
      }

UserSearchQuery型がGenericsで渡されているので戻り値は型安全になっています、また使用する際はスニペットが効くのでかなり開発しやすいです。

空の状態でsubmitした際にはバリデーションがかかり、onBlurでもバリデーションがかかるように実装しています。この時にuseFormにGenericsで渡したFormの型がReact Hook Formに登録されます。

今回はMaterial-UIのBoxとGridを用いて画面を実装しましたが、これによりレスポンシブにも対応できる作りになっています。

まとめ

簡単ではありますが、新規開発等でも型安全にかつスピード感を持って開発できるような開発手法を紹介いたしました。

このように、GraphQLのスキーマから型情報を取得しTypeScriptとReactを用いて型安全な実装ができる上に、React Hook Formを用いることで簡単にFormの値の制御が行うことができるので非常に使い勝手が良いです。

HRテック事業部では一緒にサービスを作ってくれる仲間を募集中です!ご興味を持たれた方は、下記リンクから是非ご応募ください。 https://recruit.jobcan.jp/leverages/