1. TOP
  2. BLOG
  3. DEVELOP
  4. React+TypeScript+GraphQL+Apollo ClientでReduxを使わずにアプリ開発した話

React+TypeScript+GraphQL+Apollo ClientでReduxを使わずにアプリ開発した話

2020.02.20


こんにちは、エンジニアの川上(@natsumican63)です。

前回は、Webエンジニアのみで、ReactNativeアプリ開発を行った話を書きました。

ReactNativeアプリを開発した際、下記の技術を使用しました。

  • TypeScript
  • React Native
  • GraphQL
  • Apollo Client

今回は、これらの技術スタックを用いた開発を行ってみた際の模様について、お伝えしていこうと思います。

要約

  • Rexux/Mobx等の状態管理ライブラリを使用せず、Apollo Clientのキャッシュ機構とReactのlocal stateのみで状態管理を行った
  • @apollo/react-hooksを利用することで、通信周りのコードの見通しが良くなった
  • TypeScript(クライアント側)と、GraphQLの型システムで、更に型安全な開発が望める

Apollo Clientの導入

Apollo Clientは、クライアントがGraphQLサーバーと通信し状態管理を行うためのライブラリで、Reactだけでなく、VueやiOSなどもサポートしています。(GraphQLの説明については良い記事がたくさん存在するので、ここでは省略します。

Apollo Clientの主な特徴として、宣言的にデータフェッチやUI更新を記述できることが挙げられます。

LoadingやErrorなどの通信状態をコンポーネントが自動的にハンドリングしてくれたり、サーバーからのレスポンスを待たずにUIを更新するOptimistic UI (楽観的UI)の実装を簡単に行うことができます。

使い方は、次のようにApollo Clientのインスタンスを生成する際に、コンストラクタにGraphQLサーバーのエンドポイントを渡し、Reduxのように、アプリ全体をApolloProviderでwrappeすることで、コンポーネントがApollo Clientの機能を利用できるようになります。

import React from 'react';
import { View, Text } from 'react-native';
import { MainScreen } from './MainScreen';

import ApolloClient from 'apollo-boost';
import { ApolloProvider } from '@apollo/react-hooks';

const client = new ApolloClient({
  uri: API_URL, // GraphQLサーバーのエンドポイントを設定
});

// Appollo Providerでアプリ全体をwrappeする
const App = () => (
  <ApolloProvider client={client}>
    <MainScreen />
  </ApolloProvider>
);

@apollo/react-hooksの導入

今回、ほとんどのコンポーネントがFunction Componentで実装されており、ステート管理や副作用の取り扱いも、React16.8から導入されたhooksを用いて実装しました。

Apollo Clientでも、 @apollo/react-hooks というhooks拡張が提供されており、 useQueryuseMutation といったカスタムフックを利用することで、次のように通信周りをすっきり記述することができます。

import React from 'react';
import { View, Text } from 'react-native';
import { LoadingComponent, ErrorComponent } from './components';
import { gql, useQuery } from '@apollo/client';

// クエリを定義
const GET_GREETING = gql`
  query getGreeting($language: String!) {
    greeting(language: $language) {
      message
    }
  }
`;

function Hello() {
  // マウント時にリクエストが走る
  const { loading, error, data } = useQuery(GET_GREETING, {
    variables: { language: 'japanese' },
  });

  // 通信状態に応じたコンポーネントを表示
  if (loading) return <LoadingComponent />;
  if (error)   return <ErrorComponent error={error} />;

  return (
    <View>
      <Text>Hello {data.greeting.message}!</Text>
    </View>;
  );
}

GraphQLのスキーマからTypeScriptの型定義とhooksを自動生成する

graphql-code-generatorは、クライアント側で必要なデータをクエリとして書くと、サーバー側のスキーマを元に、TypeScriptの型定義とhooksを自動生成してくれます。
例えばこのようなクエリを書くと、

query user {
  user {
    id
    age
    name
  }
}

graphql-code-generatorは、このような型定義とhooksを自動生成します。

export type User = {
  __typename?: 'User'
  id: Scalars['ID']
  name: Scalars['String']
  age?: Maybe<Scalars['Int']>
}

export type Query = {
  __typename?: 'Query',
  user: User,
};

export type UserQuery = { __typename?: 'Query' } & {
  user: { __typename?: 'User' } & Pick<User, 'id' | 'age' | 'name'>
}

export const UserDocument = gql`
  query user {
    user {
      id
      age
      name
    }
  }
`

export function useUserQuery(
  baseOptions?: ApolloReactHooks.QueryHookOptions<
    UserQuery,
    UserQueryVariables
  >,
) {
  return ApolloReactHooks.useQuery<UserQuery, UserQueryVariables>(
    UserDocument,
    baseOptions,
  )
}
export function useUserLazyQuery(
  baseOptions?: ApolloReactHooks.LazyQueryHookOptions<
    UserQuery,
    UserQueryVariables
  >,
) {
  return ApolloReactHooks.useLazyQuery<UserQuery, UserQueryVariables>(
    UserDocument,
    baseOptions,
  )
}

export type UserQueryHookResult = ReturnType<typeof useUserQuery>
export type UserQueryResult = ApolloReactCommon.QueryResult<
  UserQuery,
  UserQueryVariables
>

自動生成したhooksは、下記のようにFunction Component内で利用します。

import { useUserQuery } from './graphql/generated'

function User() {
  const { loading, error, data } = useUserQuery()

  if (loading) return <LoadingComponent />;
  if (error)   return <ErrorComponent error={error} />;

  return (
    <View>
      <Text>Hello {data.user.name}!</Text>
    </View>
  );
}

graphql-code-generatorを利用することで、型の二重定義が無くなり、より型安全な開発ができるようになりました。

Apollo Clientのキャッシュ機構について

Apollo Clientでは、キャッシュ機構として、InMemoryCacheという仕組みが提供されています。

次のように、Apollo Clientのインスタンスを生成する際に、InMemoryCacheオブジェクトをコンストラクタに渡すことで、キャッシュ機構を利用することができます。

import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http';
import { ApolloClient } from 'apollo-client';

const client = new ApolloClient({
  link: new HttpLink(),
  cache: new InMemoryCache()
});

こうすることで、リクエストしたクエリを保存する際に、__typenameid(_id) を元にデータを正規化して保管します。

// このようなクエリを投げると
query user {
  user {
    id
    age
    name
  }
}

// このようなレスポンスが返ってくる
{
  "data": {
    "user": {
      "id": "1",
      "age": 69,
      "name": "Joe Perry",
    }
  }
}

// キャッシュに下記のようにデータが保存される
{
  'User:1': { id: '1', age: 69, name: 'Joe Perry', __typename: 'User' },
  // 省略
}

ちなみに、コンポーネントがマウントされる度にリクエストが走るのではなく、キャッシュが存在する場合、基本的にはキャッシュ優先でデータを読み込むため、無駄なリクエストを防止することができます。

キャッシュの更新

キャッシュの更新はいくつかの方法がありますが、主な方法は、Mutationのレスポンス内容でキャッシュを更新し、UIに反映する方法です。

// (1) 記事のいいね!数を取得するQueryを投げる
query post {
  post(id: '5') {
    id
    score
  }
}

// (2) キャッシュに下記のようにデータが保存される
{
  'Post:5': { id: '5', score: 3, __typename: 'Post' },
  // 省略
}

// (3) いいね!数をインクリメントするMutationを投げる
mutation {
  upvotePost(id: '5') {
    id
    score
  }
}

// (4) キャッシュが書き換わりUIが更新される
{
  'Post:5': { id: '5', score: 4, __typename: 'Post' },
  // 省略
}

Mutationのレスポンスでキャッシュを更新する場合、Query,Mutation両方に必ずidが存在し、フィールドが一致している必要があります。

これが結構な難点で、今回の開発ではキャッシュヒットさせるために、サーバー側のコードを書き換える場面が多々ありました。

Apollo Clientのキャッシュ機構をReduxの代わりに利用する

今回のプロジェクトでは、Redux/Mobxなどの状態管理ライブラリを併用せず、Apollo Clientのキャッシュ機構 + Reactのlocal stateで状態管理を行いました。
Apolloのキャッシュ機構では、@client ディレクティブを指定することで、ローカルのデータに対してもクエリを発行することができます。

Local state management

ただし、今回のプロジェクトでは、ほとんどの状態が通信にまつわるものだったため、ローカルキャッシュにアクセスする場面はそこまで多くありませんでした。

そのため、冗長なローカルリゾルバの書き味にも耐え切れましたが、もう少し楽にキャッシュのread/writeが行いたいので、今後はContext APIやAsyncStorageなど他の管理方法も検討したいと思います。

スキーマ駆動開発の導入

GraphQL は、SDL(Schema Definition Language)を使用して、クライアントがどのようなデータにアクセスできるかを定義します。

あらかじめチーム内で、どのようなスキーマが想定されるか定義し、フロントエンドチームは定義されたスキーマをモックすることで、バックエンドの実装を待たずに作業をすすめることができました。

Apollo Server Mocking

スキーマ駆動開発を取り入れることで、並行して開発が進められたため、スムーズな開発ができたと思います。

おわりに

今回のプロジェクトでは、通信と状態管理を、従来のREST API + Reduxの組み合わせではなく、GraphQL + Apollo Clientで行いました。

Redux/Mobxなど状態管理ライブラリを使用せず、Apollo Clientのキャッシュ機構で状態管理行い、TypeScript + GraphQLで型安全にアプリ開発を行う手法は、最近ちらほら見かけるようになってきた気がします。

実際に、開発体験として非常に良いものだったと感じており、引き続きスタジオ・アルカナでは、GraphQL + Apollo Client + TypeScriptでの開発を続けていきたいと思います。

React(Native)+GraphQL+Apollo Client+TypeScriptで自社サービス開発する仲間を募集しています!