👶

Laravelにlighthouseを導入して、ReactでApollo Clientを使って簡単なCRUDアプリを作る!!!

2022/07/31に公開1

この記事では以下のような簡単CRUDアプリを作っていきたいと思います!

バックエンド Laravel,lighthouse

Laravelにlighthouseを導入することにより、LaravelをGraphQLサーバーとして扱うことができるようになります。

実際にGraphQLを利用したCRUDアプリを作成していきたいと思います。

Laravelプロジェクトを作成します。色々選択肢があります。お好きなのをどうぞ。
https://readouble.com/laravel/8.x/ja/installation.html

モデルとマイグレーションファイルを作成する

php artisan make:model Tweet --migration

マイグレーションファイル作成とシーディングを行う

このパートはメインではなく準備になるのでコードは抜粋しています。
tweetsテーブルのスキーマを定義します。超シンプルは構成です。

public function up()
    {
        Schema::create('tweets', function (Blueprint $table) {
            $table->id();
            $table->string('content');
            $table->timestamps();
        });
    }

マイグレートを行います。

php artisan migrate

テーブルが完成したのでダミーデーターを作成していきます。factoryとseederを利用します。

TweetFactory.php
 public function definition()
    {
        return [
            'content' => $this->faker->text,
        ];
    }

シーダーを記述してシーディングを行います。

   public function run()
    {
        // \App\Models\User::factory(10)->create();
        Tweet::factory(100)->create();
    }
php artisan db:seed

これでダミーデータを100件作成することができました。

laravelにlighthouseを導入する

プロジェクトディレクトリにてコンポーザー経由でインストールを行います。

composer require nuwave/lighthouse

そして以下のartisanコマンドを入力すると、lighthouseが用意したデフォルトのスキーマを発行することができます。

php artisan vendor:publish --tag=lighthouse-schema

するとgraphqlディレクトリの中に、schema.graphqlファイルが作成されます。
内容としてはUser型と2つのQueryが定義されています。今回はTweetのデータだけを扱う予定なので触りません。

GraphQL開発には欠かせないツールをインストールする

以下のコードでGraphQL Playgroundをインストールすることができます。
mysqlをphpmyadminで操作する人が多いですが、そのgraphqlバージョンと考えていいと思います。
自分はフロントからバックエンドにクエリを投げる前にクエリ文が合っているのかプレイグラウンドで投げて
確かめています。

composer require mll-lab/laravel-graphql-playground

http://127.0.0.1:8000/graphql-playground
以下の画面が表示されたら成功です。

実際にGraphQLクエリを投げて値を取得する

schema.graphqlを以下のようにします。
phpを書かずにモデルの操作を行うことができました。lighthouseすごいですね。
文字通り@allで全てのTweetを取得しています。この@はlighthouseのディレクティブと呼ばれる機能で、
例えばlaravelのModel::all()とやっていたのを@allのように記述することができます。
用意されているディレクティブの一覧
https://lighthouse-php.com/5/api-reference/directives.html#aggregate

schema.graphql
scalar DateTime
    @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\DateTime")

type Query {
    #ツイートを全て取得
    tweets: [Tweet!]! @all
}

type Tweet {
    id: ID!
    content: String!
}


上記にCreate、UpDate、Deleteを追加して以下のようになりました。
CRUD機能をこんなに簡単に作ることができました。

schema.graphql
scalar DateTime
    @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\DateTime")

type Query {
    #ツイートを全て取得
    tweets: [Tweet!]! @all
}

type Mutation {
    #ツイート作成
    createTweet(content: String!): Tweet! @create

    #ツイートアップデート
    updateTweet(id: ID!, content: String!): Tweet! @update

    #ツイート削除
    deleteTweet(id: ID!): Tweet! @delete
}

type Tweet {
    id: ID!
    content: String!
}

上のコードの簡単な補足です。

  • !は、そのフィールドがnullにならないことを表している。
  • ): Tweetは返り値を表している。type Tweetの構造のデータを取得することができる。
  • Queryはバックエンドから固定の結果を処理で、Readで使われます。ex)一覧取得、条件による検索など
  • DBのデータに変更を加えるCreate、UpDate、DeleteはMutation
    https://lighthouse-php.com/5/the-basics/schema.html#types

