古生代紀のブログ

主にアプリ開発/その他ナレッジ

Nextjs Hasura GraphQL 勉強

はじめに

例のごとく勉強のメモ
GitHub - GomaGoma676/nextjs-hasura-basic-lesson: [Hasura基礎編] Nextjs+Hasura+Apollo Clientで学ぶモダンGraphQL Web開発 🚀

Next.js

フロントエンドのアプリが作れます

Hasura

GrapghQLを使用するためのもの
GrapghQLとセットの代物と思えばいいっぽい

GrapghQL

テーブルの中の特定のフィールドだけ取得することができたりする
効率的なデータ取得ができる模様

APOLLO

GraphQLのクライアントというものらしい
ふむ...


Hasura Cloud

https://hasura.io/cloud/
Githubのアカウントで始められる

1. プロジェクト作る

new projectでFree
FreeではUSのみ

2. お掃除

Ev varsのADMIN_SECRETが作成されてしまった場合は削除

3. コンソール起動

Dataタブクリック
Heroku使う

4. テーブル作り

public > create table
こんな感じで作れる
f:id:shinseidaiki:20211117224552p:plain

Inset rowでデータ追加できる
browse rowsで確認できる

5. APIタブ データのCRUD

チェックつけるだけでこんな感じで使える楽
f:id:shinseidaiki:20211117224945p:plain

下のほうmutationを選択するとAddとかも使えるようになる +のボタンで追加できる
f:id:shinseidaiki:20211117225039p:plain
f:id:shinseidaiki:20211117225244p:plain

limitとか使って任意の件数取得などのオプションを付けられる
aggregateで集計できたりする

特定レコード取りたい場合はコレ
f:id:shinseidaiki:20211117225716p:plain
f:id:shinseidaiki:20211117225823p:plain


データ作成とかはこっち mutationで再生
f:id:shinseidaiki:20211117230011p:plain


データ更新はこう mutationで実行
f:id:shinseidaiki:20211117230429p:plain

削除はこうだよ
f:id:shinseidaiki:20211117230509p:plain

6 グループとリレーション

以下のリレーション関係あり
1to1
1toM
→の方向: Object relationshipと呼ぶ
←の方向: Array relationshipと呼ぶ
MtoM
互いにObject relationship

1toM

別テーブル作ってModifyのAdd a new Columnからこんな感じで作る
userテーブルにgroup_idつけているこの例は
f:id:shinseidaiki:20211117231331p:plain

そんでForeign Keyをこんな感じで設定
f:id:shinseidaiki:20211117231821p:plain

そいでハスラが提案するリレーションをRelationshipタブで選択する
f:id:shinseidaiki:20211117231955p:plain

リレーションの逆側に関しても明示的に設定をする
f:id:shinseidaiki:20211117232207p:plain

API クエリで確認

userにgroupが新しくできる
f:id:shinseidaiki:20211117232500p:plain

groupにもuserが新しくできる
f:id:shinseidaiki:20211117232641p:plain
f:id:shinseidaiki:20211117232715p:plain

1to1

1to1の作り方は、1toMを作ってrelationship属性にunique属性をつけることで実現させる

プロフィールテーブル作った後userテーブルでフィールド追加してuniqueとする
f:id:shinseidaiki:20211117233003p:plain
そいでForeignKeyは一緒
f:id:shinseidaiki:20211117233148p:plain

そのあとはrelationshipでaddする
profileでもaddする

MtoM

MtoMは中間テーブルを使って実現する
友達のリレーションを作りたいときに、profileとuserをMtoMにするケースなどが代表例

中間テーブルを作る
f:id:shinseidaiki:20211117234005p:plain

profileへのリレーション
f:id:shinseidaiki:20211117234049p:plain

userへのリレーション
f:id:shinseidaiki:20211117234145p:plain


中間テーブルからのrelationshipのaddをsaveで決定する(Objectrelationship)
その後、MtoMしたいテーブルのrelationshipタブからAddをsaveで決定する(Arrray relationship)


Apollo クライアント + Next.js

事前準備

VSコードの拡張機能
ES7
Jest
Prettier

プロジェクトの作成

今回はyarnで作成するのでこちらの手順に従う
GitHub - GomaGoma676/nextjs-hasura-basic-lesson: [Hasura基礎編] Nextjs+Hasura+Apollo Clientで学ぶモダンGraphQL Web開発 🚀

yarnの実行ができない場合の対処法

https://qiita.com/ponsuke0531/items/4629626a3e84bcd9398f
hoge.psはyarn.psに読み替える
結論としてはこのコマンドを実行する
powershellでyarnを実行させたいときは毎回このコマンドをたたくこと

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
起動確認
yarn dev
1. Nextjs Project 新規作成
1-1. yarn install *インストールしていない場合
npm install --global yarn
yarn --version
1-2. create-next-app
npx create-next-app .

Node.js version 10.13以降が必要

1-3. Apollo Client + heroicons + cross-fetch のインストール
yarn add @apollo/client graphql @apollo/react-hooks cross-fetch @heroicons/react
1-4. React-Testing-Library + MSW + next-page-tester のインストール
yarn add -D msw next-page-tester jest @testing-library/react @types/jest @testing-library/jest-dom @testing-library/dom babel-jest @babel/core @testing-library/user-event jest-css-modules
1-5. Project folder 直下に".babelrc"ファイルを作成して下記設定を追加

touch .babelrc

    {
        "presets": ["next/babel"]
    }
1-6. package.json に jest の設定を追記
    "jest": {
        "testPathIgnorePatterns": [
            "<rootDir>/.next/",
            "<rootDir>/node_modules/"
        ],
        "moduleNameMapper": {
            "\\.(css)$": "<rootDir>/node_modules/jest-css-modules"
        }
    }
1-7. package.jsonに test scriptを追記
    "scripts": {
        ...
        "test": "jest --env=jsdom --verbose"
    },
1-8. prettierの設定 : settingsでRequire Config + Format On Saveにチェック

touch .prettierrc

    {
        "singleQuote": true,
        "semi": false
    }

f:id:shinseidaiki:20211119131531p:plain
f:id:shinseidaiki:20211119131613p:plain

2-1. 空のtsconfig.json作成
2-2. 必要moduleのインストール
yarn add -D typescript @types/react @types/node
2-3. 開発server起動
yarn dev
2-4. _app.js, index.js -> tsx へ拡張子変更

f:id:shinseidaiki:20211119131841p:plain

2-5. AppProps型追記
    import { AppProps } from 'next/app'

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

    export default MyApp
3-1. 必要moduleのインストール
yarn add tailwindcss@latest postcss@latest autoprefixer@latest
3-2. tailwind.config.js, postcss.config.jsの生成
npx tailwindcss init -p
3-3. tailwind.config.jsのpurge設定追加
module.exports = {
    purge: ['./pages/**/*.tsx', './components/**/*.tsx'],
    darkMode: false,
    theme: {
        extend: {},
    },
    variants: {
        extend: {},
    },
    plugins: [],
}
3-3-2. componentsフォルダの作成
3-4. globals.cssの編集

中身を以下の3行に完全に置き換える

@tailwind base;
@tailwind components;
@tailwind utilities;
4. Test動作確認
4-1. __tests__フォルダとHome.test.tsxファイルの作成
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import Home from '../pages/index'

