🌊

React + GraphQL + Apolloを触ってみる ~ 4. ToDoの追加・編集・削除まで ~

2023/03/09に公開

前回からの続き

ToDoの追加、編集、削除機能を実装する

目次

  1. GraphQLの環境構築まで
  2. ToDoリストの読み込みまで
  3. Firebaseの準備とGraphQLとの連携部分まで
  4. ToDoの追加・編集・削除まで

サーバ側

GraphQLで編集を行う場合、QueryではなくMutationを利用するため、Mutation用のファイルを新たに作成する

src/resolvers/mutations.js
import { collection, doc, addDoc, setDoc, deleteDoc } from 'firebase/firestore/lite'
import { db } from '../utils/firebase.js'

export const Mutation = {
  addTodo: async (parent, args, context, info) => {
    const { text } = args
    await addDoc(collection(db, 'todos'), { text, isCompleted: false, })
    return true
  },
  updateTodo: async(parent, args, context, info) => {
    const { id, text, isCompleted } = args
    await setDoc(doc(db, 'todos', id), { text, isCompleted })
    return true
  },
  deleteTodo: async(parent, args, context, info) => {
    const { id } = args
    await deleteDoc(doc(db, 'todos', id))
    return true
  }
}

上記のコードはすでにFirebaseへの操作も実装済み
昔よりFirebaseシンプルに書けるようになったなー

あと今回はシンプルに実装するためにエラー処理は省略してる

resolversへの追加とスキーマの追加も忘れずに

src/resolvers.js
import { Query } from './resolvers/query.js'
+ import { Mutation } from './resolvers/mutations.js'

- const resolvers = { Query }
+ const resolvers = { Query, Mutation }
export default resolvers
src/schema.js
const typeDefs = `#graphql
  type Todo {
    id: ID!,
    text: String,
    isCompleted: Boolean,
  }
  type Query {
    getTodos: [Todo],
  }
+   type Mutation {
+     addTodo(text: String): Boolean
+     updateTodo(id: ID!, text: String, isCompleted: Boolean): Boolean
+     deleteTodo(id: ID!): Boolean
+   }
`

export default typeDefs

フロント側

ToDoの追加、編集、削除を呼び出すよう修正
これは完全な好みだけど、追加とか編集とかするならContextにまとめたい欲があるので、取得部分も合わせて全部Contextに押し込むようにする

取得部分をContext化

まずは取得部分のコードをContextで作成

src/contexts/todos.tsx
import { useQuery } from '@apollo/client'
import { createContext, useState, useEffect } from 'react'
import { TODOS_QUERY } from '@/queries/todos'

const TodosContext = createContext()

const TodosProvider = ({ children }) => {
  const [todos, setTodos] = useState([])
  const { loading, error, data } = useQuery(TODOS_QUERY)

  useEffect(() => {
    if (!!data && !!data.getTodos) {
      setTodos(data.getTodos)
    }
  }, [data])

  return <TodosContext.Provider value={{ todos, loading, error }}>{children}</TodosContext.Provider>
}

export { TodosContext, TodosProvider }

Providerでラップするのを忘れずに

src/pages/_app.tsx
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'
import { AppProps } from 'next/app'
import { createGlobalStyle } from 'styled-components'
import reset from 'styled-reset'
+ import { TodosProvider } from '@/contexts/todos'

const cache = new InMemoryCache()
const client = new ApolloClient({
  uri: 'http://localhost:4001',
  cache,
})

const GlobalStyle = createGlobalStyle`
  & {
    ${reset}

    * {
      box-sizing: border-box;
    }
  }
`

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={client}>
+     <TodosProvider>
        <GlobalStyle />
        <Component {...pageProps} />
+     </TodosProvider>
    </ApolloProvider>
  )
}

export default MyApp

取得部分をContextを使うよう修正

src/pages/index.tsx
import { NextPage } from 'next'
- import { useQuery } from '@apollo/client'
- import { TODOS_QUERY } from '@/queries/todos'
+ import { useContext } from 'react'
+ import { TodosContext } from '@/contexts/todos'

interface IndexPageProps {}