フロントエンド React,ApolloClient

Reactプロジェクトの作成

まず以下のコマンドでReactとTypeScriptの環境を作ります。

npx create-react-app graphql-crud-frontend --template typescript

Apollo Clientの導入

https://www.apollographql.com/docs/react/get-started/ を参考に行います。

以下のコマンドを実行して、Reactのデフォルトのページが表示されれば成功です。

npm start

npm install @apollo/client graphql

Apollo Clientの初期化

uriにはapiサーバーのエンドポイントを設定します。rest形式のapiだと情報を取得するための様々なエンドポイントが存在しますが、graphqlでは1つのエンドポイントで様々なクエリを投げることができます。。

index.tsx一部抜粋
const client = new ApolloClient({
  uri: "http://127.0.0.1:8000/graphql",
  cache: new InMemoryCache(),
});

root.render(
  <React.StrictMode>
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  </React.StrictMode>
);

corsの問題が発生

作業をしていく中でcorsの問題が発生すると思うのでgraphqlサーバー側でレスポンスヘッダーをいじる必要があります。以下の記事が非常に参考になります。
https://qiita.com/10mi8o/items/2221134f9001d8d107d6
この記事ではRest形式のapiサーバーが想定されているため、routeMiddlewareが使用されているので、
全ての通信で作成するミドルウェアが挟まるようにrouteMiddlewareではなく、Middlewareの部分に設定してあげる必要があります。

Reactからクエリを投げて、情報を取得する

では試しにクエリを投げていきたいと思います。クエリを投げるにはuseQueryを使用します。
useQueryの返り値であるオブジェクトから分割代入法によりloadingとerror、dataを取得します。
loadingがtrueの場合はデータを取得している最中なので、その場合は「Tweetを取得中です...」というメッセージを画面に表示するようにしています。メッセージもありですが、スピナーを表示すると一気に様になります。

App.tsx
import { useQuery } from "@apollo/client";
import { GET_ALL_TWEETS } from "./gql/crud";

interface DATA {
  __typename: string;
  id: string;
  content: string;
}
interface ARRAY_DATA {
  tweets: Array<DATA>;
}

function App() {
  const { loading, error, data } = useQuery<ARRAY_DATA>(GET_ALL_TWEETS);

  if (loading) return <>Tweet取得中です...</>;
  if (error) return <>Tweet取得に失敗しました...</>;

  console.log(data);

  return (
    <div className="App">
      {data?.tweets.map((tweet) => (
        <p key={tweet.id}>
          {tweet.id}:{tweet.content}
        </p>
      ))}
    </div>
  );
}

export default App;

useQueryの第一引数であるGET_ALL_TWEETSは別ファイルに定義したクエリ文のことで以下のように定義をしています。クエリを直接useQueryの引数に書くことはできますが、クエリだけを記述するファイルを作成し、定義することが多いです。

crud.ts
import { gql } from "@apollo/client";

export const GET_ALL_TWEETS = gql`
  query GetAllTweets {
    tweets {
      id
      content
    }
  }
`;

また今回はqueryを発行し、かつapollo clientの初期化の際にcache: new InMemoryCache()を設定したので、取得したデータがキャッシュとして保存されています。キャッシュとして保存しておくことにより、
ページアクセスするたびに、サーバーに問い合わせるのではなく、キャッシュの有無によってサーバーに問い合わせを行うのかを決めることが可能になります。
キャッシュを可視化するChromeの拡張機能があります。
キャッシュの情報を取得するには__typenameとidを組み合わせたものをkeyとして取得することが可能になります。
https://chrome.google.com/webstore/detail/apollo-client-devtools/jdkknkkbebbapilgoeccciglkfbmbnfm

create

ミューテーションの定義

まずミューテーションを以下のように定義します。$inputは変数で、入力した文字列を変数に入れ、ミューテーションを投げます。変数を使う場合はCreateTweetの部分は省略できません。変数使う使わない関係なく書く場合が多いです。

