🐕

[Vue, React] .tsxで比較するComosition Api vs React Hooks

2022/12/15に公開

はじめに

今回、記事を作成した経緯は以下の通りです。

  1. 元々、VueのComposition ApiはReact Hooksに非常に似ているという情報は知っていた。
  2. その二つでどのような差分があるのか知りたかった。
  3. 以下の順番で同じTodo Appを、それぞれの技術スタックで作成。
    a. Vue(拡張子 .vue)
    b. React(拡張子 .tsx)
    c. Vue(拡張子 .tsx)
  4. Composition ApiとReact Hooksを両方.tsxにした状態で、比較。

実際に作成したAppは以下GitHub上に公開しております。

Vue(.vue file):
https://github.com/hinatha/crud-vue-ts
React:
https://github.com/hinatha/crud-react-ts
Vue(.tsx file):
https://github.com/hinatha/crud-vue-on-tsx

以下の記事を参考に作成させていただきました。非常にわかりやすく、Vue初心者でも理解しやすかったです!ありがとうございました🙇‍♂️
https://zenn.dev/azukiazusa/articles/aacff94e249bcf

結論

Composition ApiとReact Hooksの違いは以下の表の通りです。

Composition Api React Hooks
mutable immutable

App構成

Appの構成は以下のとおりです。

  • Todo追加用pageへのリンク(Top page) -> Todo追加用page
  • Todo titleクリック(Top page) -> Todo編集用page

実際のpage

Top page

Todo追加用page

Todo編集用page

Top page

Vue

App.tsx
import { defineComponent } from 'vue'
import AsyncTodos from '@/components/AsyncTodos'

export default defineComponent({
  components: {
    AsyncTodos,
  },
  setup () {
    return () => (
      <div>
        <h2>TODO一覧</h2>
        <AsyncTodos />
        <router-link to="/new">新規作成</router-link>
      </div>
    )
  },
})

React

App.tsx
import { Link } from "react-router-dom";
import AsyncTodos from "../components/AsyncTodos";

const App = () => {

    return (
        <div>
            <h2>Show Todo List</h2>
            <AsyncTodos />
            <Link to="new">Add Todo</Link>
        </div>
    );
}

export default App;

AsyncTodos component

Loading処理

以下の画像のようにTodoをlocal storageから持ってくる間、Loading処理を行なっている。

それぞれの実装方法は以下の表のとおり。

Composition Api React Hooks
非同期処理のmount onMounted useEffect
loading表示の切り替え reactive useState

Vue

AsyncTodos.tsx
import { defineComponent, onMounted, inject, reactive } from 'vue'
import { Todo } from '@/store/todo/types'
import { useRouter } from 'vue-router'
import TodoItem from '@/components/TodoItem'
import { todoKey } from '@/store/todo'

export default defineComponent({
  components: {
    TodoItem,
  },
  setup () {
    // Inject todoStore
    const todoStore = inject(todoKey)
    // todoStore: Store | undefined
    // We need to check if the type is correct (Store)
    if (!todoStore) {
      throw new Error('todoStore is not provided')
    }

    // Call useRouter() to get the routes
    // Former vue.js can access the routes by this.$router
    // In case of composition api can't access to "this"
    const router = useRouter()

    // Parent component can receive props.todo.item as the event argument
    // Set todo id as argument of this method
    const onClickDelete = (id: number) => {
      todoStore.deleteTodo(id)
    }

    // Parent component can receive props.todo.item as the event argument
    // Set todo id as argument of this method
    const onClickTitle = (id: number) => {
      // Move to edit page
      router.push(`/edit/${id}`)
    }

    // Define loading state
    const loading = reactive({
      show: false,
    })

    // Wrapper await method by onMounted
    onMounted(async () => {
      // Turn on loading
      loading.show = true
      // Call fetchTodos() with await
      // In case of await, we need to call after inject and useRouter
      await todoStore.fetchTodos()
      // Turn off loading
      loading.show = false
    })

    return () => (
      <div>
        {loading.show
          ? (<div>Loading ...</div>)
          : (<ul>
              {todoStore.state.todos?.map((todo: Todo) => (
                <TodoItem todo={todo} key={todo.id} onClickTitle={onClickTitle} onClickDelete={onClickDelete} />
              ))}
            </ul>)
        }
      </div>
    )
  },
})

React

