ReactHooksを利用したCRUDアプリケーションを作成してみる

UI開発者 森崎

この記事はミツエーリンクスアドベントカレンダー2019 - Qiitaの24日目の記事です。

React v16.8.0で正式にHooksが導入されましたね。

ReactHooksはstate、context、ref、ライフサイクルなどReactで扱うには少々非効率的であったAPIを、シンプルで扱いやすい形に置き換えたような機能です。

各hookの機能は公式サイトに詳細が記載されていますが、本稿では1つのアプリケーションの中で実際にどのように組み込まれ、機能するのかをTODOリストの作成という形で試してみたいと思います。

  • カスタムフック
  • useState
  • useEffect
  • useContext
  • useReducer
  • useRef

また、本稿の趣旨はHooksの機能紹介となりますので、Reactの基礎的な部分やReduxを模した状態管理の手法については割愛いたします旨ご了承ください。それでは早速作成します。

ファイルとデータの準備

まずはファイルを作成しましょう。任意のディレクトリで下記のコマンドを実行すると、Reactのスターターファイルが作成されます。

npx create-react-app crud-todo

今回作成するTODOリストに必要なファイルを準備します。中身は空で構いませんので、src配下に下記のファイルを作成してください。

src
├ actions
│ └ actions.js
├ components
│ ├ App.js
│ ├ Form.js
│ ├ Todo.js
│ └ TodoList.js
├ css
│ ├ App.css
│ └ reset.css
├ hooks
│ └ render.js
├ reducer
│ └ reducer.js
├ index.js

ファイルを作成したら、次はAPIを作成します。今回はmyjsonを使用してAPIを作成します。

myjsonを使うと任意のJSONを返すAPIを簡単に作成できます。また、JavaScriptでGET・PUTによる読み取りと更新も可能です。

今回作成するTODOリストは下記のようなJSON形式で保存します。

{
  "TODOS": [
    {
      "text": "やること",
      "isComplete": false
    },
    {
      "text": "やること",
      "isComplete": false
    }
  ]
}

このJSONをmyjsonのテキスト入力エリアに記述し、Saveボタンで保存をしましょう。すると、「URI to access this JSON directly.」というテキストの下に保存したJSONを返してくれるURIが発行されます。

発行されたURIは後ほど使用しますので忘れずに保存しておきます。以上で準備が完了しましたので、アプリケーションを作成していきましょう。

アプリケーションの作成

まずはindex.jsを見ていきましょう。

index.js

import React from 'react'
import ReactDOM from 'react-dom'
import App from './components/App'
import './css/reset.css'
import './css/App.css'

ReactDOM.render(
  <App />,
  document.getElementById('root')
)

App.jsの置き場所と、cssファイルのみ初期状態と異なりますが、その他に特筆すべきことはありませんので次のファイルを見ていきましょう。

components/App.js

import React, { createContext } from 'react'
import { useRender } from '../hooks/render'
import Form from './Form'
import TodoList from './TodoList'

export const DispatchContext = createContext()

const App = () => {
  const [todos, dispatch] = useRender('APIのURI')

  return (
    <div className="todo-wrap">
      <DispatchContext.Provider value={dispatch}>
        <TodoList todos={todos} />
        <Form />
      </DispatchContext.Provider>
    </div>
  )
}

export default App

Appコンポーネントは本アプリケーションにおけるルートコンポーネントになりますので、ここで管理するTODOリストの状態と状態を更新する関数、そしてネストする子コンポーネントへ状態を更新する関数を渡す準備を行っています。

では上から順に説明します。

Contextオブジェクト

まずはコンポーネント間で値を渡すためのContextオブジェクトを作成します。

export const DispatchContext = createContext()

Reactには遠く離れたコンポーネントへpropsを渡す手段としてコンポジションコンテクストが備わっており、2つの使用場面の違いは渡される値がアプリケーションにとってグローバルな存在であるかどうかとされています。

今回のアプリケーションにおいてコンテクストの使用が適切かどうかはさておき、useContextの紹介のためにここではコンテクストを使用することとします。

Contextオブジェクトはコンポーネントの接続に関するプロパティを持っています。今回はその中の1つであるProviderプロパティをコンポーネントの様に配置し、valueに子コンポーネントへ渡したい値を設定します。