it('Should render title text', () => {
  render(<Home />)
  expect(screen.getByText('Next.js!')).toBeInTheDocument()
})
4-2. yarn test -> テストがPASSするか確認
 PASS  __tests__/Home.test.tsx
  ✓ Should render hello text (20 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.728 s, estimated 2 s
5. GraphQL codegen
5-1. install modules + init
yarn add -D @graphql-codegen/cli
yarn graphql-codegen init

yarn graphql-codegen init実行時に選択肢が出てくるので以下のように設定

1 What type of application are you building?
React

2. Where is your schema?: (path or url)
GraphQL server URL: Hasura(Hasura Cloud)で作成したDBのGraphQL Endpointを指定する
このエンドポイントは作成時にHerokuの本番環境にdeployされている
f:id:shinseidaiki:20211119133027p:plain

3. Where are your operations and fragments?: (src/**/*.graphql)
プロジェクトフォルダ内で作成したカスタムクエリを使用することができる
定義場所を指定することでCodegenが自動的に解析してくれる
queriesフォルダをプロジェクト直下に作成して、サブフォルダ以下すべてのクエリを解析してくれるように以下のパスを設定
queries/**/*.ts

4. Pick plugins: (Press to select, to toggle all, to invert selection)
デフォルトのまま選択

5. Where to write the output: (src/generated/graphql.tsx)
自動生成させた型定義ファイルをプロジェクトのどのフォルダに出力させるかを設定
types/generated/graphql.tsx

6. Do you want to generate an introspection file? (Y/n)
作らない

7. How to name the config file? (codegen.yml)
提案されている名前を利用

8. What script in package.json should run the codegen?
スクリプト実行名を決めることができる
gen-types

最終設定

? What type of application are you building? Application built with React
? Where is your schema?: (path or url) https://[YOUR-DB-NAME].hasura.app/v1/graphql
? Where are your operations and fragments?: queries/**/*.ts
? Pick plugins: TypeScript (required by other typescript plugins), TypeScript Operations (operations and fragments), TypeScript React 
? Where to write the output: types/generated/graphql.tsx
? Do you want to generate an introspection file? No
? How to name the config file? codegen.yml
? What script in package.json should run the codegen? gen-types
5.1 続き

以下の指示に対してnpmではなく今回はyarnを実行する

Fetching latest versions of selected plugins...

Config file generated at codegen.yml

$ npm install

To install the plugins.

$ npm run gen-types

To run GraphQL Code Generator.

yarnを実行してプラグインをインストール

yarn

TypeScriptモジュールのインストール

yarn add -D @graphql-codegen/typescript
5-2. add queries in queries/queries.ts file
5-3. generate types automatically
yarn gen-types

END

トラブルシュート

next-page-testerがNextjs ver12に対応していないためversion11に変更

yarn add next@11.1.2
graphQLの実行確認

上記の5-2と5-3の手順
queries.ts 基本形

import { gql } from '@apollo/client';

export const GET_USERS = gql`
    query GetUsers {
       // クエリの中身
        users(order_by: {created_at: desc}) {
            id
            name
            created_at
        }
    }
`

クエリの中身にはHasuraで自動生成させたコードをコピペで貼り付ければよい

もう一つqueryを作成 @clientが追加されているとサーバーではなくクライアント側のキャッシュにクエリを送ることができる

export const GET_USERS_LOCAL = gql`
    query GetUsers {
        users(order_by: {created_at: desc}) @client {
            id
            name
            created_at
        }
    }
`

idだけを取得するquery

export const GET_USERIDS = gql`
    query GetUserIds {
        users(order_by: {created_at: desc}) {
            id
        }
    }
`

特定のidを引数にとって特定のユーザー情報を取得するquery

export const GET_USERBY_ID = gql`
    query GetUserById($id: uuid!) {
        users_by_pk(id: $id) {
            id
            name
            created_at
        }
    }
`

作成更新削除 query
HasuraではMutationを利用してqueryを作成させていくf:id:shinseidaiki:20211119141611p:plain

export const CREATE_USER = gql`
    mutation CreateUser($name: String!) {
        insert_users_one(object: { name: $name }) {
            id
            name
            created_at
        }
    }
`

export const DELETE_USER = gql`
    mutation DeleteUser($id: uuid!) {
        delete_users_by_pk(id: $id) {
            id
            name
            created_at
        }
    }
`

export const UPDATE_USER = gql`
    mutation UpdateUser($id: uuid = "", $name: String = "") {
        update_users_by_pk(pk_columns: {id: $id}, _set: {name: $name}) {
            id
            name
            created_at
        }
    }
`

ユーザー作成
コピーして利用していく
f:id:shinseidaiki:20211119141818p:plain
f:id:shinseidaiki:20211119141831p:plain

ユーザー削除
f:id:shinseidaiki:20211119143029p:plain
f:id:shinseidaiki:20211119143040p:plain

ユーザー更新
f:id:shinseidaiki:20211119143437p:plain
f:id:shinseidaiki:20211119143505p:plain

クエリを自動生成

※クエリ名がかぶっていると(今回ではquery GetUsers)エラーが発生するので、@client側のクエリはコメントアウトして、以下を実行

yarn gen-types

※実行後コメントアウトしていた部分のコメントアウトを外す

コンポーネントレイアウトの作成

TypeScriptを利用したNext.jsにおけるLayout.tsx基本形

import { ReactNode, VFC } from "react";
import Head from "next/head";
import Link from "next/link";

interface Props {
    children: ReactNode
    title: string
}

export const Layout: VFC<Props> = ({
    children,
    title = 'Welcome to Next.js',
}) => {
    return (
        <></>
    );
}

VFCはFunctionコンポーネントの型に関するモジュール

next.jsだと以下のように受け取るpropsのデータ型を定義しておくとよい模様

interface Props {
    children: ReactNode
    title: string
}

Layout.tsx return以下

return (
        <div className="flex flex-col justify-center min-h-screen text-gray-600 text-sm font-mono">
            <Head>
                <title>{title}</title>
            </Head>
            <header>
                <nav className="bg-gray-800 w-screen">
                    <div className="flex items-center pl-8 h-14">
                        <div className="flex space-x-4">
                            <Link href="/">
                                <a 
                                    data-testid="home-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    Home
                                </a>
                            </Link>
                            <Link href="/local-state-a">
                                <a 
                                    data-testid="makevar-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    makeVar
                                </a>
                            </Link>
                            <Link href="/hasura-main">
                                <a 
                                    data-testid="fetchpolicy-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    fetchPolicy(Hasura)
                                </a>
                            </Link>
                            <Link href="/hasura-crud">
                                <a 
                                    data-testid="crud-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    CRUD(Hasura)
                                </a>
                            </Link>
                            <Link href="/hasura-ssg">
                                <a 
                                    data-testid="ssg-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    SSG+ISR(Hasura)
                                </a>
                            </Link>
                            <Link href="/hooks-memo">
                                <a 
                                    data-testid="memo-nav"
                                    className="text-gray-300 hover:bg-gray-700 px-3 py-2 rounded"
                                >
                                    custom hook + memo
                                </a>
                            </Link>
                        </div>
                    </div>
                </nav>
            </header>
            <main className="flex flex-1 flex-col justify-center items-center w-screen">
                {children}
            </main>
            <footer className="w-full h-12 flex justify-center items-center border-t">
                <a
                    href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
                    target="_blank"
                    rel="noopener noreferrer"
                >
                    Powered by{' '}
                    <span className="h-4 ml-2">
                        <Image src="/vercel.svg" alt="Vercel Logo" width={72} height={16} />
                    </span>
                </a>
            </footer>
        </div>
    );

リンクにはdata-testidをつけておき、テスターが識別できるようにしておく



index.tsxの変更

import { VFC } from "react"
import { Layout } from "../components/Layout"

const Home: VFC = () => {
  return (
    <Layout title="Home">
      <p className="text-3xl font-bold">Next.js + GraphQL</p>
    </Layout>  
  )
}

export default Home

Home.module.cssの削除
pages/apiフォルダの削除

Apollo Client の利用

特徴1.

通常はReduxでuseState+useContextを使用してStoreでデータを一括管理できるようにStore(Provider)を自分で作成するが、Apollo Clientの場合は、cacheを自動的に作成してくれるためStore(Provider)をわざわざ作らなくてもよくなる
Apollo ClientではQuery @clientでcacheにアクセスできる

特徴2.

ローカルでStateを管理する方法についてのReduxとの違い
Redux(userContext)を利用する場合は商品が増えた場合などの変更時にdispatchを呼び出し、useSelectorで他のコンポーネントからデータにアクセスできるようにしている
Apollo Clientを利用する場合は、商品が増えた場合などの変更時はmakeVarを呼び出し、useReactiveVarで他のコンポーネントからデータにアクセスできるようにしている
makeVarで保存される領域はcacheとは独立したクライアントのローカル領域に保存される

特徴3

Apollo Clientは自動的にcache領域にデータを保存するが、それに加えてmakeVarでデータをローカルに保存して管理することもできる
cacheへのアクセスのためにはquery @clientを書く必要があるため、細かいクエリを書く手間を省きたい場合にmakeVarを使用することができる
また、cacheの更新の挙動などはマシン側にイニシアチブがあるため、自身の管理下でステートを制御したい場合にmakeVarを使用できる

makeVarを使用する

プロジェクト直下にcache.tsを作成する
基本形(cache.ts)

import { makeVar } from "@apollo/client";

interface Task {
    title: String
}

export const todoVar = makeVar<Task[]> ([])

Taskを管理するステートを作成


TaskをcacheとmakeVarで管理する際の両方の挙動を確認していく
タスクを作成していくコンポーネントとしてcomponents/LocalStateA.tsxを作成する
LocalStateA.tsx

import { ChangeEvent, FormEvent, useState, VFC } from "react";
import { todoVar } from "../cache";
import { useReactiveVar } from "@apollo/client";
import Link from "next/link";

export const LocalStateA: VFC = () => {
    const [input, setInput] = useState('')
    const todos = useReactiveVar(todoVar)

    const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        todoVar([...todoVar(), { title: input }])
        setInput('')
    }

    return (
        <>
            <p className="mb-3 font-bold">makeVar</p>
            {todos?.map((task, index) => {
                return (
                    <p className="mb-3 y-1" key={index}>
                        {task.title}
                    </p>
                )
            })}
            <form 
                className="flex flex-col justify-center items-center"
                onSubmit={handleSubmit}
            >
                <input 
                    className="mb-3 px-3 py-2 border border-gray-300"
                    placeholder="New task ?"
                    value={input}
                    onChange={(e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value)}
                />
                <button
                    disabled={!input}
                    className="disabled:opacity-40 mb-3 py-1 px-3 text-white bg-indigo-600 hover:bg-indigo-700 rounded-2xl focus:outline-none"
                    type="submit"
                >
                    Add new state
                </button>
            </form>
            <Link href="/local-state-b">
                <a>Next</a>
            </Link>
        </>
    )
}


