AWS AmplifyのReact向けチュートリアルをTypeScript化しながら体験してみた
はじめに
今回は数あるAWSサービスの中でもフロントエンドエンジニアが扱うにとても便利なサービスであるAmplifyのチュートリアルを試してみるついでにTypeScript化、Hooks部分をRedux化しながら進めてみます。
AWS Amplifyとは
今回は数あるサービスの中でもフロントエンドエンジニアが扱うにとても便利なサービスであるAmplifyのチュートリアルを試してみます。AWS Amplifyは、ひとつの機能を提供しているサービスというわけではなく、Webアプリケーションやモバイルアプリケーションに必要なサービスを複合的に構築するためのプラットフォームのようなものになります。APIや認証、CDN、ホスティングなどなどコマンドやWebコンソールから簡単に機能追加することができるおそろしく便利なサービスです。
GitHubと連携することでCI/CDまで対応しています。
フロントエンドエンジニアとしてはAmplifyとその付随するサービスを使えれば生きていけるのでは!?くらいのサービスなのではと個人的には感じています。
そんなAmplifyにも他のサービスと同じ用にいろいろな言語やフレームワークのチュートリアルがあるのですが、今回はReactのチュートリアルをTypeScriptに置き換えながら勉強してみました。
チュートリアル
TypeScriptに置き換えるといってもほとんどはチュートリアルのまま進めていくことであっという間に認証機能付きTodoアプリが出来上がってしまいます🙈
TypeScriptと後ほどHooks部分をReduxに置き換えたかったので、プロジェクトのテンプレートにredux-typescript
を利用しました。
$ yarn create react-app project-name --template redux-typescript
他にチュートリアルの項目と若干異なるのはこの部分くらいでしょうか。
Create a GraphQL API and database
? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target typescript // ここでTypeScriptを選ぶことで以後の質問が変わる
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.ts
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
? Enter the file name for the generated code src/API.ts
Add authenticationまで反映した際のソースは下記のようになりました。
Amplifyの設定とたったこれだけでログイン認証からGraphQLとの連携までできてしまうのが恐ろしいところ。。。
import React, { useEffect, useState } from 'react';
import Amplify, { API, graphqlOperation } from 'aws-amplify';
import { withAuthenticator } from '@aws-amplify/ui-react';
import { createTodo } from 'src/graphql/mutations';
import { listTodos } from 'src/graphql/queries';
import { ListTodosQuery, CreateTodoInput } from 'src/API';
import awsExports from 'src/aws-exports';
import { GraphQLResult } from '@aws-amplify/api';
Amplify.configure(awsExports);
const initialState = { name: '', description: '' };
const App: React.VFC = () => {
const [formState, setFormState] = useState(initialState);
const [todos, setTodos] = useState<CreateTodoInput[]>([]);
useEffect(() => {
fetchTodos();
}, []);
const setInput = (key: string, value: string) => {
setFormState({ ...formState, [key]: value });
};
const fetchTodos = async () => {
try {
const todoData = (await API.graphql(
graphqlOperation(listTodos),
)) as GraphQLResult<ListTodosQuery>;
if (todoData.data?.listTodos?.items) {
const todos = todoData.data.listTodos.items as CreateTodoInput[];
setTodos(todos);
}
} catch (err) {
console.log('error fetching todos');
}
};
const addTodo = async () => {
try {
if (!formState.name || !formState.description) return;
const todo: CreateTodoInput = { ...formState };
setTodos([...todos, todo]);
setFormState(initialState);
(await API.graphql(
graphqlOperation(createTodo, { input: todo }),
)) as GraphQLResult<CreateTodoInput>;
} catch (err) {
console.log('error creating todo:', err);
}
};
return (
<div style={styles.container}>
<h2>Amplify Todos</h2>
<input
onChange={(event) => setInput('name', event.target.value)}
style={styles.input}
value={formState.name}
placeholder="Name"
/>
<input
onChange={(event) => setInput('description', event.target.value)}
style={styles.input}
value={formState.description}
placeholder="Description"
/>
<button style={styles.button} onClick={addTodo}>
Create Todo
</button>
{todos.map((todo, index) => (
<div key={todo.id ? todo.id : index} style={styles.todo}>
<p style={styles.todoName}>{todo.name}</p>
<p style={styles.todoDesctiption}>{todo.description}</p>
</div>
))}
</div>
);
};
const styles: {
[key: string]: React.CSSProperties;
} = {
container: {
width: 400,
margin: '0 auto',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: 20,
},
todo: { marginBottom: 15 },
input: { border: 'none', backgroundColor: '#ddd', marginBottom: 10, padding: 8, fontSize: 18 },
todoName: { fontSize: 20, fontWeight: 'bold' },
todoDescription: { marginBottom: 0 },
button: {
backgroundColor: 'black',
color: 'white',
outline: 'none',
fontSize: 18,
padding: '12px 0px',
},
};
export default withAuthenticator(App);
HooksをRedux Toolkitに置き換えてみる
次にReduxの学習を兼ねてHooksでの状態管理部分をRedux Toolkitへ置き換えてみました。
編集ファイル以外はredux-typescript
テンプレートのままになります。
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
import todoReducer from 'src/features/todo/todoSlice';
export const store = configureStore({
reducer: {
todo: todoReducer,
},
});
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action<string>
>;
// チュートリアルでApp.tsxに記載したAPI接続部分のロジックを別ファイルに分離します
import Amplify, { API, graphqlOperation } from 'aws-amplify';
import { createTodo } from 'src/graphql/mutations';
import { listTodos } from 'src/graphql/queries';
import { ListTodosQuery, CreateTodoInput } from 'src/API';
import awsExports from 'src/aws-exports';
import { GraphQLResult } from '@aws-amplify/api';
Amplify.configure(awsExports);
export const fetchTodos = async () => {
let todos;
try {
const todoData = (await API.graphql(
graphqlOperation(listTodos),
)) as GraphQLResult<ListTodosQuery>;
if (todoData.data?.listTodos?.items) {
todos = todoData.data.listTodos.items as CreateTodoInput[];
}
} catch (err) {
console.log('error fetching todos');
}
return todos;
};
export const addTodo = async (formState: { name: string; description: string }) => {
try {
if (!formState.name || !formState.description) return;
const todo: CreateTodoInput = { ...formState };
(await API.graphql(
graphqlOperation(createTodo, { input: todo }),
)) as GraphQLResult<CreateTodoInput>;
} catch (err) {
console.log('error creating todo:', err);
}
};
redux-typescript
テンプレートのサンプルにあるCounterSlice
をベースに書き換えていきます。
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { RootState } from 'src/app/store';
import { CreateTodoInput } from 'src/API';
import { addTodo, fetchTodos } from './todoAPI';
export type todosState = {
todos: CreateTodoInput[];
status: 'idle' | 'loading' | 'failed';
};
const initialState: todosState = {
todos: [],
status: 'idle',
};
/**
* Todo一覧の取得
*/
export const fetchAsyncTodos = createAsyncThunk('todo/fetchAsyncTodos', async (_, thunkApi) => {
const todos = await fetchTodos().catch((error) => {
throw error;
});
if (todos) {
return todos;
} else {
return initialState.todos;
}
});
/**
* Todoの追加
*/
export const createAsyncTodo = createAsyncThunk(
'todo/createAsyncTodo',
async (formState: { name: string; description: string }) => {
await addTodo(formState).catch((error) => {
throw error;
});
return formState;
},
);
export const todoSlice = createSlice({
name: 'todo',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchAsyncTodos.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchAsyncTodos.fulfilled, (state, action) => {
state.status = 'idle';
const data = action.payload.map((post) => {
return { name: post?.name, description: post.description };
});
state.todos = data;
})
.addCase(createAsyncTodo.pending, (state) => {
state.status = 'loading';
})
.addCase(createAsyncTodo.fulfilled, (state, action) => {
state.status = 'idle';
state.todos.push(action.payload);
});
},
});
export const selectTodos = (state: RootState) => state.todo.todos;
export default todoSlice.reducer;
App.tsxはかなりすっきりです。
import React, { useEffect, useState } from 'react';
import { withAuthenticator } from '@aws-amplify/ui-react';
import { useAppDispatch, useAppSelector } from 'src/app/hooks';
import { selectTodos, fetchAsyncTodos, createAsyncTodo } from './features/todo/todoSlice';
const initialState = { name: '', description: '' };
const App: React.VFC = () => {
const [formState, setFormState] = useState(initialState);
const todos = useAppSelector(selectTodos);
const dispatch = useAppDispatch();
useEffect(() => {
dispatch(fetchAsyncTodos());
}, [dispatch]);
const setInput = (key: string, value: string) => {
setFormState({ ...formState, [key]: value });
};
return (
<div style={styles.container}>
<h2>Amplify Todos</h2>
<input
onChange={(event) => setInput('name', event.target.value)}
style={styles.input}
value={formState.name}
placeholder="Name"
/>
<input
onChange={(event) => setInput('description', event.target.value)}
style={styles.input}
value={formState.description}
placeholder="Description"
/>
<button
style={styles.button}
onClick={() => {
dispatch(createAsyncTodo(formState));
setFormState(initialState);
}}
>
Create Todo
</button>
{todos.map((todo, index) => (
<div key={todo.id ? todo.id : index} style={styles.todo}>
<p style={styles.todoName}>{todo.name}</p>
<p style={styles.todoDescription}>{todo.description}</p>
</div>
))}
</div>
);
};
const styles: {
[key: string]: React.CSSProperties;
} = {
container: {
width: 400,
margin: '0 auto',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: 20,
},
todo: { marginBottom: 15 },
input: { border: 'none', backgroundColor: '#ddd', marginBottom: 10, padding: 8, fontSize: 18 },
todoName: { fontSize: 20, fontWeight: 'bold' },
todoDescription: { marginBottom: 0 },
button: {
backgroundColor: 'black',
color: 'white',
outline: 'none',
fontSize: 18,
padding: '12px 0px',
},
};
export default withAuthenticator(App);
苦労した点
チュートリアルはJavaScriptで記載されているため、TypeScriptに変更するには各所で型をしていく必要があります。まずはチュートリアル通りJavaScriptで写経して、VS Codeでエラーの箇所を少しずつ修正していきました。とくにGraphQLの型指定がなかなかうまくはまらず、いろいろなページを調べながら解決、もしくはas
多様で乗り切っています。。。
さいごに
AWS Amplifyのチュートリアルを体験するだけでも、Amplifyの便利さを体験できると思います。ぜひ試してみてください。
参考にさせていただいたサイト
Discussion