はじめに
本エントリは 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
それぞれのファイルを以下のように編集します。
<html lang='ja'>
<head>
<meta charset='UTF-8'>
<title>React app</title>
</head>
<body>
<div id='root'></div>
</body>
</html>
import React from 'react';
import Header from 'aws-northstar/components/Header';
const App = () => {
return (
<Header
title="GraphQL ToDo App"
/>
);
};
export default App;
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')
);
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}
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
に以下を追記します。
"scripts": {
"start": "webpack-dev-server .",
"build": "webpack ."
},
で、npm start
して以下の画面が表示されれば OK です。
$ npm start
フロントエンド開発
それでは雛形もできたことですし、実際に UI 部分を作っていきます。まずは index.js
を以下のように編集し、ApolloClient を利用できるようにします。
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
にまとめておきます。
import { gql } from "@apollo/client";
export const LIST_TODOS = gql`
query {
listTodos {
id
title
description
createDate
}
}
`;
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
に含まれる useQuery
と useMutation
を使ってクエリを実行、それをボタン等のコンポーネントに紐づくアクションから実行すれば良いです。
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 操作がフロントアプリから可能なことも確認できました。
おわりに
如何でしたでしょうか。GraphQL を導入することで、フロント側はかなりスッキリ書けるようになる印象を受けました。バックエンド側は依然としてリゾルバの定義があったり、また学習コストも考えるとそこまで恩恵を受けられないかもしれませんが、GraphQL もオプションとして考慮しても良いかもしれない、と思いました。