※tailwindでopacityを使用する場合はtailwind.config.jsのextendにopacityを追記する必要がある

extend: { opacity: ['disabled'] },


次にStateの状態をほかのコンポーネントからも利用できるか確認するためのcomponents/LocalStateB.tsxを作成する
LocalStateB.tsxでは更新されたステートを読みこむコンポーネントとする

import { ChangeEvent, FormEvent, useState, VFC } from "react";
import { todoVar } from "../cache";
import { useReactiveVar } from "@apollo/client";
import Link from "next/link";

export const LocalStateB: VFC = () => {
    const todos = useReactiveVar(todoVar)
    return (
        <>
            {todos?.map((task, index) => {
                return (
                    <p className="mb-3 y-1" key={index}>
                        {task.title}
                    </p>
                )
            })}
            <Link href="/local-state-a">
                <a>Back</a>
            </Link>
        </>
    );
}


StateA, StateBのpageを作成する
local-state-a.tsx

import { VFC } from "react"
import { LocalStateA } from "../components/LocalStateA"
import { Layout } from "../components/Layout"

const LocalStatePageA: VFC = () => {
  return (
    <Layout title="Local State A">
        <LocalStateA />
    </Layout>  
  )
}

export default LocalStatePageA

local-state-b.tsx

import { VFC } from "react"
import { LocalStateB } from "../components/LocalStateB"
import { Layout } from "../components/Layout"

const LocalStatePageB: VFC = () => {
  return (
    <Layout title="Local State B">
        <LocalStateB />
    </Layout>  
  )
}

export default LocalStatePageB

TastAで追記した値がTaskBからも取得できていることがわかる
f:id:shinseidaiki:20211119162825p:plain
f:id:shinseidaiki:20211119162901p:plain

Hasura next.js連携

libフォルダ作成
その直下にapolloClient.tsを作成
apolloClient.tsxは公式による作成例がGitHubで公開されている
https://github.com/vercel/next.js/blob/canary/examples/with-apollo/lib/apolloClient.js

使用法上の注意

next.jsはSSG + サーバーサイドレンダリング + IncrementalStaticGenerationがあって、サーバーサイドで実行される処理とクライアント側で実行されるJSの処理が混在している
そのため、作成するapolloClientはサーバー側とクライアント側とで切り分ける必要がある
特に、SSGやSSRのようなサーバーサイド側で処理されるもの(getStaticProps, getStaticPaths)でアポロクライアントを使う場合は毎回アポロクライアントのインスタンスを作成する必要がある
クライアント側で処理されるもの(jsで実行されるqueryの発行)に関しては一度アポロクライアントを作成しておけばよいという実装になっている
ここの処理が適切でない場合はruntimeエラーが発生する

基本的には公式サンプルのコピペで実装を行う

基本形(apolloClient.ts)

import { ApolloClient, HttpLink, InMemoryCache, NormalizedCacheObject } from "@apollo/client";
import 'cross-fetch/polyfill'

export const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__'

let apolloClient: ApolloClient<NormalizedCacheObject> | undefined

const createApolloClient= () => {
    return new ApolloClient({
      ssrMode: typeof window === 'undefined',
      link: new HttpLink({
        uri: 'https://[YOUR-HASURA-DB-NAME].hasura.app/v1/graphql',
      }),
      cache: new InMemoryCache(),
    })
}

let apolloClient: ApolloClient | undefinedはアポロクライアントがない場合はundefinedになる
ssrMode: typeof window === 'undefined', のwindowsがundefinedでない場合はブラウザで実行しているという意味すなわちクライアントの処理であり、undefinedである場合はサーバーサイドの処理であることを差している
linkのuriにはHasuraHasura CloudのエンドポイントのURLを入れる


基本形2(apolloClient.ts)- initializeApolloの追加

export const initializeApollo = (initialState = null) => {
    const _apolloClient = apolloClient ?? createApolloClient()  
    // For SSG and SSR always create a new Apollo Client
    if (typeof window === 'undefined') return _apolloClient
    // Create the Apollo Client once in the client
    if (!apolloClient) apolloClient = _apolloClient
  
    return _apolloClient
}

この処理を入れることでサーバーサイドの場合は毎回アポロクライアントが生成され、クライアントの場合は一度生成したものを使いまわす処理ができる
サーバーサイドの処理の場合はlet apolloClient: ApolloClient | undefinedのapolloClientが毎回undefinedとなり、 const _apolloClient = apolloClient ?? createApolloClient() の処理で毎回生成される形となる
クライアントの処理の場合は const _apolloClient = apolloClient ?? createApolloClient() で一度作成されたapolloClient を使いまわす処理となるためにこの実装で、切り分ける処理が実装できた

apolloClientをプロジェクトの中で作成する

_app.tsxを編集してアポロクライアントを作成する
追記する部分(_app.tsx)

import { ApolloProvider } from '@apollo/client'
import { initializeApollo } from '../lib/apolloClient'