AsyncTodos.tsx
import { useEffect, useCallback, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Todo } from '../types/index';
import TodoItem from "./TodoItem";
import useTodoList from '../hooks/useTodoList'

const AsyncTodos = () => {

  // Get Todo List from custom hooks
  const { todos, fetchTodos, deleteTodo } = useTodoList();

  // To change page
  const navigate = useNavigate();

  // When clicking title, move to the edit page
  const onClickTitle = useCallback(
    (id: number) => {
      // Move to edit page
      navigate("/edit/" + id);
    },
    [navigate]
  );

  // When pushing delete button, receiving todo.id
  // Memoization by useCallback
  const onClickDelete = useCallback(
    (id: number) => {
      // Execute delete todo logic in custom hooks(useTodoList)
      deleteTodo(id);
    },
    // Set deleteTodo as dependent relation
    [deleteTodo]
  );

  // Define loading state
  const [loading, setLoading] = useState(false);

  // Call fetchTodos
  // Prevent method from re-render
  useEffect(() => {
      (async() => {
        // Turn on loading
        setLoading(true);
        // Get Todos from localStorage and set todos
        await fetchTodos();
        // Turn off loading
        setLoading(false);
      })()
    },
    [fetchTodos]
  );

  return (
    <div>
      {loading ? (
        <div>Loading ...</div>
      ) : (
        <ul>
          {todos?.map((todo: Todo) => (
            <TodoItem todo={todo} key={todo.id} onClickTitle={onClickTitle} onClickDelete={onClickDelete} />
          ))}
        </ul>
      )}
    </div>
  );
}

export default AsyncTodos;

TodoItem component

Vue

TodoItem.tsx
import '@/css/TodoItem.css'
import { Todo } from '@/store/todo/types'
import { defineComponent, PropType } from 'vue'

export default defineComponent({
  // props: parent component -> child component
  // Data from parent component is readonly
  props: {
    todo: {
      // Set Object props' type as Todo
      type: Object as PropType<Todo>,
      // Warn when don't pass data as props
      required: true,
    },
    onClickTitle: {
      // Set Function props' type
      type: Function as PropType<(id: number) => void>,
      // Warn when don't pass data as props
      required: true,
    },
    onClickDelete: {
      // Set Function props' type
      type: Function as PropType<(id: number) => void>,
      // Warn when don't pass data as props
      required: true,
    },
  },

  setup (props) {
    const clickDelete = (id: number) => {
      // Execute onClickDelete method in props
      props.onClickDelete(id)
    }

    const clickTitle = (id: number) => {
      // Execute onClickTitle method in props
      props.onClickTitle(id)
    }

    // Change formatDate
    const formatDate = `${props.todo.createdAt.getFullYear()}/${props.todo.createdAt.getMonth() + 1}/${props.todo.createdAt.getDate()}`

    return () => (
      <div class="card">
        <div>
          <span class="title" onClick={() => clickTitle(props.todo.id)}>{ props.todo.title }</span>
          <span class={'status' + ' ' + `${props.todo.status}`}>{ props.todo.status }</span>
        </div>
        <hr />
        <div class="body">作成日:{ formatDate }</div>
        <div class="action">
          <button onClick={() => clickDelete(props.todo.id)}>削除</button>
        </div>
      </div>
    )
  },
})

React

TodoItem.tsx
import { Todo } from '../types/index';
import '../css/TodoItem.css';

type Props = {
  todo: Todo;
  onClickTitle: (id: number) => void;
  onClickDelete: (id: number) => void;
};


const TodoItem = (props: Props) => {

  // Divide props
  const { todo, onClickTitle, onClickDelete } = props;

  //Define clickTitle method to parent component
  const clickTitle = (id: number) => {
    onClickTitle(id);
  }
  
  // Define clickDelete method to parent component
  const clickDelete = (id: number) => {
    onClickDelete(id);
  };

  // Change formatDate
  const formatDate = `${todo.createdAt.getFullYear()}/${todo.createdAt.getMonth() + 1}/${todo.createdAt.getDate()}`

  return (
      <div className="card">
          <div>
              <span className="title" onClick={() => clickTitle(todo.id)}>{ todo.title }</span>
              <span className={"status" + " " + `${todo.status}`}>{ todo.status }</span>
          </div>
          <div className="body">作成日:{ formatDate }</div>
          <hr />
          <div className="action">
              <button onClick={() => clickDelete(todo.id)}>削除</button>
          </div>
      </div>
  );
}