crud.ts
export const CREATE_TWEET = gql`
  mutation CreateTweet($input: String!) {
    createTweet(content: $input) {
      id
      content
    }
  }
`;

カスタムhooksの作成

useMutationの返り値のcreateTweetは実際にミューテーションを投げる関数です。このフックスではcreateTweet関数だけ返すようにしています。

useCreate.ts
import { useMutation } from "@apollo/client";
import React from "react";
import { CREATE_TWEET } from "../gql/crud";

export const useCreate = () => {
  const [createTweet, { data, loading, error }] = useMutation(CREATE_TWEET, {
    onCompleted(completed) {
      console.log("成功しました");
    },
    onError(error) {
      console.log(error.graphQLErrors);
    },
  });
  return { createTweet };
};

ミューテーションを投げる

Tweet!ボタンが押された時に、フックスで取得したcreateTweet関数を呼び出しています。
refetch関数を使うことにより再度サーバーにクエリを投げています。こうすることでサーバー側とキャッシュ側でのデータの差異を防ぐことができます。refetchするのではなく、既存のキャッシュを操作する方法もあります。

App.tsx
import { useQuery } from "@apollo/client";
import React from "react";
import { GET_ALL_TWEETS } from "./gql/crud";
import { useCreate } from "./hook/useCreate";

interface DATA {
  __typename: string;
  id: string;
  content: string;
}
interface ARRAY_DATA {
  tweets: Array<DATA>;
}

function App() {
  const { loading, error, data, refetch } =
    useQuery<ARRAY_DATA>(GET_ALL_TWEETS);
  const { createTweet } = useCreate();

  if (loading) return <>Tweet取得中です...</>;
  if (error) return <>Tweet取得に失敗しました...</>;

  //Create
  const clickCreateButton = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const tweet = document.querySelector("#tweet") as HTMLInputElement;
    createTweet({ variables: { input: tweet.value } });
    refetch();
    tweet.value = "";
  };

  return (
    <div className="App">
      <form name="submitTweetForm" onSubmit={(e) => clickCreateButton(e)}>
        <input type="text" id="tweet" />
        <button>Tweet!</button>
      </form>
      {data?.tweets.map((tweet) => (
        <p key={tweet.id}>
          {tweet.id}:{tweet.content}
        </p>
      ))}
    </div>
  );
}

export default App;

update

ミューテーションの定義

crud.ts
export const UPDATE_TWEET = gql`
  mutation UpdateTweet($id: ID!, $content: String!) {
    updateTweet(id: $id, content: $content) {
      id
      content
    }
  }
`;

カスタムhooksの作成

useUpdate.ts
import { useMutation } from "@apollo/client";
import React from "react";
import { UPDATE_TWEET } from "../gql/crud";

export const useUpdate = () => {
  const [updateTweet, { data, loading, error }] = useMutation(UPDATE_TWEET, {
    onCompleted(completed) {
      console.log("成功しました");
    },
    onError(error) {
      console.log(error.graphQLErrors);
    },
  });
  return { updateTweet };
};

ミューテーションを投げる

ポイントはCREATEの時のようにrefetchを行なっていない点です。既存の単一のアイテムを更新するミューテーションは自動でキャッシュを更新してくれます。
https://zenn.dev/kazu777/articles/b64935ea7d6fee#apollo-client-のキャッシュの仕組み

App.tsx
import { useQuery } from "@apollo/client";
import React from "react";
import { GET_ALL_TWEETS } from "./gql/crud";
import { useCreate } from "./hook/useCreate";
import { useUpdate } from "./hook/useUpdate";

interface DATA {
  __typename: string;
  id: string;
  content: string;
}
interface ARRAY_DATA {
  tweets: Array<DATA>;
}