下記のようにContext.Providerを配置すると、Provider配下でレンダリングされるコンポーネントは、後述するuseContextを使用することでvalueに渡された値をどこからでも使用することが可能になります。

<DispatchContext.Provider value={渡したい値}>
  <SomeComponent />
<DispatchContext.Provider>

次にAppコンポーネントの中身を見ていきますが、Appコンポーネントの説明の前に、Reactの状態管理を担うhookであるuseStateとuseReducerの説明をします。

useState

useStateは、コンポーネントに対して状態とその状態を更新する関数を渡すhookです。下記がuseStateを使用する簡単な例になります。

const Counter = () => {
  const [state, setState] = useState(0)

  return (
    <div>
      <p>{state}</p>
      <button
        type="button"
        onClick={() => setState(count + 1)}
      >
        カウントを増やす
      </button>
    </div>
  )
}

useStateは変数の分割代入による宣言で、1つ目の変数に初期の状態が代入され、2つ目の変数に1つ目の状態を更新する関数が代入されます。上記の例では、stateに0という数字が代入されているため、p要素の中で0という数字が描画されます。

そして、button要素のイベントリスナーに更新したい値を引数に渡したsetStateを実行することで、stateの状態を更新します。

以上がuseStateの基本となる使用方法です。ではそれを踏まえてuseReducerの説明をします。

useReducer

useReducerは状態を管理するhookで、useStateの状態管理をより厳格にした存在です。

useStateは状態と状態を更新する関数を提供しますが、useReducerはuseStateに比べてもう1つ更新に段階を挟みます。具体的には、状態と状態に更新を促す関数と、その通知を受け取り状態を更新する関数を提供します。

useReducerはReduxの状態管理フローと共通したロジックを使用しますのでここは少々難解になります。どうしても理解が難しいようであれば一度Redux公式チュートリアルに目を通すとよいかもしれません。それではコードを見ていきます。

const [todos, dispatch] = useRender('APIのURI')

こちらはカスタムフックと呼ばれる複数のhookを1つにまとめた関数となります。

詳細については後述しますので、ひとまずは同義である下記の宣言で説明したいと思います。

const [todos, dispatch] = useReducer(reducer, [])

useReducerは変数へ分割代入することで使用でき、1つ目の変数には状態の初期値が入り、2つ目の変数には状態の変更を通知する関数が入ります。

また、useReducerの第1引数には、状態の変更の通知を受け取り状態の更新を行う関数を、第2引数には状態の初期値を渡します。

通常、dispatchはどのような種類の変更が起きたかを示すtypeプロパティと、どのような値で変更したのかを示すpayloadプロパティを返すactionCreatorを引数に渡して実行します。

actionCreatordispatchを通じてreducerへ渡され、reducerは渡された変更点を元に現在の状態を更新します。そして、Reactは更新された状態を検知してDOMを再レンダリングする仕組みです。

actions/actions.js

本アプリケーションにおけるactionCreatorreducerは下記の通りです。

/*
 * actionCreator関数
 * dispatch関数を通じてreducerにオブジェクトして渡される
 * actionCreatorは必ずdispatchの引数として渡される
 * 使用例と展開イメージ
 * dispatch(addTodo(String))
 * dispatch({ type: 'ADD_TODO', text: String })
 * reducer(todos, { type: 'ADD_TODO', text: String }) => {...}
 */
export const initTodo = todos => {
  return {
    type: 'INIT_TODO',
    todos
  }
}

export const addTodo = text => {
  return {
    type: 'ADD_TODO',
    text
  }
}

export const removeTodo = index => {
  return {
    type: 'REMOVE_TODO',
    index
  }
}

export const toggleTodo = index => {
  return {
    type: 'TOGGLE_TODO',
    index
  }
}

reducer/reducer.js

/**
 * Reducer
 * @param {Array} todos 更新される直前のTODOリスト
 * @param {Object} action どのような更新かを示す[action.type]と更新された値及び項目を示すプロパティを格納
 */