export default TodoItem;

Todo追加用page

Form値state

それぞれの実装方法は以下の表のとおり。

Composition Api React Hooks
State管理 reactive useState
Form値の切り替え v-model onChange

v-model vs onChange

v-model onChange
値の切り替え よしなに変更(mutable) 自作のfunction(immutable)

Vue

AddTodo.tsx
import { defineComponent, inject, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { Params } from '@/store/todo/types'
import { todoKey } from '@/store/todo'

export default defineComponent({
  setup () {
    // Inject todoStore
    const todoStore = inject(todoKey)

    // todoStore: Store | undefined
    // We need to check if the type is correct (Store)
    if (!todoStore) {
      throw new Error('todoStore is not provided')
    }

    // Call useRouter() to get the routes
    // Former Vue.js can access the routes by this.$router
    // In case of composition api can't access to "this"
    const router = useRouter()

    // Set form data as reactive
    const data = reactive<Params>({
      title: '',
      description: '',
      status: 'waiting',
    })

    const onSubmit = () => {
      // Fetch each todo data from form data
      const { title, description, status } = data
      // type Params = {
      //   title: string
      //   description: string
      //   status: Status
      // };
      todoStore.addTodo({
        title,
        description,
        status,
      })
      // Move to top page
      router.push('/')
    }

    return () => (
      <div>
        <h2>TODOを作成する</h2>
        <div>
          <label for="title">タイトル</label>
          <input type="text" id="title" v-model={data.title} />
        </div>
        <div>
          <label for="description">説明</label>
          <textarea id="description" v-model={data.description} />
        </div>
        <div>
          <label for="status">ステータス</label>
          <select id="status" v-model={data.status}>
            <option value="waiting">waiting</option>
            <option value="working">working</option>
            <option value="completed">completed</option>
            <option value="pending">pending</option>
          </select>
        </div>
        <button onClick={() => onSubmit()}>作成する</button>
      </div>
    )
  },
})

React

AddTodo.tsx
import { ChangeEvent, useState } from "react";
import { useNavigate } from 'react-router-dom';
import { Status, Params } from '../types/index';
import useTodoList from '../hooks/useTodoList'


const AddTodo = () => {

    // Todo form(param) State
    const [param, setParam] = useState<Params>({ title: '', description: '', status: 'waiting' });

    // To redirect to top package
    const navigate = useNavigate();

    // Set input content to state when input title form
    const onChangeTitle = (e: ChangeEvent<HTMLInputElement>) => {
        const newTitle = e.target.value;
        setParam((prevParam) => ({ ...prevParam, title: newTitle }));
    }

    // Set input content to state when input description form
    const onChangeDescription = (e: ChangeEvent<HTMLInputElement>) => {
        const newDescription = e.target.value;
        setParam((prevParam) => ({ ...prevParam, description: newDescription }));
    }

    // Set input content to state when input description form
    const onChangeStatus = (e: React.ChangeEvent<HTMLSelectElement>) => {
        const newStatus = e.target.value as Status;
        setParam((prevParam) => ({ ...prevParam, status: newStatus }));
    }

    // Get addTodo method from custom hooks
    const { addTodo } = useTodoList();

    // When push add button
    const onClickAdd = () => {
        // Execute add todo method
        addTodo(param);
        // Redirect to top page
        navigate('/');
    };

    return (
    <div>
        <h2>Add TODO</h2>
        <div>
            <label>タイトル</label>
            <input type="text" value={param.title} onChange={onChangeTitle} />
        </div>
        <div>
            <label>説明</label>
            <input id="description" value={param.description} onChange={onChangeDescription} />
        </div>
        <div>
            <label>ステータス</label>
            <select id="status" value={param.status} onChange={onChangeStatus}>
                <option value="waiting">waiting</option>
                <option value="working">working</option>
                <option value="completed">completed</option>
                <option value="pending">pending</option>
            </select>
        </div>
        <button onClick={() => onClickAdd()}>Add</button>
    </div>
    );
}

export default AddTodo;

Todo編集用page

Vue

EditTodo.tsx
import { defineComponent, inject, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Params } from '@/store/todo/types'
import { todoKey } from '@/store/todo'

export default defineComponent({
  setup () {
    // Inject todoStore
    const todoStore = inject(todoKey)
    // todoStore: Store | undefined
    // We need to check if the type is correct (Store)
    if (!todoStore) {
      throw new Error('todoStore is not provided')
    }

    // Call useRouter() to get the routes
    // Former vue.js can access the routes by this.$router
    // In case of composition api can't access to "this"
    const router = useRouter()

    // Router() can make access $route
    const route = useRoute()

    // We can get id(route.params.id) by using useRoute()
    const id = Number(route.params.id)
    // Get todo by id from todoStore
    const todo = todoStore.getTodo(id)

    // Set Params as first form value
    // If React, like below
    // const [data, setData] = useState<Params>({
    //   title: todo.title,
    //   description: todo.description,
    //   status: todo.status,
    // })
    const data = reactive<Params>({
      title: todo.title,
      description: todo.description,
      status: todo.status,
    })

    const onSubmit = () => {
      // Form values
      const { title, description, status } = data
      todoStore.updateTodo(id, {
        // Original todo before update
        ...todo,
        // Form values after changing todo
        // type Params = {
        //   title: string
        //   description: string
        //   status: Status
        // };
        title,
        description,
        status,
      })
      router.push('/')
    }

    return () => (
      <div>
        <h2>TODOを編集する</h2>
        <div>
          <label for="title">タイトル</label>
          <input type="text" id="title" v-model={data.title} />
        </div>
        <div>
          <label for="description">説明</label>
          <textarea id="description" v-model={data.description} />
        </div>
        <div>
          <label for="status">ステータス</label>
          <select id="status" v-model={data.status}>
            <option value="waiting">waiting</option>
            <option value="working">working</option>
            <option value="completed">completed</option>
            <option value="pending">pending</option>
          </select>
        </div>
        <button onClick={() => onSubmit()}>更新する</button>
      </div>
    )
  },
})

React

EditTodo.tsx
import { ChangeEvent, useState } from "react";
import { useNavigate } from 'react-router-dom';
import { Status, Params } from '../types/index';
import useTodoList from '../hooks/useTodoList';
import { useParams } from 'react-router-dom'


const EditTodo = () => {

    // Get addTodo method from custom hooks
    const { getTodo, updateTodo } = useTodoList();

    // Get url parameter
    const urlParams = useParams<{ id: string }>()

    // Change id(string) to Number type
    const id = Number(urlParams.id);

    // Get todo by id from todoStore
    const todo = getTodo(id)

    // Todo form(param) State
    // The first value is original todo
    const [param, setParam] = useState<Params>({ title: todo.title, description: todo.description, status: todo.status });

    // Set input content to state when input title form
    const onChangeTitle = (e: ChangeEvent<HTMLInputElement>) => {
        const newTitle = e.target.value;
        setParam((prevParam) => ({ ...prevParam, title: newTitle }));
    }

    // Set input content to state when input description form
    const onChangeDescription = (e: ChangeEvent<HTMLInputElement>) => {
        const newDescription = e.target.value;
        setParam((prevParam) => ({ ...prevParam, description: newDescription }));
    }

    // Set input content to state when input description form
    const onChangeStatus = (e: React.ChangeEvent<HTMLSelectElement>) => {
        const newStatus = e.target.value as Status;
        setParam((prevParam) => ({ ...prevParam, status: newStatus }));
    }

    // To redirect to top package
    const navigate = useNavigate();

    // Get each value from param
    const { title, description, status } = param;

    // When push add button
    const onClickUpdate = () => {
        // Execute update todo method
        updateTodo(id, 
            {
                // Other title, description and status
                ...todo,
                // Form values after changing todo
                title,
                description,
                status
            });
        // Redirect to top page
        navigate('/');
    };

    return (
    <div>
        <h2>Edit TODO</h2>
        <div>
            <label>タイトル</label>
            <input type="text" value={param.title} onChange={onChangeTitle} />
        </div>
        <div>
            <label>説明</label>
            <input id="description" value={param.description} onChange={onChangeDescription} />
        </div>
        <div>
            <label>ステータス</label>
            <select id="status" value={param.status} onChange={onChangeStatus}>
                <option value="waiting">waiting</option>
                <option value="working">working</option>
                <option value="completed">completed</option>
                <option value="pending">pending</option>
            </select>
        </div>
        <button onClick={() => onClickUpdate()}>Update</button>
    </div>
    );
}

export default EditTodo;

Discussion