const IndexPage: NextPage<IndexPageProps> = (props: IndexPageProps) => {
-   const { loading, error, data } = useQuery(TODOS_QUERY)
+   const { loading, error, todos } = useContext(TodosContext)
  if (loading) {
    return <div>loading..</div>
  }

  if (error) {
    return <div>error: {error.message}</div>
  }

-   if (!data.getTodos) {
+   if (!todos) {
    return null
  }

  return (
    <ul>
-       {data.getTodos.map(({ id, text, isCompleted }) => (
+       {todos.map(({ id, text, isCompleted }) => (
        <li key={id}>
          <div>id: {id}</div>
          <div>text: {text}</div>
          <div>isCompleted: {isCompleted ? 'true' : 'false'}</div>
        </li>
      ))}
    </ul>
  )
}
export default IndexPage

追加、編集、削除機能の実装

Mutationの作成

src/mutations/todos.ts
import { gql } from '@apollo/client'

export const ADD_TODO_MUTATION = gql`
  mutation addTodo($text: String!) {
    addTodo(text: $text)
  }
`

export const UPDATE_TODO_MUTATION = gql`
  mutation updateTodo($id: ID!, $text: String, $isCompleted: Boolean) {
    updateTodo(id: $id, text: $text, isCompleted: $isCompleted)
  }
`

export const DELETE_TODO_MUTATION = gql`
  mutation deleteTodo($id: ID!) {
    deleteTodo(id: $id)
  }
`

Contextの修正

diffにするのが面倒だったので修正後のコードを載せとく

src/contexts/todos.tsx
import { useQuery, useMutation } from '@apollo/client'
import { createContext, useState, useCallback, useEffect } from 'react'
import { TODOS_QUERY } from '../queries/todos'
import { ADD_TODO_MUTATION, UPDATE_TODO_MUTATION, DELETE_TODO_MUTATION } from '../mutations/todos'

const TodosContext = createContext()

const TodosProvider = ({ children }) => {
  const [todos, setTodos] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState({})

  const { loading: getLoading, error: getError, data } = useQuery(TODOS_QUERY)

  const [_addTodo, { loading: addLoading, error: addError }] = useMutation(ADD_TODO_MUTATION, {
    refetchQueries: ['getTodos'],
  })

  const [_updateTodo, { loading: updateLoading, error: updateError }] = useMutation(UPDATE_TODO_MUTATION, {
    refetchQueries: ['getTodos'],
  })

  const [_deleteTodo, { loading: deleteLoading, error: deleteError }] = useMutation(DELETE_TODO_MUTATION, {
    refetchQueries: ['getTodos'],
  })

  const addTodo = useCallback(
    ({ text }) => {
      _addTodo({ variables: { text } })
    },
    [todos],
  )

  const updateTodo = useCallback(
    ({ id, text, isCompleted }) => {
      _updateTodo({ variables: { id, text, isCompleted } })
    },
    [todos],
  )

  const deleteTodo = useCallback(
    ({ id }) => {
      _deleteTodo({ variables: { id } })
    },
    [todos],
  )

  useEffect(() => {
    if (!!data && !!data.getTodos) {
      setTodos(data.getTodos)
    }
  }, [data])

  useEffect(() => {
    setLoading(getLoading || addLoading || updateLoading || deleteLoading)
    setError({ getError, addError, updateError, deleteError })
  }, [getLoading, addLoading, updateLoading, deleteLoading, getError, addError, updateError, deleteError])

  return (
    <TodosContext.Provider value={{ todos, loading, error, addTodo, updateTodo, deleteTodo }}>
      {children}
    </TodosContext.Provider>
  )
}

export { TodosContext, TodosProvider }

useMutationを使ってadd, update, deleteメソッドを実装

ポイントは refetchQueries オプションを使って、更新後に再度取得するようにしている
これにより、更新があった場合に即座に一覧部分に反映されるようになる

useCallbackで各メソッドを再定義しているが、これはuseMutationで作成したメソッドを呼び出す際に変数をvariablesオブジェクトで囲う必要があったので、毎回呼び出し先で囲う必要が無いようにしているだけで必須ではない

コンポーネントからの呼び出し

これも長くなったので修正後のコード
実際に使う際はコンポーネント分けよう(面倒だった

Contextを使うことで、コンポーネントが分かれていても更新後の一覧への自動反映もちゃんと動作するので良き

import { NextPage } from 'next'
import { useState, useContext } from 'react'
import { TodosContext } from '@/contexts/todos'

interface IndexPageProps {}

const IndexPage: NextPage<IndexPageProps> = (props: IndexPageProps) => {
  const [text, setText] = useState('')
  const { todos, loading, error, addTodo, updateTodo, deleteTodo } = useContext(TodosContext)
  if (loading) {
    return <div>loading..</div>
  }

  if (error.getError) {
    return <div>error: {(error.getError || {}).message}</div>
  }

  if (!todos) {
    return null
  }

  return (
    <div>
      <input type="text" value={text} onChange={e => setText(e.target.value)} />
      <button onClick={(e) => {
        e.preventDefault()
        if (text.length < 0) return
        addTodo({ text })
        setText('')
      }}>Add</button>
      <hr />
      <table>
        <tbody>
          {todos.map(({ id, text, isCompleted }) => (
            <tr key={id}>
              <td>
                <input type="checkbox" checked={isCompleted} onChange={(e) => {
                  e.preventDefault()
                  updateTodo({ id, text, isCompleted: !isCompleted })
                }} />
              </td>
              <td>{text}</td>
              <td>
                <button onClick={e => {
                  e.preventDefault()
                  deleteTodo({ id })
                }}>Delete</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}
export default IndexPage

これでGraphQLを使ってToDoリストっぽいものを作るという目的は果たせたので一旦はここまで

Discussion