🙊

AWS AmplifyのReact向けチュートリアルをTypeScript化しながら体験してみた

2021/06/25に公開

はじめに

今回は数あるAWSサービスの中でもフロントエンドエンジニアが扱うにとても便利なサービスであるAmplifyのチュートリアルを試してみるついでにTypeScript化、Hooks部分をRedux化しながら進めてみます。

AWS Amplifyとは

今回は数あるサービスの中でもフロントエンドエンジニアが扱うにとても便利なサービスであるAmplifyのチュートリアルを試してみます。AWS Amplifyは、ひとつの機能を提供しているサービスというわけではなく、Webアプリケーションやモバイルアプリケーションに必要なサービスを複合的に構築するためのプラットフォームのようなものになります。APIや認証、CDN、ホスティングなどなどコマンドやWebコンソールから簡単に機能追加することができるおそろしく便利なサービスです。

GitHubと連携することでCI/CDまで対応しています。

フロントエンドエンジニアとしてはAmplifyとその付随するサービスを使えれば生きていけるのでは!?くらいのサービスなのではと個人的には感じています。

そんなAmplifyにも他のサービスと同じ用にいろいろな言語やフレームワークのチュートリアルがあるのですが、今回はReactのチュートリアルをTypeScriptに置き換えながら勉強してみました。

https://docs.amplify.aws/start/q/integration/react

チュートリアル

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との連携までできてしまうのが恐ろしいところ。。。

src/App.tsx
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テンプレートのままになります。

src/app/store.ts
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>
>;
src/features/todo/todoAPI.ts
// チュートリアルで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をベースに書き換えていきます。

src/features/todo/todoSlice.ts
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はかなりすっきりです。

src/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の便利さを体験できると思います。ぜひ試してみてください。

参考にさせていただいたサイト

https://zenn.dev/ynakamura/articles/210460b470f7b973e5cc

https://qiita.com/otanu/items/2c522a652e5843a5e2c5

https://qiita.com/aakasaka/items/0b081c90b1b99b82143c

GitHubで編集を提案

Discussion