🐕

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

に公開

はじめに

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

  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