export const reducer = (todos, action) => {
  switch(action.type) {
    // 初回読み込み時にAPIからGETしたTODOオブジェクトリストをそのまま反映
    case 'INIT_TODO':
      return action.todos
    // 現在のTODOリスト配列の最後尾に、渡されたテキストを反映したTODOを追加
    case 'ADD_TODO':
      return [...todos, {
        text: action.text,
        isComplete: false
      }]
    // クリックされたボタンが何番目かを示すindexを元に、該当の配列を除いたTODOリストを作成
    case 'REMOVE_TODO':
      const newTodos = []
      for (let i = 0; i < todos.length; i++) {
        if (action.index !== i) {
          newTodos.push(todos[i])
        }
      }
      return newTodos
    // クリックされたボタンが何番目かを示すindexを元に、該当のTODOのisCompleteを反転させる
    case 'TOGGLE_TODO':
      return todos.map((todo, index) => {
        if (action.index === index) {
          return Object.assign({}, todo, {
            isComplete: !todo.isComplete
          })
        }
        return todo
      })
    default:
      return [...todos]
  }
}

例えば、任意の箇所でdispatch(addTodo('今度やること'))と実行すると、「今度やること」というテキストのTODOが追加され、dispatch(removeTodo(0))と実行すると最初のTODOが削除されるという仕組みになります。

この時点で、dispatchを実行するだけで状態の管理を行うことができ、またdispatchContext.Providerのvalueに渡されますので、App配下のコンポーネントのどこからでも使用できます。以上で状態管理の準備が整いましたので、次はuseRenderの詳細を見ていきます。

hooks/render.js

import {
  useReducer,
  useEffect,
  useRef
} from 'react'
import { reducer } from '../reducer/reducer'
import { initTodo } from '../actions/actions'

/**
 * カスタムフック
 * @params {String} jsonUri
 * @returns {Array} [todos]現在のTODOリスト, [dispatch]actionCreatorをReducerへ渡す関数
 */
export const useRender = (jsonUri) => {
  const [todos, dispatch] = useReducer(reducer, [])
  const isFirstRender = useRef(true)

  useEffect(() => {
    const newTodos = {
      TODOS: [...todos]
    }

    // 初回レンダリング後の処理
    if (isFirstRender.current) {
      fetch(jsonUri)
      .then(response => {
        return response.json()
      })
      .then(init => {
        newTodos.TODOS = [init.TODOS]
        dispatch(initTodo(init.TODOS))
        isFirstRender.current = false
        return
      })
    }

    // 2回目以降のレンダリング後の処理
    fetch(jsonUri, {
      method: 'PUT',
      body: JSON.stringify(newTodos),
      headers: {
        'Content-Type': 'application/json'
      }
    })
  }, [jsonUri, todos])

  return [todos, dispatch]
}

ここでは複数のHooksを1つの関数にまとめたカスタムフックを定義しています。

ですが、「なぜこのようにhookを別の関数として分離しているのか?」という疑問が頭に浮かぶのではないでしょうか。まずはその答えとしてのカスタムフックの利点について説明します。

前提としてhookはコンポーネントのトップレベルで宣言しなければならないのですが、例えばもし他にロジックがよく似たTODOリストのコンポーネントを別に作成する必要が生じた場合、同じhookの処理をコンポーネントごとに複数書かなければなりません。

しかし、hookの処理を別の関数としてまとめておき、引数に複数の任意の値を持たせることでより柔軟にhookの処理を使いまわすことができます。

また、複雑になりがちな処理をコンポーネント自身に持たせないことで関心の分離もでき、可読性の向上にもつながります。ではそれを踏まえて実際の中身を説明します。

useRef

const isFirstRender = useRef(true)

ここでuseRefという新しいhookが登場しました。useRefは主にReactが管理するDOMにアクセスする手段を提供するhookです。まずは簡単な仕様から説明します。

const refObj = useRef(初期値)

useRefの返り値はオブジェクトです。このオブジェクトにはcurrentプロパティが存在し、引数に渡した値がセットされています。また、引数を指定しない場合はundefinedとなりますが、その場合currentにはundefinedはセットされませんので引数は空でも問題ありません。

変数を宣言後、JSXにref={refObj}を渡すことでrefObjのcurrentプロパティにrefのpropsを持つDOMがセットされ、Reactを通じたDOMへのアクセスが可能になります。

