🐶

Reactのバケツリレーを効果的に使えるEntity型コンポーネント(TypeScript)

13 min read 1

はじめまして。
株式会社digsasでCPOを務めるmorika2と申します。

当社は「変遷するビジネスに、IT投資のモノサシを」作る、というミッションを元に、IT投資におけるユーザー企業の導入設計力を向上させるためのプロダクトを開発しています。
(近々大きなアップデートを予定しており、この挨拶も最後です)

有り難いことに以前書いたフルスタックなTypeScript環境 (Blitz.js) でDDDするが未だにぼちぼち伸びておりまして、技術面の試行錯誤をしている会社感!チーム感!をアピールするためにも、検証結果やプロダクションでのワークパフォーマンス等、書いていけたらな〜〜と思ってます。

https://zenn.dev/repomn/articles/2ecaebd9ef6bfa

現在チームは、私+2名の分野のプロ(副業)と少ない中、B2B SaaSとして派生展開や多くの仮説検証を繰り返していきたいこともあり、依存管理のしやすいmonorepoへの移行(npm v7からのworkspaceを使ってみています)を行っています。

大型アップデートの後には、オープンβに向けた正式リリースおよびグロースが待ち受けております。

CEO石井も私もTech投資に重要性を感じており、今のうちにベースとなる社内〜開発投資等を行って行きたく、設計・技術選定・採用・開発文化などを皮切りに、経営にも茶々を入れていきたい何でもござれでやってみたいアナタ今すぐ採用ページへお越しください。

https://digsas.com/recruit

バケツリレー嫌よね

まずそもそも私は、バケツリレーが嫌いでした・・・。
まあ結構いらっしゃるんじゃないでしょうか、嫌いな方。

可読性とか再レンダリングのしやすさとか、memo化だとか色々ありますけど、私が一番嫌いなのは、何度も同じようなpropsを書くことが微妙でした。

しかし、本記事で伝えたい最終ゴールは「書いたほうが結果メリット多くない?」というところにあります。

コンポーネントの設計思想にも依るとは思いますが、当プロダクトでは結果として、なかなかに捗る構造となりました。

TodoList で考える EntityModel

ここからは例のごとくTodoListでざっくりめのコードも併記していきます。

GraphQL等でAPIスキーマが決まっている前提です。

schema.gql
type TodoList {
  todoListId: UUID!
  name: String!
  TodoItems: [TodoItem!]
}

type TodoItem {
  todoItemId: UUID!
  status: TodoItemStatusEnum!
  title: String!
}

enum TodoItemStatusEnum {
  TODO
  DONE
}

grahql-codegenで生成済みのファイルが以下
enumsAsTypes: true を設定しています)

generated.ts
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で返却されるデータが決まったら、フロントでバケツリレーされるモデルを設計します。

todoList.entity.ts
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で値をパースできるようにしておきます。
また、以下のようなモデル毎の振る舞いを設定します。

todoList.entity.ts
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 もモデリングします。

todoItem.entity.ts
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

global.d.ts
/**
 * 指定したキーだけをrequiredにする = 指定したキー以外をoptionalにする
 */
export type PartialRequired<T, K extends keyof T> = Pick<T, K> & Partial<T>
id.ts
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ベースでバリデーションができるライブラリです。フォーム等とも相性がよく非常に重宝しています。

todoItemStatus.ts
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ベースのコンポーネントを作ってしまいましょう!
スタイリングやコンポーネント名は適当です、笑

todoList.tsx
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>
  )
}
todoItem.tsx
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.updatexReact.useStateのバケツリレー

私は冒頭の通り、バケツリレーが好きではなかったので、 recoil を用いたusecase hookを作成し、どこからでも引数さえ渡せば使える状態を作っていました。

ベースコンポーネント + usecase hook によるラッパーがあれば、 user listを取得して選択することができる UsersSelectbox みたいなコンポーネントを作成することもでき、非常に気持ちよいものの、逆に言えば、所定の手続きを踏んでいないのに fetchしようとして失敗するコンポーネントが現れたり、ロジックとUIが混在したりする可能性をはらんでいました。

そこで、バケツリレーです。
先のコンポーネント例を拡張していきます。

todoList.tsx
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>
  )
}
todoItem.tsx
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でも対応できると思います。

todoList.tsx
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ヶ月後にはメンテしづらいものになっていたりします。

最強のコード・銀の弾丸はその時々によって変わるもので、確かなことは
「フットワークが軽い」
「投資体制がある」
「意思疎通がしやすい」
というチームづくりにこそ、骨太な開発環境が宿るのではないかと思います。

冒頭にも書きましたが、β前の少人数のフェーズにて、設計・技術選定・採用・開発文化から経営まで、さまざまな課題を手伝ってくれるアナタをお待ちしています

https://digsas.com/recruit

Discussion

ログインするとコメントできます