function App() {
  const { loading, error, data, refetch } =
    useQuery<ARRAY_DATA>(GET_ALL_TWEETS);
  const { createTweet } = useCreate();
  const { updateTweet } = useUpdate();

  if (loading) return <>Tweet取得中です...</>;
  if (error) return <>Tweet取得に失敗しました...</>;

  //Create
  const clickCreateButton = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const tweet = document.querySelector("#tweet") as HTMLInputElement;
    createTweet({ variables: { input: tweet.value } });
    refetch();
    tweet.value = "";
  };

  //Update
  const clickUpdateButton = (
    e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
    id: string
  ) => {
    const tweet = document.querySelector(`#tweet${id}`) as HTMLInputElement;
    updateTweet({ variables: { id, content: tweet.value } });
  };

  return (
    <div className="App">
      <form name="submitTweetForm" onSubmit={(e) => clickCreateButton(e)}>
        <input type="text" id="tweet" />
        <button>Tweet!</button>
      </form>
      {data?.tweets.map((tweet) => (
        <div key={tweet.id}>
          <p>{tweet.id}</p>
          <textarea id={`tweet${tweet.id}`}>{tweet.content}</textarea>
          <button onClick={(e) => clickUpdateButton(e, tweet.id)}>
            update!!!
          </button>
        </div>
      ))}
    </div>
  );
}

export default App;

delete

ミューテーションの定義

crud.ts
export const DELETE_TWEET = gql`
  mutation DeleteTweet($id: ID!) {
    deleteTweet(id: $id) {
      id
      content
    }
  }
`;

カスタムhooksの作成

useDelete.ts
import { useMutation } from "@apollo/client";
import React from "react";
import { DELETE_TWEET } from "../gql/crud";

export const useDelete = () => {
  const [deleteTweet, { data, loading, error }] = useMutation(DELETE_TWEET, {
    onCompleted(completed) {
      console.log("成功しました");
    },
    onError(error) {
      console.log(error.graphQLErrors);
    },
  });
  return { deleteTweet };
};

ミューテーションを投げる

App.ts
import { useQuery } from "@apollo/client";
import React from "react";
import { GET_ALL_TWEETS } from "./gql/crud";
import { useCreate } from "./hook/useCreate";
import { useUpdate } from "./hook/useUpdate";
import { useDelete } from "./hook/useDelete";

interface DATA {
  __typename: string;
  id: string;
  content: string;
}
interface ARRAY_DATA {
  tweets: Array<DATA>;
}

function App() {
  const { loading, error, data, refetch } =
    useQuery<ARRAY_DATA>(GET_ALL_TWEETS);
  const { createTweet } = useCreate();
  const { updateTweet } = useUpdate();
  const { deleteTweet } = useDelete();

  if (loading) return <>Tweet取得中です...</>;
  if (error) return <>Tweet取得に失敗しました...</>;

  //Create
  const clickCreateButton = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const tweet = document.querySelector("#tweet") as HTMLInputElement;
    createTweet({ variables: { input: tweet.value } });
    refetch();
    tweet.value = "";
  };

  //Update
  const clickUpdateButton = (id: string) => {
    const tweet = document.querySelector(`#tweet${id}`) as HTMLInputElement;
    updateTweet({ variables: { id, content: tweet.value } });
  };

  //Delete
  const clickDeleteButton = (id: string) => {
    deleteTweet({ variables: { id } });
    refetch();
  };

  return (
    <div className="App">
      <form name="submitTweetForm" onSubmit={(e) => clickCreateButton(e)}>
        <input type="text" id="tweet" />
        <button>Tweet!</button>
      </form>
      {data?.tweets.map((tweet) => (
        <div key={tweet.id}>
          <p>{tweet.id}</p>
          <textarea id={`tweet${tweet.id}`}>{tweet.content}</textarea>
          <br />
          <button onClick={() => clickUpdateButton(tweet.id)}>update!!!</button>
          <button onClick={() => clickDeleteButton(tweet.id)}>delete!!!</button>
        </div>
      ))}
    </div>
  );
}

export default App;

これで一応完成となります!

さいごに

次回はMaterial UIを使っていい感じに見栄えを良くしていきたいと思います。自分自身フロントエンドやGraphQLに関して初心者なので、コメントいただけたらありがたいです。

https://mui.com/

Discussion