簡単に例を見てみましょう。下記はbutton要素を押下するとinput要素にフォーカスが移動するコンポーネントです。

const App = () => {
  const inputRef = useRef(null)

  return (
    <>
      <input type="text" ref={inputRef} />
      <button
        type="button"
        onClick={() => inputRef.current.focus()}
      >
        押下するとinputにフォーカスが移動する
      </button>
    </>
  )
}

通常のJavaScriptであれば、document.querySelector('input').focus()といった記述になりますが、Reactではdocumentオブジェクトから取得したDOMに変更を行ってはいけません。理由はReactが管理する外側で起きたDOMの変更をReactは検知することができないからです。

つまりdocumentオブジェクトを通じてDOMの変更を行うと、Reactの認識しているDOMと実際に描画されているDOMに齟齬が起き、思わぬ不具合を生む可能性があります。そのため、ReactではDOMにアクセスする場合はrefからアクセスをしなければなりませんので注意してください。

変数としてのuseRef

その他にuseRefの特長として、currentプロパティはReactがDOMを再レンダリングした後も値を初期化せずに保持し続けるという仕様があります。これが何を意味するかと言うと、useRefはコンポーネント内の変数としても使用できるということです。

const App = () => {
  let count = 0

  return (
    <SomeComponent />
  )
}

例えば上記のようにコンポーネント内で何らかのカウントを行う変数を宣言したとします。しかし、Appがレンダリングされるごとに変数countは0に戻りますので、コンポーネントの内部でcount++といった処理をしても意味がありません。

ではどうすればよいかと言うと、答えは下記のコードになります。

const App = () => {
  const count = useRef(0)

  return (
    <SomeComponent />
  )
}

// 上記の宣言の結果、このようなオブジェクトになります
console.log(count) // {current: 0}

useRefの特長を利用することで、count.currentがレンダリングごとに初期化がされない再代入可能な変数として使用できます。では次に変数としてのuseRefであるisFirstRenderを含むuseEffectを見ていきます。

useEffect

useEffectはレンダリングが完了した後、第1引数に渡した関数を実行するhookで、Reactにおけるライフサイクルを提供します。

useEffect(() => {
  レンダリング後に行われる処理
}, レンダリング後に処理を行うかを監視する配列)

第2引数には配列を渡し、レンダリング後、渡した配列に変化が起きなかった場合は第1引数の実行をキャンセルします。第2引数は指定しなくても問題ありません。ちなみに第2引数に空の配列を渡すと初回のレンダリング時のみ実行するということができます。

では実際のコードの中身を見ていきます。最初にレンダリング後の現在のTODOをオブジェクトに格納します(初期値は空のオブジェクトです)。

const newTodos = {
  TODOS: [...todos]
}

useRefで管理する変数により初回レンダリングかどうかを判定しています。初回レンダリングであればAPIから現在のTODOを取得し、dispatch関数で初回の更新を促します。

// 初回レンダリング後の処理
if (isFirstRender.current) {
  fetch(jsonUri)
  .then(response => {
    return response.json()
  })
  .then(init => {
    newTodos.TODOS = [init.TODOS]
    dispatch(initTodo(init.TODOS))
    isFirstRender.current = false
    return
  })
}

2回目以降のレンダリングであれば、更新されたTODOをPUTします。

// 2回目以降のレンダリング後の処理
fetch(jsonUri, {
  method: 'PUT',
  body: JSON.stringify(newTodos),
  headers: {
    'Content-Type': 'application/json'
  }
})

useRenderは最後に現在のTODOとdispatch関数を返却することで、呼び出し元の変数に該当のオブジェクトが格納されます。

return [todos, dispatch]

// ↓呼び出し元のインデックスに値が返る

const [todos, dispatch] = useRender('APIのURI')

以上で状態管理の準備が整いましたので、最後に配置するコンポーネントを作成します。

components/Form.js

import React, {
  useState,
  useContext,
} from 'react'
import { addTodo } from '../actions/actions'
import { DispatchContext } from './App'

