Reactのバケツリレーを効果的に使えるEntity型コンポーネント(TypeScript)
はじめまして。
株式会社digsasでCPOを務める森勝と申します。
当社は「変遷するビジネスに、IT投資のモノサシを」作る、というミッションを元に、IT投資におけるユーザー企業の導入設計力を向上させるためのプロダクトを開発しています。
(近々大きなアップデートを予定しており、この挨拶も最後です)
有り難いことに以前書いたフルスタックなTypeScript環境 (Blitz.js) でDDDするが未だにぼちぼち伸びておりまして、技術面の試行錯誤をしている会社感!チーム感!をアピールするためにも、検証結果やプロダクションでのワークパフォーマンス等、書いていけたらな〜〜と思ってます。
現在チームは、私+2名の分野のプロ(副業)と少ない中、B2B SaaSとして派生展開や多くの仮説検証を繰り返していきたいこともあり、依存管理のしやすいmonorepoへの移行(npm v7からのworkspaceを使ってみています)を行っています。
大型アップデートの後には、オープンβに向けた正式リリースおよびグロースが待ち受けております。
CEO石井も私もTech投資に重要性を感じており、今のうちにベースとなる社内〜開発投資等を行って行きたく、設計・技術選定・採用・開発文化などを皮切りに、経営にも茶々を入れていきたい何でもござれでやってみたいアナタは今すぐ採用ページへお越しください。
バケツリレー嫌よね
まずそもそも私は、バケツリレーが嫌いでした・・・。
まあ結構いらっしゃるんじゃないでしょうか、嫌いな方。
可読性とか再レンダリングのしやすさとか、memo化だとか色々ありますけど、私が一番嫌いなのは、何度も同じようなpropsを書くことが微妙でした。
しかし、本記事で伝えたい最終ゴールは「書いたほうが結果メリット多くない?」というところにあります。
コンポーネントの設計思想にも依るとは思いますが、当プロダクトでは結果として、なかなかに捗る構造となりました。
TodoList で考える EntityModel
ここからは例のごとくTodoListでざっくりめのコードも併記していきます。
GraphQL等でAPIスキーマが決まっている前提です。
type TodoList {
todoListId: UUID!
name: String!
TodoItems: [TodoItem!]
}
type TodoItem {
todoItemId: UUID!
status: TodoItemStatusEnum!
title: String!
}
enum TodoItemStatusEnum {
TODO
DONE
}
grahql-codegenで生成済みのファイルが以下
(enumsAsTypes: true
を設定しています)
export type TodoList = {
todoListId: Scalars['UUID'] // string
name: Scalars['String'] // string
TodoItems: Maybe<Array<TodoItem>> // TodoItem[] | undefined
}
export type TodoItem = {
todoListId: Scalars['UUID'] // string
title: Scalars['String'] // string
status: TodoItemStatusEnum
}
export type TodoItemStatusEnum =
| 'TODO'
| 'DONE'
APIで返却されるデータが決まったら、フロントでバケツリレーされるモデルを設計します。
export type ITodoListEntity = {
todoListId: Id
name: string
TodoItems?: TodoItemEntity[]
}
export class TodoListEntity implements ITodoListEntity {
todoListId: Id
name: string
TodoItems?: TodoItemEntity[]
constructor(value: PartialRequired<TodoList, "name">) {
this.todoListId = new Id(value.todoListId)
this.name = value.name
this.TodoItems = value.TodoItems?.map(item => new TodoItemEntity(item))
}
update(value: PartialRequired<TodoList, "todoListId">) {
return new TodoListEntity({
...this.values(),
...value,
})
}
values(): TodoList {
return {
todoListId: this.todoListId.value,
name: this.name,
TodoItems: this.TodoItems?.map(item => item.values()),
}
}
}
updateでプロパティの更新ができるようにし、valuesで値をパースできるようにしておきます。
また、以下のようなモデル毎の振る舞いを設定します。
export class TodoListEntity implements ITodoListEntity {
// ...
// 追加する値を受け取りたいときはここに規定
addTodoItem() {
return new TodoListEntity({
...this.values(),
TodoItems: [
...(this.TodoItems || []),
new TodoItemEntity(),
].map(item => item.values()),
})
}
updateTodoItem(newItem: TodoItem) {
// バリデーション
if (!this.TodoItems) {
throw new Error("TodoItemがありません")
}
return new TodoListEntity({
...this.values(),
TodoItems: this.TodoItems
.map(item => item.todoItemId.eq(newItem.todoItemId) ? item.update(newItem).values() : item.values()),
})
}
removeTodoItem(todoItemId: TodoItem["todoItemId"]) {
return new TodoListEntity({
...this.values(),
TodoItems: this.TodoItems
.filter(item => !item.todoItemId.eq(todoItemId)),
})
}
}
こんな感じです。
同じように TodoItem もモデリングします。
export type ITodoItemEntity = {
todoItemId: Id
title: string
status: TodoItemStatus
}
export class TodoListEntity implements ITodoListEntity {
todoItemId: Id
title: string
status: TodoItemStatus
constructor(value: Partial<TodoItem>) {
this.todoItemId = new Id(value.todoItemId)
this.title = value.title
this.status = new TodoItemStatus(value.status)
}
update(value: PartialRequired<TodoItem, "todoItemId">) {
return new TodoItemEntity({
...this.values(),
...value,
})
}
values(): TodoItem {
return {
todoItemId: this.todoItemId.value,
title: this.title,
status: this.status.value,
}
}
}
ちらほら出てきたType/Class
/**
* 指定したキーだけをrequiredにする = 指定したキー以外をoptionalにする
*/
export type PartialRequired<T, K extends keyof T> = Pick<T, K> & Partial<T>
import { v4 } from "uuid"
import { z } from "zod"
const IdSchema = z
.string()
.uuid()
.default(() => v4())
type IdValue = z.infer<typeof IdSchema>
export class Id {
value: IdValue
// 値がなければ初期値を入れる
constructor(value?: IdValue) {
this.value = IdSchema.parse(value)
Object.freeze(value)
}
eq(value: string) {
return this.value === value
}
}
zod はtypeベースでバリデーションができるライブラリです。フォーム等とも相性がよく非常に重宝しています。
export class TodoItemStatus {
private LIST: {
[key in TodoItemStatusEnum]: {
label: string
color: string
}
} = {
TODO: {
label: "未着手",
color: "red",
},
DONE: {
label: "完了",
color: "green",
},
}
// 値がなければ初期値を入れる
constructor(public value: TodoItemStatusEnum = "TODO") {
Object.freeze(this)
}
eq(value: TodoItemStatusEnum) {
return this.value === value
}
get label() {
return this.LIST[this.value].label
}
get color() {
return this.LIST[this.value].color
}
}
statusについてはzodでenumを作ってバリデーションするのも良いです。
TSXでベースコンポーネントを作る
モデリングができたら、entityベースのコンポーネントを作ってしまいましょう!
スタイリングやコンポーネント名は適当です、笑
export type TodoListProps = {
item: TodoListEntity
}
export const TodoList: React.FC<TodoListProps> = ({ item }) => {
return (
<Box>
<Text>{item.name}</Text>
<Box gap={"small"}>
<Box>
{!item.TodoItems ? (
<Text>まだTodoがありません</Text>
) : (
item.TodoItems.map(item => (
<TodoItem
key={item.todoItemId.value}
item={item}
// 後ほど
// onUpdate={() => {}}
// onRemove={() => {}}
/>
))
)}
</Box>
<Button
label={"Add TodoItem"}
icon={IconAdd}
// 後ほど
// onClick={() => {}}
/>
</Box>
</Box>
)
}
export type TodoItemProps = {
item: TodoItemEntity
}
export const TodoItem: React.FC<TodoItemProps> = ({ item }) => {
return (
<Row gap={"small"}>
{item.status.eq("TODO") && <IconCheckbox />}
{item.status.eq("DONE") && <IconCheckboxFilled />}
<Tag color={item.status.color}>{item.status.label}</Tag>
<TextInput>{item.title}</TextInput>
<IconClose />
</Row>
)
}
めちゃくちゃざっくりですが、こんな感じでしょうかね。
ここからが本題となります。
Entity.update
xReact.useState
のバケツリレー
私は冒頭の通り、バケツリレーが好きではなかったので、 recoil
を用いたusecase hookを作成し、どこからでも引数さえ渡せば使える状態を作っていました。
ベースコンポーネント + usecase hook によるラッパーがあれば、 user listを取得して選択することができる UsersSelectbox みたいなコンポーネントを作成することもでき、非常に気持ちよいものの、逆に言えば、所定の手続きを踏んでいないのに fetchしようとして失敗するコンポーネントが現れたり、ロジックとUIが混在したりする可能性をはらんでいました。
そこで、バケツリレーです。
先のコンポーネント例を拡張していきます。
export type TodoListProps = {
item: TodoListEntity
}
export const TodoList: React.FC<TodoListProps> = ({ item }) => {
const [todoList, setTodoList] = useState(item)
return (
<Box>
<Text>{item.name}</Text>
// ...
todoList.TodoItems.map(item => (
<TodoItem
key={todoList.todoItemId.value}
item={item}
onUpdate={(newItem) => { // value: TodoItemEntity
// 最新のtodoListに対して更新したいので、関数型です
setTodoList(
(todoList) => todoList.updateTodoItem(newItem.values())
)
}}
onRemove={(todoItemId) => {
setTodoList(
(todoList) => todoList.removeTodoItem(todoItemId.value)
)
}}
/>
))
)}
// ...
<Button
label={"Add TodoItem"}
icon={IconAdd}
onClick={() => {
setTodoList(
(todoList) => todoList.addTodoItem()
)
}}
/>
// ...
</Box>
)
}
export type TodoItemProps = {
item: TodoItemEntity
onUpdate: (newItem: TodoItemEntity) => void
onRemove: (todoItemId: TodoItemEntity["todoItemId"]) => void
}
export const TodoItem: React.FC<TodoItemProps> = ({ item, onUpdate, onRemove }) => {
return (
<Row gap={"small"}>
{item.status.eq("TODO") && <IconCheckbox onClick={() => {
onUpdate(item.update({
todoItemId: item.todoItemId.value,
status: "DONE"
}))
}} />}
{item.status.eq("DONE") && <IconCheckboxFilled onClick={() => {
onUpdate(item.update({
todoItemId: item.todoItemId.value,
status: "TODO"
}))
}} />}
<Tag color={item.status.color}>{item.status.label}</Tag>
<TextInput
onBlur={(e) => {
onUpdate(item.update({
todoItemId: item.todoItemId.value,
title: e.target.value
}))
}}
>
{item.title}
</TextInput>
<IconClose onClick={() => {
onRemove(item.todoItemId.value)
}} />
</Row>
)
}
ミソになっているのは、 setTodoList
を使ったstate更新をバケツリレーしている箇所です。
例えば、TodoItem.statusをTODO->DONEにする構造としては、以下のようになっています。
setTodoList(
(todoList) => todoList.updateTodoItem( // return TodoListEntity
todoItem.update({ // return TodoItemEntity
todoItemId,
status: "DONE",
})
)
)
各コンポーネント内の
const [title, setTitle] = useState(props.title)
useEffect(() => {
// ...
}, [])
const onChange = useCallback((e: Event) => {
if (e.target.value === props.title) return
setTitle(e.target.value)
}, [props.title])
みたいな、細かい設計がなくなってスッキリですね。
ロジックがentity側に行ったこともあって、entity内だけでアプリケーションのコアロジックを作ることだってできます。
Entity化するタイミング
おおよその場合、Page単位でFetchしていると思いますが、以下のようにfetchした直後にEntity化して、stateに入れる必要があります。
ApolloLink内でconveterを実装するのも良さそうです。
const { data, loading } = useQuery(hoge)
const [todoList, setTodoList] = useState<TodoListEntity>()
useEffect(() => {
if (data?.todoList) {
setTodoList(new TodoListEntity(data?.todoList))
}
}, [data])
if (loading || !todoList) return null
return (
// ...
)
POST等非同期処理との組み合わせ
GraphQLのApolloHooksで書いてみていますが、自作Fetchでも対応できると思います。
const [updateTodoItemMutation] = useUpdateTodoItemMutation()
const updateTodoItem = useCallback(async (input) => {
startLoading()
const newItemVaues = await updateTodoItemMutation(input)
endLoading()
return newItemVaues // type TodoItem
}, [updateTodoItemMutation])
//...
<TodoItem
key={todoList.todoItemId.value}
item={item}
onUpdate={async (newItem) => {
const newItemVaues = await updateTodoItem(newItem)
setTodoList(
(todoList) => todoList.updateTodoItem(newItemVaues)
)
}}
// try catch を使うパターン
onRemove={(todoItemId) => {
try {
await removeTodoItem(todoItemId)
setTodoList(
(todoList) => todoList.removeTodoItem(todoItemId.value)
)
} catch (e) {
throw e
}
}}
/>
useSWR
や何かしらのAPI hooks等を用いてもいいし、自分でAPI通信用のリストを作っても良いと思います。
課題・メリットなど
そもそも、この構造の課題はいくつか存在します。
- 大元のentity毎アップデートすることになるので、関係のない箇所まで再レンダリングしてしまうことが多々ある
- memo化でpropsの内容判別で多少は抑えられる
- もしくは各コンポーネントでuseState & useEffectでprops更新を確認する
- entityめっちゃ書かないといけない
- ガワにまつわるロジックをentityに入れてしまうと本末転倒。router受け取ったりしちゃダメ
- パフォーマンスや、インスタンスの設計等、考える範囲が広がる
その分メリットもたくさんあります。
- ロジックをUIから切り離せる
- サーバー側もTypeScriptなら、全く同じentityを使うこともできる
- ちなみに当社では実験的にこのパターンで書き始めており、GraphQLやprismaと相性よく使えています
- dbから取得 → entity化でバリデーション → 返却時に
.values()
みたいな流れです
- コンポーネント内の記述が宣言的になる
- entityにまつわる条件文が非常に少なくなります
-
id.eq(value)
など - カラリングも大抵はデザイン側で配色決めてるので、entityにしちゃえる(
status.color
) - filterがいる配列などは、親元でgetterにできる(例えば、
todoList.doneTodoItems
)
- メソッドチェーンが使える
-
todoList.addTodoItem().removeTodoItem(x)
等としてから setTodoListに入れることがしやすい
-
まとめ
ずらっとコードを書いてみました。
フロントの責務としてのコンポーネント設計では、そもそも本質的に「キレイにバケツできるほど設計が良い」類のものだと思っています。(それでも面倒で嫌いではありましたが・・・)
思い返せば、
- データ
- 表示ロジック
- ドメインの状態
などを一緒くたにバケツしていたことでわかりづらかったのでは、と考え直しているところでもあります。
entityを渡す、という発想は、コンポーネント設計としてはあまりよろしくないはずです。
なぜなら、entityの形をしていないと文字列一つ表示できないのですから。
この設計だと、propsを最小に保つことはできません
ただ、ごっちゃになっていたpropsが私の中では明確に区分できるようになり、onUpdate
などで渡す際にビジネスロジックと表示ロジックをうまく結合できているように思います。
(※個人的にはこの結合自体も別のところでまとめてやりたいところですが)
当プロダクトでも、プロダクトやチームの成長に伴って、コードが何度も変わってきており、「できるだけメンテしやすいコード」を模索しては、3ヶ月後にはメンテしづらいものになっていたりします。
最強のコード・銀の弾丸はその時々によって変わるもので、確かなことは
「フットワークが軽い」
「投資体制がある」
「意思疎通がしやすい」
というチームづくりにこそ、骨太な開発環境が宿るのではないかと思います。
冒頭にも書きましたが、β前の少人数のフェーズにて、設計・技術選定・採用・開発文化から経営まで、さまざまな課題を手伝ってくれるアナタをお待ちしています。
Discussion
TodoListEntity.addTodoItem
の記述を修正しました