🙊

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

12 min read

はじめに

今回は数ある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多様で乗り切っています。。。

また、チュートリアルにあるconst stylesの部分についてもエラーを回避するためにインデックスシグネチャを利用していますが、型安全ではないということのようなので別の記載にしたほうがよいのかもしれません。styleくらいなら大丈夫???

さいごに

AWS Amplifyのチュートリアルを体験するだけでも、Amplifyの便利さを体験できると思います。ぜひ試してみてください。

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

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

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

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

Discussion

ログインするとコメントできます