GraphQL を使った ToDo アプリ - フロントエンド編

May 04, 2023

はじめに

本エントリは GraphQL を使った ToDo アプリ - バックエンド編 の続きとなります。今回は React を使ってフロントエンドの実装を行い、Web アプリから API を叩いてみたいと思います。

環境構築

実際の開発に移る前にまずは環境を構築します。なお、aws-northstar が現時点で最新の React 18.2.0 に対応していなかったため、create-react-app では作成せず、雛形は手動で作ってます。

それでは、まず必要なモジュール等をまずはインストールします。

$ cd frontend
$ npm init -y
$ npm install --dev @babel/core babel-loader @babel/cli @babel/preset-env @babel/preset-react
$ npm install -D webpack webpack-cli webpack-dev-server html-webpack-plugin
$ npm install aws-northstar
$ npm install react react-dom
$ npm install @apollo/client graphql

次に必要なディレクトリ及びファイルを作成します。

$ mkdir public src
$ mkdir src/graphql
$ touch babel.config.json webpack.config.js index.js public/index.html src/App.js

それぞれのファイルを以下のように編集します。

public/index.html
<html lang='ja'>
  <head>
    <meta charset='UTF-8'>
    <title>React app</title>
  </head>
  <body>
    <div id='root'></div>
  </body>
</html>
src/App.js
import React from 'react';
import Header from 'aws-northstar/components/Header';

const App = () => {
  return (
    <Header 
      title="GraphQL ToDo App"
    />
  );
};

export default App;
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './src/App.js';
import NorthStarThemeProvider from 'aws-northstar/components/NorthStarThemeProvider';

ReactDOM.render(
  <React.StrictMode>
    <NorthStarThemeProvider>
      <App />
    </NorthStarThemeProvider>
  </React.StrictMode>,
  document.getElementById('root')
);
babel.config.json
{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react"
  ]
}
webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');

module.exports = {
  entry: './index.js',
  mode: 'development',
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'index_bundle.js',
  },
  target: 'web',
  devServer: {
    port: '3000',
    static: {
      directory: path.join(__dirname, 'public'),
    },
    open: true,
    hot: true,
    liveReload: true,
  },
  resolve: {
    extensions: ['.js', '.jsx', '.json'],
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/,
        exclude: /node_modules/,
        use: 'babel-loader',
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.join(__dirname, 'public', 'index.html'),
    }),
  ],
};

また、package.json に以下を追記します。

package.json
  "scripts": {
    "start": "webpack-dev-server .",
    "build": "webpack ."    
  },

で、npm start して以下の画面が表示されれば OK です。

$ npm start

f:id:shiro_kochi:2018××××××××:plain:w100:left

フロントエンド開発

それでは雛形もできたことですし、実際に UI 部分を作っていきます。まずは index.js を以下のように編集し、ApolloClient を利用できるようにします。

index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './src/App.js';
import NorthStarThemeProvider from 'aws-northstar/components/NorthStarThemeProvider';
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";

const cache = new InMemoryCache();
const client = new ApolloClient({
  cache: cache,
  uri: "http://localhost:4000/graphql"
});

ReactDOM.render(
  <React.StrictMode>
    <ApolloProvider client={client}>
      <NorthStarThemeProvider>
        <App />
      </NorthStarThemeProvider>
    </ApolloProvider>
  </React.StrictMode>,
  document.getElementById('root')
);

次に、フロントから叩くクエリを src/graphql 配下、具体的には Query.js 及び Mutation.js にまとめておきます。

src/graphql/Query.js
import { gql } from "@apollo/client";

export const LIST_TODOS = gql`
  query {
    listTodos {
      id
      title
      description
      createDate
    }
  }
`;
src/graphql/Mutaton.js
import { gql } from "@apollo/client";

export const CREATE_TODO = gql`
  mutation createTodo($title: String!, $description: String!, $createDate: String!) {
    createTodo(
      data: {
        title: $title
        description: $description
        createDate: $createDate
      }
    ) {
      id
      title
      description
      createDate
    }
  }
`;

export const UPDATE_TODO = gql`
  mutation updateTodo($id: ID!, $title: String!, $description: String!, $createDate: String!) {
    updateTodo(id: $id, data: {
      title: $title
      description: $description
      createDate: $createDate
    }) {
      id
      title
      description
      createDate
    }
  }
`;

export const DELETE_TODO = gql`
  mutation deleteTodo($id: ID!) {
    deleteTodo(id: $id) {
      id
      title
      description
      createDate
    }
  }
`;

あとは上記をよしなに叩くだけです。@apollo/client に含まれる useQueryuseMutation を使ってクエリを実行、それをボタン等のコンポーネントに紐づくアクションから実行すれば良いです。

src/App.js
import React, { useEffect, useState } from "react";
import { useMutation, useQuery } from "@apollo/client";
import { LIST_TODOS } from "./graphql/Query";
import { CREATE_TODO, UPDATE_TODO, DELETE_TODO } from "./graphql/Mutation";
import Button from "aws-northstar/components/Button";
import Container from "aws-northstar/layouts/Container";
import Form from "aws-northstar/components/Form";
import FormField from "aws-northstar/components/FormField";
import Header from "aws-northstar/components/Header";
import Input from "aws-northstar/components/Input";
import Modal from "aws-northstar/components/Modal";
import Textarea from "aws-northstar/components/Textarea";