function MyApp({ ................................) {
  const client = initializeApollo()
  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  )

_app.tsxのComponentをApolloProviderで囲うことによってプロジェクトのあらゆる場所でアポロプロバイダーを利用することができるようになる

Hasuraからデータを取得してアポロクライアントを介してデータを処理する

hasura-main.tsxをpagesフォルダ下に作成

import { VFC } from "react";
import Link from "next/link";
import { useQuery } from "@apollo/client";
import { GET_USERS } from "../queries/queries";
import { GetUsersQuery } from "../types/generated/graphql";
import { Layout } from "../components/Layout";

const FetchMain: VFC = () => {
    const { data, error } = useQuery<GetUsersQuery>(GET_USERS)
    if (error) {
        return (
            <Layout title="Hasura fetchPolicy">
                <p>Error: {error.message}</p>
            </Layout>
        )
    }
    return (
        <Layout title="Hasura fetchPolicy">
            <p className="mb-6 font-bold">Hasura main page</p>
            {data?.users.map((user) => {
                return (
                    <p className="my-1" key={user.id}>
                        {user.name}
                    </p>
                )
            })}
            <Link href="/hasura-sub">
                <a className="mt-6">Next</a>
            </Link>
        </Layout>
    )
}

export default FetchMain

import { useQuery } from "@apollo/client";はアポロクライアントがクエリを生成するために使用
import { GET_USERS } from "../queries/queries";をインポートすることでユーザー情報を取得するクエリを使用できる
import { GetUsersQuery } from "../types/generated/graphql"; はユーザークエリの型である
実際の型名はgraphql.tsxに記述されているが、queries.tsファイルのquery xxxxのxxxxの末尾にQueryをつけると型名になる
f:id:shinseidaiki:20211119173638p:plain

GetUsersQueryの構造はgraphql.tsxで確認できる

export type GetUsersQuery = { __typename?: 'query_root', users: Array<{ __typename?: 'users', id: any, name: string, created_at: any }> };

useQuery(GET_USERS)は引数に作成したクエリを選択する

動作確認

fecthPolicyのメニューを選択してユーザーが取得できていればOK
データは以下のような形にデフォルトでフォーマットされる
__typenameが付与される

{users: Array(3)}
users: Array(3)
0: {__typename: 'users', id: '5d4df...................', name: 'user3', created_at: '2021-11-17T13:47:46.832885+00:00'}
1: {__typename: 'users', id: '13399...................', name: 'user2', created_at: '2021-11-17T13:47:36.44969+00:00'}
2: {__typename: 'users', id: 'bcsce.....................', name: 'user1', created_at: '2021-11-17T13:47:23.308833+00:00'}
length: 3

取得したデータのキャッシュにアクセスする際は__typenameとidをkeyにしてアクセスするメカニズムとなっている

ApolloClientでキャッシュ(Cache)のデータを参照する

hasura-sub.tsxを作成
@clientを使用しない実装と使用する(キャッシュを使用する)実装の動作の違いを確認する


その1
@clientを使用しない実装における動作
const { data } = useQuery(GET_USERS)をGET_USERとする
useQueryはfetchPolicyを設定することができ、デフォルトでcache firstの設定が有効になっており、キャッシュが存在する場合は常にキャッシュの値を参照しに行くという処理を行うようになっている
今回は挙動確認のためにnetwork-onlyにしておく(毎回データをサーバーに取得しにいく)

import { VFC } from "react";
import Link from "next/link";
import { useQuery } from "@apollo/client";
import { GET_USERS_LOCAL, GET_USERS } from "../queries/queries";
import { GetUsersQuery } from "../types/generated/graphql";
import { Layout } from "../components/Layout";

const FetchSub: VFC = () => {
    const { data } = useQuery<GetUsersQuery>(GET_USERS, {
        fetchPolicy: 'network-only'
    })
    return (
        <Layout title="hasura fetchPolicy read cache">
            <p className="mb-6 font-bold">Hasura main page</p>
            {data?.users.map((user) => {
                return (
                    <p className="my-1" key={user.id}>
                        {user.name}
                    </p>
                )
            })}
            <Link href="/hasura-main">
                <a className="mt-6">Back</a>
            </Link>
        </Layout>
    ) 
}

export default FetchSub
動作確認

自作したhasura main pageのページにアクセスしてF12のnetwork>XHRを選択して再読み込みを実施
graphqlが確認でき、これはクライアントからGraphQLサーバーにフェッチが行われている処理であり、想定通りGraphQLサーバーからデータを取得している
f:id:shinseidaiki:20211119181536p:plain
subページも想定通りの挙動、graphqlを確認することができる


その2
@clientを使用する実装における動作
const { data } = useQuery(GET_USERS)をGET_USERS_LOCALに変更してfetchPolicyはデフォルトに戻す

const { data } = useQuery<GetUsersQuery>(GET_USERS_LOCAL)
動作確認

graphQLサーバーにアクセスしていない状態でもデータが表示されていることが確認できる

4種類のFetch Policyについて

FetchPolicyには以下の4種類が存在

useQuery<GetUsersQuery>(QUERY_NAME, {
        fetchPolicy: 'network-only',
        fetchPolicy:'cache-and-network',
        fetchPolicy:'cache-first',
        fetchPolicy:'no-cache',
    })

デフォルトでは'cache-first',が選択される
'cache-first' 常に最初に取得したデータしか読み取らないため、サーバーサイドの更新データを反映することができず、頻繁にサーバー側のデータが更新されるようなサイトには不向き
'no-cache' キャッシュが作られない
動作確認としてsubの方は何も表示されないことが確認できる
mainは毎回サーバーにデータを取得しに行く
f:id:shinseidaiki:20211119194619p:plain
'network-only'は必ずサーバー側にフェッチしに行く
取得中はなにも表示しない
'cache-and-network'も必ずサーバー側にフェッチに行く
取得中はキャッシュの内容を表示しておく

基本的には'cache-and-network'を設定しておくとよいと考えられる


Hasura CRUDの実装

pages直下にhasura-crud.tsxを作成する
hasura-crud.tsx

import { VFC, useState, FormEvent } from "react"
import { useQuery, useMutation } from "@apollo/client"
import { GET_USERS, CREATE_USER, UPDATE_USER, DELETE_USER } from "../queries/queries"
import { GetUsersQuery, CreateUserMutation, UpdateUserMutation, DeleteUserMutation } from "../types/generated/graphql"
import { Layout } from "../components/Layout"
import { UserItem } from "../components/UserItem"

const HasuraCRUD: VFC = () => {
    const [editedUser, setEditedUser] = useState({id: '', name:''})
    const { data, error } = useQuery<GetUsersQuery>(GET_USERS, {
        fetchPolicy:'cache-and-network',
    })
    const [update_users_by_pk] = useMutation<UpdateUserMutation>(UPDATE_USER)
    const [insert_users_one] = useMutation<CreateUserMutation>(CREATE_USER, {
        update(cache, {data: {insert_users_one} }) {
            const cacheId = cache.identify(insert_users_one)
            cache.modify({
                fields: {
                    users(existingUsers, {toReference}) {
                        return [toReference(cacheId), ...existingUsers]
                    },
                },
            })
        },
    })
    const [delete_users_by_pk] = useMutation<DeleteUserMutation>(DELETE_USER, {
        update(cache, {data: {delete_users_by_pk} }) {
            cache.modify({
                fields: {
                    users(existingUsers, {readField}) {
                        return existingUsers.filter(
                            (user) => delete_users_by_pk.id !== readField('id', user)
                        )
                    },
                },
            })
        },
    })
    const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        if (editedUser.id) {
            try {
                await update_users_by_pk({
                    variables: {
                        id: editedUser.id,
                        name: editedUser.name,
                    },
                })
            } catch (err) {
                alert(err.message)
            }
            setEditedUser({ id: '', name: ''})
        } else {
            try {
                await insert_users_one({
                    variables: {
                        name: editedUser.name,
                    },
                })
            } catch (err) {
                alert(err.message)
            }
            setEditedUser({ id: '', name: ''})
        }
    }
    if (error) return <Layout title="HasuraCRUD">Error: {error.message}</Layout>

    return (
        <Layout title="Hasura CRUD">
            <p className="mb-6 font-bold">Hasura CRUD</p>
            <form 
                className="flex flex-col justify-center items-center"
                onSubmit={handleSubmit}
            >
                <input
                    className="px-3 py-2 border border-gray-300" 
                    type="text"
                    placeholder="New user"
                    value={editedUser.name}
                    onChange={(e) => setEditedUser({ ...editedUser, name: e.target.value})}
                />
                <button 
                    disabled={!editedUser.name}
                    className="disabled:opacity-40 mb-3 py-1 px-3 text-white bg-indigo-600 hover:bg-indigo-700 rounded-2xl focus:outline-none"
                    data-testid="new"
                    type="submit"
                >
                    {editedUser.id ? 'Update' : 'Create'}
                </button>
            </form>

            {data?.users.map((user) => {
                return (
                    <UserItem 
                        key={user.id}
                        user={user}
                        setEditedUser={setEditedUser}
                        delete_users_by_pk={delete_users_by_pk}
                    />
                )
            })}
        </Layout>
    )
}

export default HasuraCRUD

CREATEとDELETEの場合は処理終了後に自動的にキャッシュが更新されない使用になっている(UPDATEは自動で更新される)
データ生成・削除の場合はキャッシュを更新させたい場合は自身で明示的にキャッシュの更新をさせる処理を記載する必要がある

データ生成・削除処理終了後に返ってくるデータはqueries.tsのクエリの関数名をテーブル名として返してくる
insert_users_oneがテーブル名として返ってくる
フィールドにはid, name, created_atの情報が記載されたデータが返ってくる

export const CREATE_USER = gql`
    mutation CreateUser($name: String!) {
        insert_users_one(object: { name: $name }) {
            id
            name
            created_at
        }
    }
`

