🌖

Next.js:Apollo Client の構築手順

2021/03/02に公開

Next.js での Apollo Client の構築手順を整理します。

前提

こちらの手順に従い、開発環境を構築していることを前提とします。

https://zenn.dev/kei178/articles/43172ba33eece4

GraphQL API はこちらの手順で構築したものを前提とします。

https://zenn.dev/kei178/articles/2f4ffc6b89618c

0. TypeScript のセットアップ

Next.js に TypeScript を導入します。

touch project-front/tsconfig.json
docker-compose run frontend yarn add --dev typescript @types/react @types/node
docker-compose run frontend yarn dev

既存の js ファイルを ts もしくは tsx に置き換えます。

types.ts を作成し、API から取得するリソースの型を定義します。

project-frontend/types.ts
export interface Post {
  id: string;
  title: string;
  body: string;
}

1. Apollo Client のセットアップ

https://www.apollographql.com/docs/react/get-started/

インストール

docker-compose run frontend yarn add @apollo/client graphql

_app.tsx

_app.tsx で ApolloClient を初期化し、コンポーネントを <ApolloProvider> でラップします。

project-frontend/pages/_app.tsx
import '../styles/globals.css';

import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
import { AppProps } from 'next/dist/next-server/lib/router/router';

const cache = new InMemoryCache();
const client = new ApolloClient({
  uri: `${process.env.NEXT_PUBLIC_BACKEND_URL}/graphql`,
  cache,
});

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

export default MyApp;

2. GraphQL クエリ

GraphQL API からデータを取得する処理を書いていきます。

POSTS_QUERY

Post の一覧を取得するクエリです。

posts.query.ts

クエリと取得できるデータの型を定義します。

project-frontend/graphql/queries/posts.query.ts
import { gql } from '@apollo/client';
import { Post } from '../../types';

export const POSTS_QUERY = gql`
  query {
    posts {
      id
      title
    }
  }
`;

export interface PostsData {
  posts: Post[];
}

PostsList.tsx

Post 一覧を取得・描画するコンポーネントです。

project-frontend/components/PostsList.tsx
import Link from 'next/link';
import { useQuery } from '@apollo/client';
import { POSTS_QUERY, PostsData } from '../graphql/queries/posts.query';
import { NextPage } from 'next';

interface PostsListProps {}

const PostsList: NextPage<PostsListProps> = () => {
  const { loading, error, data } = useQuery<PostsData>(POSTS_QUERY);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {JSON.stringify(error)}</p>;

  const { posts } = data;
  if (!posts) return null;

  return (
    <ul>
      {posts.map((post, index) => {
        return (
          <li key={index}>
            {post.title}{' '}
            <Link href={`/posts/${post.id}`}>
              <a>[Detail]</a>
            </Link>
          </li>
        );
      })}
    </ul>
  );
};

export default PostsList;
  • コンテナ(HOC)でも実装できるが、ここでは useQuery フックを利用

index.tsx

ページレベルのコンポーネントです。

project-frontend/pages/index.tsx
import { NextPage } from 'next';
import PostsList from '../components/PostsList';

interface HomeProps {}

const Home: NextPage<HomeProps> = () => {
  return (
    <div>
      <h1>POSTS</h1>
      <PostsList />
    </div>
  );
};

export default Home;

POST_QUERY

Post 1件を取得するクエリです。

post.query.ts

クエリと取得データの型を定義します。

project-frontend/graphql/queries/post.query.ts
import { gql } from '@apollo/client';
import { Post } from '../../types';

export const POST_QUERY = gql`
  query($id: ID!) {
    post(id: $id) {
      id
      title
      body
    }
  }
`;

export interface PostData {
  post: Post;
}

PostItem.tsx

Post 1件を取得・描画するコンポーネントです。

project-frontend/components/PostItem.tsx
import { useQuery } from '@apollo/client';
import { NextPage } from 'next';
import { POST_QUERY, PostData } from '../graphql/queries/post.query';

