🐕
[Vue, React] .tsxで比較するComosition Api vs React Hooks
はじめに
今回、記事を作成した経緯は以下の通りです。
- 元々、VueのComposition ApiはReact Hooksに非常に似ているという情報は知っていた。
- その二つでどのような差分があるのか知りたかった。
- 以下の順番で同じTodo Appを、それぞれの技術スタックで作成。
a. Vue(拡張子 .vue)
b. React(拡張子 .tsx)
c. Vue(拡張子 .tsx) - Composition ApiとReact Hooksを両方.tsxにした状態で、比較。
実際に作成したAppは以下GitHub上に公開しております。
Vue(.vue file):
React: Vue(.tsx file):以下の記事を参考に作成させていただきました。非常にわかりやすく、Vue初心者でも理解しやすかったです!ありがとうございました🙇♂️
結論
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