データ生成後のキャッシュ更新処理において
update(cache, {data: {insert_users_one} }) { の処理は返ってきたinsert_users_oneのデータ(作成したユーザーデータ)を読みに行っており、
const cacheId = cache.identify(insert_users_one)の処理はアポロの機能であり、insert_users_one(作成したユーザーデータ)のkeyを受け取っている
toReference(cacheId)に作成したユーザーデータのIDを渡すことでIDに紐づいたinsert_users_one(作成したユーザーデータ)のデータを参照することができる
cache.modify({ fields: { は更新したいキャッシュのフィールドを記載するところであり、usersをここでは選択している
users(existingUsers, {toReference}) {
return [toReference(cacheId), ...existingUsers]
} の処理で新規作成ユーザー+既存のユーザーを配列に足してあげことで、キャッシュに格納するという処理を行ってキャッシュを更新している

データ削除の場合は、フィルターで削除したフィールドだけをキャッシュからそぎ落とす方法で消している
(※実装の方式としては今削除したユーザーと一致しないものは残すというやり方でそぎ落としている)
readFieldを使うと任意のfieldの値を読み取ることができる
users(existingUsers, {readField}) {
return existingUsers.filter(
(user) => delete_users_by_pk.id !== readField('id', user)
)
},

作成・更新クエリでデータを渡す場合はvariablesで渡すことができる

await update_users_by_pk({
    variables: {
        id: editedUser.id,
        name: editedUser.name,


上記のhasura-crudにユーザー一覧情報を表示させたいのでcomponentsにUserItem.tsxを作成している
UserItem.tsx

import { VFC, memo, Dispatch, SetStateAction } from "react";
import { Users, DeleteUserMutationFn } from "../types/generated/graphql";

interface Props {
    user: {
        __typename?: 'users'
    } & Pick<Users, 'id' | 'name' | 'created_at'>
    delete_users_by_pk: DeleteUserMutationFn
    setEditedUser: Dispatch<
        SetStateAction<{
            id: String
            name:String
        }>
    >
}

export const UserItem: VFC<Props> = ({user, delete_users_by_pk, setEditedUser}) => {
    return (
        <div className="my-1">
            <span className="mr-2">{user.name}</span>
            <span className="mr-2">{user.created_at}</span>
            <button 
                className="mr-1 py-1 px-3 text-white bg-green-600 hover:bg-green-700 rounded-2xl focus:outline-none"
                data-testid={`edit-${user.id}`}
                onClick={() => {
                    setEditedUser(user)
                }}    
            >
                Edit
            </button>
            <button
                className="mr-1 py-1 px-3 text-white bg-pink-600 hover:bg-pink-700 rounded-2xl focus:outline-none"
                data-testid={`delete-${user.id}`}
                onClick={async () => {
                    await delete_users_by_pk({
                        variables: {
                            id: user.id,
                        },
                    })
                }}    
            >
                Delete
            </button>
        </div>
    )
}

Dispatch, SetStateActionはuseStaetの更新用の関数のデータ型で使用する
DeleteUserMutationFnは削除用関数のデータ型

UserItem.tsxのinterface Propsに関して、
userのデータ型は__typenameとid, name, created_atだけをピックしたUsers(カーソルをホバーすると他にもいろいろなデータがUsersクラスには存在している)の二つのデータが格納されている
f:id:shinseidaiki:20211120011743p:plain
delete_user_by_pkはDeleteUserMutationにFnをくっつけたものがデータ型になる
setEditedUserはhasura-crudで設定したuseStateのsetEditedUserにカーソルをホバーして出てきたデータ型をコピペしている
f:id:shinseidaiki:20211120012307p:plain

動作確認

Hasura CRUDのページでCRUDができることを確認
フォームに文字を入力するとデータの数だけレンダリングが行われてしまう(例:データが4個だと4回レンダリングされる)
f:id:shinseidaiki:20211120014219p:plain
f:id:shinseidaiki:20211120014206p:plain

無駄なレンダリングの対処法
→ インポートしたmemo機能を使用する
親コンポーネントhasura-crudのinputフォームにおける文字入力に対して、子コンポーネントUserItemのsetEditedUserが反応して再レンダリングしてしまっている
→親コンポーネントの変更に対して子コンポーネントが再レンダリングされるのはデフォルトの挙動であるが、memoを子コンポーネントにかませることで再レンダリングさせないようにすることが可能になる
→UserItem.tsxのファンクショナルコンポーネントをmemoでラップする

元々UserItem.tsx

export const UserItem: VFC<Props> = ({user, delete_users_by_pk, setEditedUser}) => {
.......
}

メモでラップUserItem.tsx

export const UserItem: VFC<Props> = memo(({user, delete_users_by_pk, setEditedUser}) => {
.........
})
トラブルシュート

テストファイルに以下追加

/**

@jest-environment jsdom

/

Nextjs ver11.0対応
Layout component
1. Layout componentに Imageのimport文追加
2. をNextの表記に変更



Next.js + Hasuraによる Stastic Site Generation + Increamental Static Regenerationの実装

pages/hasura-ssg.tsxを作成
必要なモジュールのインポート

import { VFC } from "react";
import Link from "next/link";
import { GetStaticProps } from "next";
import { initializeApollo } from "../lib/apolloClient";
import { GET_USERS } from "../queries/queries";
import { GetUsersQuery, Users } from "../types/generated/graphql";
import { Layout } from "../components/Layout";

アプリのビルド時にサーバーサイドで実行される処理であるgetStaticPropsの作成
revalidateを1秒にしてインクリメンタルスタティックレジェネレーションも有効化
アポロクライアントを生成してアポロクライアントを使用してクエリを投げる
クエリで返ってくるユーザーのデータはdataとして受け取り、ファンクショナルコンポーネントのpropsにusersとしてデータが取り出せるようにしておく

export const getStaticProps: GetStaticProps = async () => {
    const apolloClient = initializeApollo()
    const { data } = await apolloClient.query<GetUsersQuery>({
        query: GET_USERS,
    })
    
    return {
        props: { users: data.users },
        revalidate: 1,
    }
}

ファンクショナルコンポーネントの実装

interface Props {
    users: ({
        __typename?: "users";
    } & Pick<Users, 'id' | 'name' | 'created_at'>)[]
}

const HasuraSSG: VFC<Props> = ({ users }) => {
    return (
        <Layout title="Hasura SSG">
            <p className="mb-3 font-bold">SSG+ISR</p>
            {users?.map((user) => {
                return (
                    <Link key={user.id} href={`/users/${user.id}`}>
                        <a className="my-1 cursor-pointer" data-testid={`link-${user.id}`}>
                            {user.name}
                        </a>
                    </Link>    
                )
            })}
        </Layout>
    )
}

export default HasuraSSG


interfaceのusersの型は例のごとくカーソルを合わせて取得する
f:id:shinseidaiki:20211120145728p:plain
ただし、user内のid, name, created_atは適宜ない場合があるので、Pickを用いて書き換えている
__typename?: "users"とUsersオブジェクトで構成された配列型をusersとしている

注意:SSG + ISR動作確認

SSGやISRの動作確認を行う場合はyarn devではなく

yarn build 
yarn start

で実行する必要がある
yarn devはサーバーサイドレンダリングを常に行うようになっているため

トラブルシュート

yarn build時の missing display name errorが発生した場合はUserItem.tsxの関数定義の上にESLint無効化の一行を追加
// eslint-disable-next-line react/display-name を追加
memo化している箇所の1行上に記載する

example UserItem.tsx

// eslint-disable-next-line react/display-name 
export const UserItem: VFC<Props> = memo(

example Child.tsx

// eslint-disable-next-line react/display-name
export const Child: VFC<Props> = memo(({ printMsg, handleSubmit }) => {


Hasura SSG画面にて、SSGが起動しているため、JavaScriptを無効化してもデータが取得できることも確認できる
F12>Setting>Debugger >Disabled JS
なお、Fetch Policyはサーバーにデータを取得しに行っているので、JS無効化の状態ではデータが何も映らないことが確認できる


hasura-ssgから個別のユーザーに飛ぶ画面の作成
pages/users/[id].tsxを作成する

[id].tsx getStaticPathsの実装

export const getStaticPaths: GetStaticPaths = async () => {
    const apolloClient = initializeApollo()
    const { data } = await apolloClient.query<GetUserIdsQuery>({
        query: GET_USERIDS,
    })
    const paths = data.users.map((user) => ({
        params: {
            id: user.id
        },
    }))
    
    return {
        paths,
        fallback: true,
    }
}


以下の記述はNext.jsで個別ページを作る際のお決まりの実装方式

const paths = data.users.map((user) => ({
        params: {
            id: user.id
        },
    }))

なおこの時ユーザーIDはanyになっているので、graphql.tsxの17行目あたりのuuidがanyとなってしまっているので、stringに書き換える
f:id:shinseidaiki:20211120153023p:plain

export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
timestamptz: any;
uuid: any;
};

のuuid: any;を以下に変更

uuid: string;


[id].tsx getStaticPropsの実装

export const getStaticProps: GetStaticProps = async ({params}) => {
    const apolloClient = initializeApollo()
    const { data } = await apolloClient.query<GetUserByIdQuery>({
        query: GET_USERBY_ID,
        variables: { id: params.id },
    })
    return {
        props: {
            user: data.users_by_pk,
        },
        revalidate: 3,
    }
}

return {props: { user: data.users_by_pk, の data.users_by_pk のフィールドの名前は queries.ts の users_by_pk(id: $id) に対応している


[id].tsx のファンクショナルコンポーネントの実装

interface Props {
    user: {
        __typename?: 'users'
    } & Pick<Users, 'id' | 'name' | 'created_at'>
}

const UserDetail: VFC<Props> = ({ user }) => {
    if (!user) return <Layout title="loading">Loading...</Layout>
    
    return (
        <Layout title={user.name}>
            <p className="text-xl font-bold">User detail</p>
            <p className="m-4">
                {'ID : '}
                {user.id}
            </p>
            <p className="mb-4 text-xl font-bold">{user.name}</p>
            <p className="mb-12">{user.created_at}</p>
            <Link href="/hasura-ssg">
                <div className="flex cursor-pointer mt-12">
                    <ChevronDoubleLeftIcon 
                        data-testid="auth-to-main"
                        className="h-5 w-5 mr-3 text-blue-500"
                    />
                    <span data-testid="back-to-main">Back to main-ssg-page</span>
                </div>
            </Link>
        </Layout>
    )
}

export default UserDetail
動作確認

SSGとISRが[id].tsxでもうまくいっていることが確認できる

Custom Hooks + useCallback + memoの実装

hasura-crud.tsxファイルは現状CRUDが混在しておりコード保守性がよくないため、Custom Hooksを使って保守性を高めていく
hooksフォルダを作成

その直下にまずはユーザー作成用Custom Hooksの useCreateForm.tsを作成する
Providerのような実装の形式になる
useCreateForm.tsに必要なモジュールをインポート

import { useState, useCallback, ChangeEvent, FormEvent } from "react";
import { useMutation } from "@apollo/client";
import { CREATE_USER } from "../queries/queries";
import { CreateUserMutation } from "../types/generated/graphql";

useCreateForm.ts ファンクショナルコンポーネントの作成

export const useCreateForm = () => {
    const [text, setText] = useState('')
    const [username, setUsername] = useState('')

    const [insert_users_one] = useMutation<CreateUserMutation>(CREATE_USER, {
        update(cache, {data: {insert_users_one} }) {
            const cacheId = cache.identify(insert_users_one)
            cache.modify({
                fields: {
                    users(existingUsers, {toReference}) {
                        return [toReference(cacheId), ...existingUsers]
                    },
                },
            })
        },
    })

    const handleTextChange = (e: ChangeEvent<HTMLInputElement>) => {
        setText(e.target.value)
    }
    const usernameChange = (e: ChangeEvent<HTMLInputElement>) => {
        setUsername(e.target.value)
    }
    const printMsg = () => {
        console.log('Hello')
    }

    const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        try {
            await insert_users_one({
                variables: {
                    name: username,
                },
            })
        } catch (err) {
            alert(err.message)
        }
        setUsername('')
    }

    return {
        text,
        handleSubmit,
        username,
        usernameChange,
        printMsg,
        handleTextChange,
    }
}

const [insert_users_one] はhasura-crud.tsx で作成したコードをそのまま持ってくる
const handleSubmit に関しても同様にコードを拝借する


次にcomponentsフォルダに CreateUser.tsx を作成する
このコンポーネントではまず Custom Hooks CreateUser で作成していたものをすべて受け取る
フォームのコンポーネントも作成する

CreateUser.tsx

import { VFC } from "react"
import { useCreateForm } from "../hooks/useCreateForm"

export const CreateUser: VFC = () => {
    const {
        text,
        handleSubmit,
        username,
        usernameChange,
        printMsg,
        handleTextChange,
    } = useCreateForm()
    
    return (
        <>
            {console.log('CreateUsr rendered')}
            <p className="mb-3 font-bold">Custom Hook + useCallback + memo</p>
            <div className="mb-3 flex flex-col justify-center items-center">
                <label>Text</label>
                <input 
                    className="px-3 py-2 border border-gray-300"
                    type="text"
                    value={text}
                    onChange={handleTextChange}
                />
            </div>
            <form 
                className="px-3 py-2 border border-gray-300"
                onSubmit={handleSubmit}
            >
                <label>Username</label>
                <input 
                    className="px-3 py-2 border border-gray-300"
                    placeholder="New user ?"
                    type="text"
                    value={username}
                    onChange={usernameChange}
                />
                <button 
                    className="mb-3 py-1 px-3 text-white bg-indigo-600 hover:bg-indigo-700 rounded-2xl focus:outline-none"
                    type="submit"
                >
                    Submit
                </button>
            </form>
        </>
    )
}

export default CreateUser


そして useCallback の最適化についての効果を確認するためにChild.tsxをcomponentsに作成する
このChild.tsxは親コンポーネントのCreateUserからprintMsgがわたされている
Child.tsx

import { ChangeEvent, FormEvent, memo, VFC } from "react"

interface Props {
    printMsg: () => void
}

export const Child: VFC<Props> = ({ printMsg }) => {
    return (
        <>
            {console.log('Child rendered')}
            <p>Child Component</p>
            <button 
                className="mb-3 py-1 px-3 text-white bg-indigo-600 hover:bg-indigo-700 rounded-2xl focus:outline-none"
                onClick={printMsg}
            >
                Click
            </button>
        </>
    )
}


親コンポーネントの CreateUser.tsx にChild.tsx を表示させる
必要なモジュールインポート CreateUser.tsx

import { Child } from "./Child"

CreateUser.tsx ファンクショナルコンポーネントの最後に以下追記

<Child printMsg={printMsg} />
||< 


CreateUserのコンポーネントをpageに埋め込んでいくために hooks-memo.tsx ファイルを作成する
hooks-memo.tsx
>||
import { VFC } from "react"
import { Layout } from "../components/Layout"
import CreateUser from "../components/CreateUser"

const HooksMemo: VFC = () => {
    return (
        <Layout title="Hooks memo">
            <CreateUser />
        </Layout>
    )
}

export default HooksMemo


ここまで実装が済めば動作確認

yarn dev

この実装ではテキストフィールドに文字を入力するごとに入力とは関係のないChild.tsxコンポーネントが影響を受けて再レンダリングを実施してしまう(Next.jsの仕様)
f:id:shinseidaiki:20211120164525p:plain

→ 対処法:Childをmemoで囲いuseCallbackを利用する
なおビルド時にエラーが発生するためESLintの無視のコードも記載する

元々Child.tsx

export const Child: VFC<Props> = ({ printMsg }) => {
    .....................
}

memo 適用後 Child.tsx

// eslint-disable-next-line react/display-name
export const Child: VFC<Props> = memo( ({ printMsg }) => {
      .....................................
} )

memoで囲うだけでは再レンダリングは止まらないため、useCallbackを次に実装していく
これは子コンポーネントはmemo化されているが、親コンポーネントCreateUser.tsxで渡してくるprintMsg自体がメモ化されていないことに起因する
毎回新しい関数オブジェクトが生成されている形になっているため、propsの値が毎回変化しているとみなされてChildコンポーネントが親コンポーネントの方で毎回レンダリングされてしまっている
printMsgを毎回生成しないようにするためにuseCallbackを使用する
useCreateForm.ts のprintMsgに useCallback の処理を追記する

useCreateForm.ts 元々

const printMsg = () => {
        console.log('Hello')
    }

useCreateForm.ts 変更後

const printMsg = useCallback( () => {
    console.log('Hello')
}, [])

ユーズコールバックはこれが基本形 useCallback(() => {} , )には依存関係を表しておりuseEffectと同様の書き方ができる
[]の場合は初回呼び出し時にのみ読み込まれる

動作確認

yarn dev
このように実装することで子コンポーネントの再レンダリングは行われなくなることが確認できる
文字を入力しても Child rendered が出力されていなければよい

Tips!!!!

カスタムフック Custom Hooks で定義されている関数はuseCallbackを適用することが推奨されている
基本的にはCustom Hooksは他の多数のコンポーネントに多用することの方が多いと考えられるため

動作検証2

useCallbackでラップしていないhandleSubmitを子コンポーネントに渡してみる
CreateUser

<Child printMsg={printMsg} handleSubmit={handleSubmit}/>

Child

interface Props {
    ..................
    handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
}

export const Child: ....................mo(({ ...........sg, handleSubmit }) => {
   ......................

この処理を実装してしまうとまたしても文字入力ごとに Child rendered が表示されて、子コンポーネントが読み込まれていることがわかる
よって、useCreateFormで使われているCustom Hooksの関数は基本的にはuseCallBack化しておくことが推奨される

handleSubmitをuseCallback化する

注意しなければいけないのはステートがある場合でも、を何も考えずにで設定すると、このケースのようにusernameが文字を入力されても初期状態の空文字''から変更できないという現象が起こってしまう
※名前のないユーザしか作れなくなってしまう
usernameのようなステートがuseCallback内にある場合、[username]を設定しておく必要がある

useCreateForm.ts handleSubmit元々

    const handleSubmit =  async (e: FormEvent<HTMLFormElement>) => {
     .............
          name: username,
      ...............
      setUsername('')
    }

useCreateForm.ts handleSubmit 変更後

    const handleSubmit =  useCallback(  async (e: FormEvent<HTMLFormElement>) => {
     ....................
         name: username,
    .........................
         setUsername('')
    }, [username] )
動作確認

Textに文字を入れた場合は子コンポーネントの再レンダリングは止まり、
Usernameに文字を入れた場合は子コンポーネントの再レンダリングは起こる状態となることを確認できれば良い
usenameはChildに関わっているパラメータであるため想定通りの挙動となる
f:id:shinseidaiki:20211120172930p:plain

他の関数の修正

useCreateForm.ts

const handleTextChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
        setText(e.target.value)
    }, [])
    const usernameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
        setUsername(e.target.value)
    }, [])

Custom Hooksを使用するメリット

CreateUser.tsx は画面
useCreateForm.tsはロジックという風に分けることができるため保守性に優れている

React-Testing-library with GraphQLを利用したTestの実装

テストの実装(クエリのMock化

mockフォルダをプロジェクト直下に作成
mock/handlers.tsを作成

mock service workerからgraphQLをインポートする
mock service workerはrestAPIとGraghQLの両方に対応している
queries.tsで作成したクエリをテスト用に作成していく

必要モジュール

import { graphql } from 'msw'

handlers.ts ユーザ取得クエリのモック化(GetUsers)

export const handlers = [
    graphql.query('GetUsers', (req, res, ctx) => {
        return res(
            ctx.data({
                users: [
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000a',
                        name: 'Test User A',
                        created_at: '2021-01-01T13:47:23.308833+00:00'
                    },
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000b',
                        name: 'Test User B',
                        created_at: '2021-02-02T13:47:23.308833+00:00'
                    },
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000c',
                        name: 'Test User C',
                        created_at: '2021-03-03T13:47:23.308833+00:00'
                    },
                ],
            })
        )
    }),
]

このように書くとテストの際にgetUserを呼び出した場合は、queries.tsでGetUsersを使っている部分を呼び出すのではなく、ここに記述したテストユーザーのデータを返すように自動的にMock化することができる


handlers.ts モック化 GetUserIDs と GetUserById

graphql.query('GetUserIDs', (req, res, ctx) => {
        return res(
            ctx.data({
                users: [
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000a',
                    },
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000b',
                    },
                    {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000c',
                    },
                ],
            })
        )
    }),
graphql.query('GetUserById', (req, res, ctx) => {
        const { id } = req.variables
        if (id === 'b39ec6cc-9d85-458d-aa0e-00000000000a') {
            return res(
                ctx.data({
                    users_by_pk: {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000a',
                        name: 'Test User A',
                        created_at: '2021-01-01T13:47:23.308833+00:00',
                    }
                })
            )
        }
        if (id === 'b39ec6cc-9d85-458d-aa0e-00000000000b') {
            return res(
                ctx.data({
                    users_by_pk: {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000b',
                        name: 'Test User B',
                        created_at: '2021-02-02T13:47:23.308833+00:00',
                    }
                })
            )
        }
        if (id === 'b39ec6cc-9d85-458d-aa0e-00000000000c') {
            return res(
                ctx.data({
                    users_by_pk: {
                        __typename: 'users',
                        id: 'b39ec6cc-9d85-458d-aa0e-00000000000c',
                        name: 'Test User C',
                        created_at: '2021-03-03T13:47:23.308833+00:00',
                    }
                })
            )
        }
    }),

モック化した関数も const { id } = req.variables で引数を受け取ることができる


テストの実装(テストファイル・テストケースの実装

__test__フォルダに正しく画面遷移が行われるかどうかをチェックするNavBar.test.tsxを作成

テスト基本形

テストに必要なモジュールのインストール

import { render, screen, cleanup } from "@testing-library/react"
import '@testing-library/jest-dom/extend-expect'
import userEvent from '@testing-library/user-event'
import { getPage, initTestHelpers } from 'next-page-tester'
import { setupServer } from 'msw/node'
import { handlers } from "../mock/handlers"

initTestHelpers()

const server = setupServer(...handlers)

beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})

userEventはユーザーのクリックイベントをシミュレーションするためのライブラリ
next-page-testerを使用する場合はinitTestHelpers()で初期化が必要
const server = setupServer(...handlers)はテスト用のモックサーバーを立てている
before ... after ... は各テストケース開始時にモックサーバーを立てて、終了時にモックサーバーをお片付けする処理


テストケースの基本形

NavBar.test.tsx

describe('Navigation Test Case', () => {
    it ('Should route to selected page in navbar', async () => {
       const { page } = await getPage({
           route: '/',
       })
       render(page)
       expect(await screen.findByText('Next.js + GraphQL')).toBeInTheDocument()
    })
})
テストの実行
yarn test

テストが通ればOK

トラブルシュート

yarn testの実行が終わらない場合
Next 12をNext11にダウングレードする

yarn add next@11.1.2
トラブルシュート

ReferenceError: setImmediate is not defined 対処法
Jest系のupdateが原因の模様

1. ReferenceError: document is not defined
対処法 : 各テストファイルの先頭に下記3行のコメント文を追加

/**
* @jest-environment jsdom
*/


2. ReferenceError: setImmediate is not defined
対処法 :

1. setimmdiate パッケージのインストール

yarn add setimmediate

2. 各テストファイルのimport部に下記importを追加

import 'setimmediate'
テストケース作成の続き

NavBar.test.tsx のクリックイベントによる画面遷移の確認

describe('Navigation Test Case', () => {
    it ('Should route to selected page in navbar', async () => {
       ...............
       userEvent.click(screen.getByTestId('makevar-nav'))
       expect(await screen.findByText('makeVar')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('fetchpolicy-nav'))
       expect(await screen.findByText('Hasura main page')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('crud-nav'))
       expect(await screen.findByText('Hasura CRUD')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('ssg-nav'))
       expect(await screen.findByText('SSG+ISR')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('memo-nav'))
       expect(await screen.findByText('Custom Hook + useCallback + memo')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('home-nav'))
       expect(await screen.findByText('Next.js + GraphQL')).toBeInTheDocument()
    })
})

クリックイベントはuserEvent.clickで実行できる
クリックしたい要素はLayout.tsx で定義した data-testid="makevar-nav" の値を使用して識別している
クリックイベントのテストを行う場合はdata-testidの実装を忘れずに行う必要がある

Hasura メインページのテスト

Mockサーバーからテストデータ取得できるかのテストを行う
HasuraMain.test.tsxを作成

HasuraMain.test.tsx 基本形

/**
* @jest-environment jsdom
*/
import { render, screen, cleanup } from "@testing-library/react"
import '@testing-library/jest-dom/extend-expect'
import { setupServer } from 'msw/node'
import { getPage, initTestHelpers } from 'next-page-tester'
import { handlers } from "../mock/handlers"
import 'setimmediate'

initTestHelpers()

const server = setupServer(...handlers)

beforeAll(() => {
    server.listen()
})
afterEach(() => {
    server.resetHandlers()
    cleanup()
})
afterAll(() => {
    server.close()
})

describe('Hasura Fetch Test Case', () => {
    it ('Should render the list of users by useQuery', async () => {
       const { page } = await getPage({
           route: '/hasura-main',
       })
       render(page)
       expect(await screen.findByText('Hasura main page')).toBeInTheDocument() 
    })
})


HasuraMain.test.tsx モックサーバーからGetUsersでデータを取得してテストが通ることを確認する

describe('Hasura Fetch Test Case', () => {
    it ('Should render the list of users by useQuery', async () => {
       ..........................
       expect(await screen.findByText('Test user A')).toBeInTheDocument()
       expect(screen.getByText('Test user B')).toBeInTheDocument()
       expect(screen.getByText('Test user C')).toBeInTheDocument()
    })
})

レンダリングに時間がかからないテストに関してはawaitは省くことができる
Test user Aのawaitは初期画面レンダリング時にはまだサーバーにフェッチしている状態でデータがレンダリングされていない状況がありうるためawaitを入れている
※自分の環境ではエラーが出るためすべてawaitを記載
→ findByTextはawaitを使わなければ即刻データを取得してしまうからだった
→またgetByTextはawaitを使えない模様

Tips!

ApolloClientのCache部分まではテストできない


CRUDに関するテストの実装

HasuraCRUD.test.tsxを作成
インポート部分は他のテストと同じなので省略

HasuraCRUD.test.tsx

describe('Hasura CRUD Test Case', () => {
    it ('Should render the list of users by useQuery', async () => {
       const { page } = await getPage({
           route: '/hasura-crud',
       })
       render(page)
       expect(await screen.findByText('Hasura CRUD')).toBeInTheDocument()
       expect(await screen.findByText('Test user A')).toBeInTheDocument()
       expect(screen.getByText('2021-01-01T13:47:23.308833+00:00')).toBeInTheDocument()
       expect(screen.getByTestId('edit-b39ec6cc-9d85-458d-aa0e-00000000000a')).toBeTruthy()
       expect(screen.getByTestId('delete-b39ec6cc-9d85-458d-aa0e-00000000000a')).toBeTruthy()
       expect(screen.getByText('Test user B')).toBeInTheDocument()
       expect(screen.getByText('2021-02-02T13:47:23.308833+00:00')).toBeInTheDocument()
       expect(screen.getByTestId('edit-b39ec6cc-9d85-458d-aa0e-00000000000b')).toBeTruthy()
       expect(screen.getByTestId('delete-b39ec6cc-9d85-458d-aa0e-00000000000b')).toBeTruthy()
       expect(screen.getByText('Test user C')).toBeInTheDocument()
       expect(screen.getByText('2021-03-03T13:47:23.308833+00:00')).toBeInTheDocument()
       expect(screen.getByTestId('edit-b39ec6cc-9d85-458d-aa0e-00000000000c')).toBeTruthy()
       expect(screen.getByTestId('delete-b39ec6cc-9d85-458d-aa0e-00000000000c')).toBeTruthy()
    })
})

created_atのデータなどの動的データstateのデータはgetByTextで取得する
ボタンなどのコンポーネントはdata-testidが見つかるかどうかで判断する
判定はtoBeTruthyとなる


SSG + ISRのテスト

HasuraSSG.test.tsx

describe('Hasura SSG Test Case', () => {
    it ('Should render the list of users pre-fetched by getStaticProps', async () => {
       const { page } = await getPage({
           route: '/hasura-ssg',
       })
       render(page)
       expect(await screen.findByText('SSG+ISR')).toBeInTheDocument()
       expect(screen.getByText('Test user A')).toBeInTheDocument()
       expect(screen.getByText('Test user B')).toBeInTheDocument()
       expect(screen.getByText('Test user C')).toBeInTheDocument()
    })
})

事前レンダリングされているはずなので、testAなどはawaitが不要なのでgetByTextで取得する

SSG 個別ページusers/[id].tsxのテスト

HasuraSSGDetail.test.tsxを作成

describe('UserDetail Test Case', () => {
    it ('Should render the user detail pre-fetched by getStaticProps', async () => {
       const { page } = await getPage({
           route: '/users/b39ec6cc-9d85-458d-aa0e-00000000000a', // Test user A
       })
       render(page)
       expect(await screen.findByText('User detail')).toBeInTheDocument()
       expect(screen.getByText('Test user A')).toBeInTheDocument()
       expect(screen.getByText('2021-01-01T13:47:23.308833+00:00')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('back-to-main'))
       expect(await screen.findByText('SSG+ISR')).toBeInTheDocument()
       userEvent.click(screen.getByTestId('link-b39ec6cc-9d85-458d-aa0e-00000000000b')) // Test user B
       expect(await screen.findByText('User detail')).toBeInTheDocument()
       expect(screen.getByText('Test user B')).toBeInTheDocument()
       expect(screen.getByText('2021-02-02T13:47:23.308833+00:00')).toBeInTheDocument()
    })
})

個別ページはrouteがモック化したデータのidを使う
route: '/users/b39ec6cc-9d85-458d-aa0e-00000000000a',
テストユーザーAの内容を確認した後に一度ユーザー一覧画面に戻り、テストユーザーBの詳細画面に移動できることを確認している



Hasuraのセキュリティ

Hasura Endpoint にプロテクションをかける

Hausuraのエンドポイントはデフォルトでは誰でもアクセスできる状態になっている

プロテクション対処法1 JWTトークンを利用する

実用上はこちらを利用する
自身で調べる必要がある

Firebase の Authentification を使用する方法がある
email を利用したアカウント作成を行うことができる

プロテクション対処法2 Hasura admin secretを利用する

今回はこちらを利用する

https://cloud.hasura.io/projectにアクセスして自身のプロジェクトの設定画面を開く(歯車マーク)
Env vars > new Env var > HASURA_GRAPHQL_ADMIN_SECRET
valueに複雑なパスワードキーを入力してAdd
コンソールに移動するとx-hasura-admin-secretが追加されていることがわかる
x-hasura-admin-secretがない状態ではデータベースにアクセスできない

Error: x-hasura-admin-secret/x-hasura-access-key required, but not found

環境変数の作成

アクセスできないと困るので先ほどvalueに入れたパスワードの値を環境変数に格納する
.env.local ファイルの作成して""の部分にパスワードを格納する

NEXT_PUBLIC_HASURA_KEY=""

apolloClientのヘッダーにx-hasura-admin-secretを追加する

apolloClient.ts

link: new HttpLink({
        uri: ........................................
        headers: {
          'x-hasura-admin-secret': process.env.NEXT_PUBLIC_HASURA_KEY,
        }
      }),

※このファイルのexport const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__'は使用しないため削除またはコメントアウトしてよい

URIも環境変数化する .env.local に追記

NEXT_PUBLIC_HASURA_URL="https://xxxxxxxxx.hasura.app/v1/graphql"

apolloClient.ts のURLを環境変数から読み込む

link: new HttpLink({
        uri: process.env.NEXT_PUBLIC_HASURA_URL
        headers: {
          'x-hasura-admin-secret': process.env.NEXT_PUBLIC_HASURA_KEY,
        }
      }),

テストモックが環境変数を読み込めるようにする実装

テストモックはデフォルトでローカルの環境変数(.env.local)を読み込まないので、テスト用の環境変数をあらたに用意してあげる必要がある
.env.test.local を作成して、.env.localと同じ内容を記載する

.env.test.local

NEXT_PUBLIC_HASURA_KEY=""
NEXT_PUBLIC_HASURA_URL="https://xxxxxxxxx.hasura.app/v1/graphql"

yarn test を行ってテストが通ることを確認できればOK

トラブルシュート

.env.test.localの環境変数が上手く読み込まれない場合、

対処1 Home.test.tsx以外の全てのテストファイルの import 直下に下記環境変数を定義してからyarn testを実行

process.env.NEXT_PUBLIC_HASURA_URL = 'https://xxx.hasura.app/v1/graphql'


対処2 環境変数を書くテストケースで読み込む

以下の記事に解決策の兆しは書いてあったものその通りの方法を実施した場合ではエラーが出たため自環境では各テストケースで環境変数を読み込む実装とした
Jestを用いたNext.jsのテスト内で環境変数を利用する - Breath Note

プロジェクト直下に
test/setupEnv.ts を作成し、以下を実装

import { loadEnvConfig } from '@next/env'

export const setupEnv = async (): Promise<void> => {
  loadEnvConfig(process.env.PWD || process.cwd())
}

各テストのテストケースで上記のsetupEnvを実行する
XXXXX.test.tsx

// ..........................
import { setupEnv } from "../test/setupEnv" 

//.......................

describe('.................................', () => {
    it ('.................................', async () => {
        await setupEnv()
        // ......................................

Vercelへデプロイ作業

前記事参照
Next.js基本編学習メモ - 古生代紀のブログ

1 リポジトリを作成してgit hubにコミット・プッシュする
2 VercelのダッシュボードのNewProjectで先ほどプッシュしたリポジトリをVercelにデプロイ
build and output settingsの設定でデプロイ前テスト自動実行をさせることができる
すべてのテストが通らないとデプロイされない
buld command の Overrideをオンにして、テキストボックスにテストとビルドを実行させるコマンドを記載する

yarn test && yarn build

Environmental Variablesに .env.local に記載した環境変数の値を入れる

3. デプロイをクリック
4 デプロイが成功して動作確認で問題なければ成功


END.