const Form = () => {
  const [value, setValue] = useState('')
  const dispatch = useContext(DispatchContext)

  const submitHandler = e => {
    e.preventDefault()
    if (!value) return
    dispatch(addTodo(value))
    setValue('')
  }

  return (
    <form onSubmit={submitHandler}>
      <input
        type="text"
        placeholder="TODOを入力してください"
        value={value}
        onChange={e => setValue(e.target.value)}
      />
      <button type="submit">TODOを登録</button>
    </form>
  )
}

export default Form

ここでようやくuseStateが登場しました。

const [value, setValue] = useState('')

このuseStateはinput要素に変更が起こるたびに入力された値を更新します。

<input
  type="text"
  placeholder="TODOを入力してください"
  value={value}
  onChange={e => setValue(e.target.value)}
/>

更新されたvalue(inputに入力されているテキスト)をactionCreatorの引数に渡し、dispatchで更新を促すことでTODOリストが更新され、DOMが再レンダリングされます。そしてTODOを更新後、setValue('')を実行することでinput要素をリセットします。

<form onSubmit={submitHandler}>
.
.
.
const submitHandler = e => {
  e.preventDefault()
  if (!value) return
  dispatch(addTodo(value))
  setValue('')
}

useContext

useContextは上層のコンポーネントでContext.Providerとして配置されたコンポーネントのvalueの値を受け取るhookです。useContextはReduxにおけるconnectのイメージが近いかと思います。

const dispatch = useContext(DispatchContext)

ここでdispatchに代入される値は下記のコンポーネントのvalueです。

// App.js

<DispatchContext.Provider value={dispatch}>
  <TodoList todos={todos} />
  <Form />
</DispatchContext.Provider>

valueに渡されているdispatchはuseReducerで定義した更新を促す関数です。

このようにcontextを渡すことで遠く離れた親コンポーネントから中間のコンポーネントを経由せずに直接dispatchを受け取ることができ、Formコンポーネント内で自由にTODOの状態を更新できるようになります。

components/TodoList.js

最終的にTodoListコンポーネントで現在のTODOを描画します。

import React from 'react'
import Todo from './Todo'

/**
 * Component
 * @param {Array} todos TODOリストオブジェクトの配列
 */
const TodoList = ({ todos }) => {
  return (
    <ul className="todo-list">
      {todos.map((todo, index) => (
        <Todo index={index} key={index} todo={todo} />
      ))}
    </ul>
  )
}

export default TodoList

components/Todo.js

Todoコンポーネントでも同様にdispatch変数にuseContextの戻り値を代入し、dispatchへのアクセスを提供しています。

import React, { useContext } from 'react'
import { DispatchContext } from './App'
import {
  removeTodo,
  toggleTodo
} from '../actions/actions'

/**
 * Component
 * @param {Object} todo TODOの内容と達成状況を持つ
 * @param {Number} index 何番目のTODOリストかを表す
 */
const Todo = ({ todo, index }) => {
  const dispatch = useContext(DispatchContext)

  return (
    <li>
      <p style={{textDecoration: todo.isComplete ? 'line-through' : 'none'}}>{ todo.text }</p>
      <ul className="button-list">
        <li>
          <button type="button" onClick={() => dispatch(toggleTodo(index))}>{ todo.isComplete ? '未達成' : '達成' }</button>
        </li>
        <li>
          <button type="button" onClick={() => dispatch(removeTodo(index))}>削除</button>
        </li>
      </ul>
    </li>
  )
}

export default Todo

以上でTODOリストの枠組みが作成できたかと思いますので、あとはお好みのstyleをCSSファイルに記述いただければ完成です。

おわりに

いかがでしたでしょうか。

Reactについては「求められる前提知識が高くて導入が難しい」「学習の成長曲線が険しい」という評判を耳にします。

しかし、

  • npx create-react-appのコマンドを実行するだけで簡単に開発のスタートができる
  • ReactHooksが導入されたことによりclassコンポーネントが実質的に不要になったこと
  • 状態の管理・ライフサイクルがとても容易になったこと
  • ミドルウェアもさまざまなカスタムフックを提供していること

などから、その導入の敷居はだいぶ低くなっているように感じます。

Reactを学習することでJavaScriptへの理解も深まりますし、またReactは昨今よく耳にするJAMstackの構築にもそのまま使うことができます。

この記事を機にReactへ興味を持っていただけたら幸いです。