const UpdateModal = ({todo, handleUpdate, visible, setVisible}) => {
  const [title, setTitle] = useState(todo["title"]);
  const [desc, setDesc] = useState(todo["description"]);
  const handleTitleChange = (e) => setTitle(e);
  const handleDescChange = (e) => setDesc(e.target.value);

  return(
    <div>
      <Modal title="Update ToDo" visible={visible} onClose={() => setVisible(false)}>
        <Form
          actions={
            <div>
              <Button variant="link" onClick={() => setVisible(false)}>Cancel</Button>
              <Button variant="primary" onClick={() => handleUpdate(todo["id"], title, desc, todo["createDate"])}>Submit</Button>
            </div>
          }
        >
          <FormField label="Title" hintText="Input your updated title.">
            <Input type="text" value={title} onChange={(e) => handleTitleChange(e)}/>
          </FormField>
          <FormField label="Description" hintText="Input your updated description.">
            <Textarea value={desc} onChange={(e) => handleDescChange(e)}/>
          </FormField>
        </Form>
      </Modal>
    </div>
  )
};

const ToDo = ({todo, visible, setVisible, handleUpdate, handleDelete}) => {
  return(
    <div>
      <Container
        headingVariant='h4'
        title={todo["title"]}
        subtitle={todo["createDate"]}
        actionGroup={<div>
          <Button variant='link' onClick={(id) => handleDelete(todo["id"])}>Delete</Button>
          <Button variant='primary' onClick={() => setVisible(true)}>Update</Button>
        </div>}
      >
        {todo["description"]}
      </Container>
      <UpdateModal todo={todo} handleUpdate={handleUpdate} visible={visible} setVisible={setVisible}/>
    </div>
  )
};

const App = () => {
  const { data } = useQuery(LIST_TODOS);
  const [createTodo] = useMutation(CREATE_TODO);
  const [updateTodo] = useMutation(UPDATE_TODO);
  const [deleteTodo] = useMutation(DELETE_TODO);
  const [title, setTitle] = useState("");
  const [desc, setDesc] = useState("");
  const [visible, setVisible] = useState(false);
  const [todos, setTodos] = useState([]);
  useEffect(() => {
    setTodos(data?.listTodos);
  }, [data]);

  const handleTitleChange = (e) => setTitle(e);
  const handleDescChange = (e) => setDesc(e.target.value);
  
  const handleCreate = async (title, description) => {
    const today = new Date();
    const year = today.getFullYear();
    const month = (today.getMonth() + 1) < 10 ? "0" + (today.getMonth() + 1) : (today.getMonth() + 1);
    const day = today.getDate() < 10 ? "0" + today.getDate() : today.getDate();
    const createDate =  year + "-" + month + "-" + day;
    const { data: createResponse } = await createTodo({
      variables: {
        title: title,
        description: description,
        createDate: createDate
      }
    });
    if (createResponse?.createTodo) {
      const _todo = createResponse?.createTodo;
      if (!todos.some((todo) => todo.id === _todo.id)) {
        console.log("Creating ToDo");
        let _todos = todos.slice();
        _todos.push(_todo);
        setTitle("");
        setDesc("");
        setTodos(_todos);
      }
    }
  };
  const handleUpdate = async (id, title, description, createDate) => {
    console.log(id + " " + title + " " + description + " " + createDate);
    const { data: updateResponse } = await updateTodo({
      variables: {
        id: id,
        title: title,
        description: description,
        createDate: createDate
      }
    });
    if (updateResponse?.updateTodo) {
      console.log(`Updated ${id}`);
      const index = todos.findIndex((e) => e.id === id);
      let _todos = todos.slice();
      _todos[index] = { id: id, title: title, description: description, createDate: createDate};
      setTodos(_todos);
      setVisible(false);
    }
  };
  const handleDelete = async (id) => {
    const { data: deleteResponse } = await deleteTodo({
      variables: { id: id }
    });
    if (deleteResponse?.deleteTodo) {
      console.log(`Deleted ${id}`);
      const index = todos.findIndex((e) => e.id === id);
      let _todos = todos.slice();
      _todos.splice(index, 1);
      setTodos(_todos);
      setVisible(false);
    }
  };
  
  return (
    <div>
      <Header 
        title="GraphQL ToDo App"
      />
      <Container title="Create your ToDo">
      <Form
          actions={
            <div>
              <Button variant="primary" onClick={() => handleCreate(title, desc)}>Submit</Button>
            </div>
          }
        >
          <FormField label="Title" hintText="Input a title for the new ToDo.">
            <Input type="text" value={title} onChange={(e) => handleTitleChange(e)}/>
          </FormField>
          <FormField label="Description" hintText="Input description for the new ToDo.">
            <Textarea value={desc} onChange={(e) => handleDescChange(e)}/>
          </FormField>
      </Form>
      </Container>
      <Container title="ToDo list">
        { todos != undefined ? todos.map((todo) => (
            <ToDo 
              todo={todo} 
              visible={visible}
              setVisible={setVisible}
              handleUpdate={handleUpdate}
              handleDelete={handleDelete}
            />)) 
          : <div></div>
        }
      </Container>
    </div>
  );
};

export default App;

完成形

具体的に作成した ToDo アプリは以下のようなものになります。実際に CRUD 操作がフロントアプリから可能なことも確認できました。

f:id:shiro_kochi:2018××××××××:plain:w100:left

おわりに

如何でしたでしょうか。GraphQL を導入することで、フロント側はかなりスッキリ書けるようになる印象を受けました。バックエンド側は依然としてリゾルバの定義があったり、また学習コストも考えるとそこまで恩恵を受けられないかもしれませんが、GraphQL もオプションとして考慮しても良いかもしれない、と思いました。


 © 2023, Dealing with Ambiguity