interface PostItemProps {
  id: string;
}

const PostItem: NextPage<PostItemProps> = ({ id }) => {
  const { loading, error, data } = useQuery<PostData>(POST_QUERY, {
    variables: { id: parseInt(id) },
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {JSON.stringify(error)}</p>;

  const { post } = data;
  if (!post) return null;

  return (
    <div>
      <h2>{post.title}</h2>
      <p>{post.body}</p>
    </div>
  );
};

export default PostItem;

[id].tsx

ページレベルのコンポーネントです。

project-frontend/pages/posts/[id].tsx
import { NextPage, NextPageContext } from 'next';
import Link from 'next/link';
import PostItem from '../../components/PostItem';

interface PostPageProps {
  id: string;
}

const PostPage: NextPage<PostPageProps> = ({ id }) => (
  <div>
    <h1>POST</h1>
    <PostItem id={id} />
    <hr />
    <Link href="/">
      <a>Go back</a>
    </Link>
  </div>
);

PostPage.getInitialProps = (ctx: NextPageContext) => {
  const { id } = ctx.query;
  return { id: typeof id === 'string' ? id : id[0] };
};

export default PostPage;

動作確認

POSTS_QUERY

POSTS_QUERY 動作確認

POST_QUERY

POST_QUERY 動作確認

問題なく表示されます。

3. GraphQL ミューテーション

GraphQL API でデータを更新する処理を書いていきます。

UPDATE_POST

Post 1件を更新するミューテーションです。

update-post.mutation.ts

クエリと取得データの型、インプットデータの型を定義します。

project-frontend/graphql/mutations/update-post.mutation.ts
import { gql } from '@apollo/client';
import { Post } from '../../types';

export const UPDATE_POST = gql`
  mutation($params: PostInputType!) {
    updatePost(input: { params: $params }) {
      id
      title
      body
    }
  }
`;

export interface UpdatePostData {
  post: Post;
}

export interface PostInputType {
  params: {
    id: number;
    title?: string;
    body?: string;
  };
}

EditPostItem.tsx

Post 1件を取得・描画し、編集・更新するコンポーネントです。

project-frontend/components/EditPostItem.tsx
import { useQuery, useMutation } from '@apollo/client';
import { NextPage } from 'next';
import { useState } from 'react';
import {
  UPDATE_POST,
  UpdatePostData,
  PostInputType,
} from '../graphql/mutations/update-post.mutation';
import { PostData, POST_QUERY } from '../graphql/queries/post.query';
import { Post } from '../types';

interface EditPostItemProps {
  id: string;
}

const EditPostItem: NextPage<EditPostItemProps> = ({ id }) => {
  const [post, setPost] = useState<Post>(null);
  const [message, setMessage] = useState<string>('');

  const [updatePost, mutationResult] = useMutation<
    UpdatePostData,
    PostInputType
  >(UPDATE_POST);
  const mutationError = mutationResult.error;
  const { loading, error, data } = useQuery<PostData>(POST_QUERY, {
    variables: { id: parseInt(id) },
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {JSON.stringify(error)}</p>;
  if (!post) setPost(data.post);
  if (!post) return null;

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    setMessage('');
    const { name, value } = e.target;
    setPost({
      ...post,
      [name]: value,
    });
  };

  const handleSave = async () => {
    await updatePost({
      variables: {
        params: {
          id: parseInt(post.id),
          title: post.title,
          body: post.body,
        },
      },
    });

    mutationError
      ? alert(`Error: ${JSON.stringify(mutationError)}`)
      : setMessage('Successfully saved');
  };

  return (
    <div>
      <div>
        <button onClick={handleSave}>Save</button>
        {message ? <span className="message">{message}</span> : null}
      </div>
      <span className="form-field">
        <input name="title" value={post.title} onChange={handleChange} />
      </span>
      <span className="form-field">
        <textarea
          name="body"
          value={post.body}
          rows={10}
          onChange={handleChange}
        />
      </span>
      <style jsx>
        {`
          input,
          textarea {
            width: 100%;
          }
          span.form-field {
            display: block;
            overflow: hidden;
            padding: 0 5px 0 0;
            margin: 10px auto;
          }
          button {
            margin-right: 5px;
          }
          span.message {
            font-size: 0.8rem;
            color: #0018f9;
          }
        `}
      </style>
    </div>
  );
};

export default EditPostItem;
  • ミューテーションもコンテナ(HOC)で実装できるが、ここでは useMutation フックを利用

edit.tsx

ページレベルのコンポーネントです。

project-frontend/pages/posts/[id]/edit.tsx
import { NextPage, NextPageContext } from 'next';
import Link from 'next/link';
import EditPostItem from '../../../components/EditPostItem';

interface EditPostPageProps {
  id: string;
}

const EditPostPage: NextPage<EditPostPageProps> = ({ id }) => (
  <div>
    <h1>POST</h1>
    <EditPostItem id={id} />
    <hr />
    <Link href={`/posts/${id}`}>
      <a>Go back</a>
    </Link>
  </div>
);

EditPostPage.getInitialProps = (ctx: NextPageContext) => {
  const { id } = ctx.query;
  return { id: typeof id === 'string' ? id : id[0] };
};

export default EditPostPage;

[id].tsx

Post 詳細から編集ページに遷移できるようにします。

project-frontend/pages/posts/[id].tsx
import { NextPage, NextPageContext } from 'next';
import Link from 'next/link';
import PostItem from '../../components/PostItem';

interface PostPageProps {
  id: string;
}

const PostPage: NextPage<PostPageProps> = ({ id }) => (
  <div>
    <h1>POST</h1>
+     <Link href={`/posts/${id}/edit`}>
+       <button>Edit</button>
+     </Link>
    <PostItem id={id} />
    <hr />
    <Link href="/">
      <a>Go back</a>
    </Link>
  </div>
);

PostPage.getInitialProps = (ctx: NextPageContext) => {
  const { id } = ctx.query;
  return { id: typeof id === 'string' ? id : id[0] };
};

export default PostPage;
  • Apollo Client のキャッシュが効くので、詳細ページ → 編集ページに遷移する場合、POST_QUERY の再取得は行われません✨

動作確認

詳細ページ

詳細ページ動作確認

編集ページ

編集ページ動作確認

更新処理

更新動作確認

問題なく「Title updated from GraphQL」から「Title updated from Apollo Client」に更新できました。

4. Apollo Client キャッシュの利用

グローバルステート(ローカルデータ)を Apollo Client のキャッシュに保存して更新できるようにします。

https://www.apollographql.com/docs/tutorial/local-state/

resolvers, typeDefs のセットアップ

Post 一覧の「表示/非表示」を listHidden: boolean としてキャッシュに持たせます。(性質的にはローカルステートですが、グローバルステートとみなして扱います。)

list-hidden.query.ts

クエリと取得データの型を定義します。

project-frontend/queries/list-hidden.query.ts
import { gql } from '@apollo/client';

const LIST_HIDDEN_QUERY = gql`
  {
    listHidden @client
  }
`;

export interface ListHiddenData {
  listHidden: boolean;
}

export default LIST_HIDDEN_QUERY;
  • @client ディレクティブでクライアントサイドのフィールドにアクセス

toggle-list-hidden.mutation.ts

クエリと取得データの型を定義します。

project-frontend/mutations/toggle-list-hidden.mutation.ts
import { gql } from '@apollo/client';

export const TOGGLE_LIST_HIDDEN = gql`
  mutation {
    toggleListHidden @client
  }
`;

export interface ToggleListHiddenData {
  listHidden: boolean;
}

resolvers.ts

typeDefs でサーバーサイドのスキーマをベースにクライアントサイドのスキーマを定義します。また、resolvers でクライアントサイドの更新処理を定義します。

project-frontend/graphql/resolvers.ts
import { gql, InMemoryCache, Resolvers } from '@apollo/client';
import { LIST_HIDDEN_QUERY, ListHiddenData } from './queries/list-hedden.query';

export const typeDefs = gql`
  extend type Mutation {
    ToggleListHidden: Boolean!
  }
`;

export const resolvers: Resolvers = {
  Mutation: {
    toggleListHidden: (_root, _args, ctx, _info) => {
      const cache: InMemoryCache = ctx.cache;
      const { listHidden } = cache.readQuery<ListHiddenData, null>({
        query: LIST_HIDDEN_QUERY,
      });

      cache.writeQuery<ListHiddenData, null>({
        query: LIST_HIDDEN_QUERY,
        data: { listHidden: !listHidden },
      });

      return !listHidden;
    },
  },
};

_app.tsx

ApolloClienttypeDefsresolvers を渡します。また、キャッシュのデフォルト値を cache.writeQuery でセットしてます。

project-frontend/pages/_app.tsx
import '../styles/globals.css';

import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
+ import { resolvers, typeDefs } from '../graphql/resolvers';
+ import { LIST_HIDDEN_QUERY } from '../graphql/queries/list-hedden.query';
import { AppProps } from 'next/dist/next-server/lib/router/router';

const cache = new InMemoryCache();
+ cache.writeQuery<ListHiddenData, null>({
+   query: LIST_HIDDEN_QUERY,
+   data: {
+     listHidden: true,
+   },
+ });
const client = new ApolloClient({
  uri: `${process.env.NEXT_PUBLIC_BACKEND_URL}/graphql`,
  cache,
+   typeDefs,
+   resolvers,
});

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

export default MyApp;

cache の取得・更新

ToggleButton.tsx

listHiddentrue/false を切り替えるボタンです。

project-frontend/components/ToggleButton.tsx
import { useMutation, useQuery } from '@apollo/client';
import {
  LIST_HIDDEN_QUERY,
  ListHiddenData,
} from '../graphql/queries/list-hedden.query';
import {
  TOGGLE_LIST_HIDDEN,
  ToggleListHiddenData,
} from '../graphql/mutations/toggle-list-hidden.mutation';
import { NextPage } from 'next';

interface ToggleButtonProps {}

const ToggleButton: NextPage<ToggleButtonProps> = () => {
  const {
    data: { listHidden },
  } = useQuery<ListHiddenData>(LIST_HIDDEN_QUERY);
  const [toggleListHidden] = useMutation<ToggleListHiddenData>(
    TOGGLE_LIST_HIDDEN
  );

  return (
    <button
      onClick={() => {
        toggleListHidden();
      }}
    >
      {listHidden ? 'Show' : 'Hide'}
    </button>
  );
};

export default ToggleButton;

index.tsx

Post 一覧に「表示/非表示」ボタンを導入します。

project-frontend/pages/index.tsx
import { useQuery } from '@apollo/client';
import { NextPage } from 'next';
import PostsList from '../components/PostsList';
+ import ToggleButton from '../components/ToggleButton';
+ import { LIST_HIDDEN_QUERY, ListHiddenData } from '../graphql/queries/list-hedden.query';

interface HomeProps {}

const Home: NextPage<HomeProps> = () => {
+   const {
+     data: { listHidden },
+   } = useQuery<ListHiddenData>(LIST_HIDDEN_QUERY);
+ 
  return (
    <div>
+       <ToggleButton />
      <h1>POSTS</h1>
      {listHidden ? null : <PostsList />}
    </div>
  );
};

export default Home;

動作確認

非表示

Home Hide 動作確認

表示

Home Show 動作確認

ボタンクリックでグローバルステートを切り替えれるようになりました。

Discussion