Recoilで始めるお手軽フロントエンドDDD
はじめに
React でコードを書いているとき、ある程度の規模のプロジェクトになると class
を使いたくなりませんか?
僕はバックエンドで DDD を用いている場合は、同じように Entity を宣言して使いたい場面が多々ありました。
しかし、React のプロジェクトで getter/setter
を用いて値を変更しても残念ながら再描画はされません。
Redux
の処理内や hooks 内で中途半端に class
を用いても結局 DDD の設計思想をうまくコードに落とし込むのは難しいです...(どなたがご享受ください!)
今回は僕が Recoil
を使ってフロントエンド DDD を手軽に実装する方法を共有したいと思います。
また、まだあまり技術記事で紹介されていないTypescript
でのサンプルコードやatomFamily
, selectorFamily
の紹介も多く含んでいるので参考にしていただければと思います!
Recoil の簡単な紹介
hooks を普段から使っている方は、atom
, selector
に関しては簡単に理解いただけるかなと思います。
公式ドキュメントを読んだことがある方や、何かしらのRecoil
に関する技術記事を読んだことがある方は、 atom
, selector
の章は飛ばして次の章に進んでいただいてもいいと思います。
Recoil 紹介(Atom, Selctor)
Atom
global な state 宣言ができます
import { atom } from "recoil";
type User = {
name: string;
age: number;
};
// state宣言部
// useStateと同じ使用感です
// 使い方が異なる点は、keyにユニークな値を指定する点のみ
const stateUser = atom<User | null>({
key: "state-user",
default: null,
});
// in Component
...
const user = useRecoilValue(stateUser) // 値のreadにはuseRecoilValueを使います
const setUser = useSetRecoilValue(stateUser) // 値のwriteにはuseSetRecoilValueを使います
// or
const [user, setUser] = useRecoilState(stateUser) // read, writeセットで宣言する場合はuseRecoilStateを使います。useStateと同じように使えます
...
Selector
- Atom の取得の際に純粋関数を通すことで新たな値として扱えます。
-
get
内に Api 等の非同期処理を書くこともできます。必ずしも Atom の値を返す必要はありません。(応用・今回は省略)
import { atom, selector, DefaultValue } from "recoil";
type User = {
name: string;
age: number;
};
// atom
// 数値カウントを保持
const stateCount = atom<number>({
key: "state-count",
default: 1,
});
// selector
// get: stateCountの2倍の値を返す
// set: stateCountに引数の2倍の値をセットする
const stateDoubleCount = selector<number>({
key: "state-double-count",
get: ({ get }) => { // getプロパティは必須 引数のgetで他のstateを取得できる
const count = get(stateCount);
return count * 2; // stateCountの値を2倍にして返す
},
set: ({ set }, newValue) => { // setプロパティは任意 引数にsetでstateの更新を行う 第二引数に新しい値を取る
if (newValue instanceof DefaultValue) return; // DefaultValue型のときはresetが呼ばれたとき
set(stateCount, newValue * 2); // 引数の2倍の値をstateCountにセット
},
});
// in Component
...
const doubleCount = useRecoilValue(stateDoubleCount)
const setDoubleCount = useSetRecoilValue(stateDoubleCount) // setプロパティを設定していない場合はエラーが起こる
// or
const [doubleCount, setDoubleCount] = useRecoilState(stateDoubleCount) // setプロパティを設定していない場合はエラーが起こる
...
Recoil を使って状態変更検知可能なクラスインスタンスのように振る舞う
ここからが本題でです。まずはクラスインスタンスを使ってうまく再描画ができないパターンを見てみましょう。
type Favorite = string;
type User = {
name: string;
age: number;
favorites: Favorite[];
};
class User {
name: string;
age: number;
construcrot({ name, age, favorites }: User) {
this.name = name;
this.age = age;
this.favorites = favorites;
}
set addFavorite(newFavorite: Favorite) {
const computedNewFavorite = ...// 色々前処理がある
this.favarites = this.favorites.push(computedNewFavorite);
}
}
// in Component
...
const [user, setUesr] = useState<User>(new User({name: '', age: 0, favorites: []}))
user.addFavorite('hoge') // 再描画はされない
...
僕の頭の中 ↓
- スプレッド構文で簡単に書けるな...
- インスタンスの
favorites
の中だけを編集したい、name
,age
をいじる必要は無いから簡潔に書きたい - クラスに
setter
生やしたるか! -
setter
で編集しても画面上の値変わらんやんけ・・・
このパターンだとクラスインスタンスのメソッドを呼んでも再描画処理が走りません。
type Favorite = string;
type User = {
name: string;
age: number;
favorites: Favorite[];
};
const useUser = () => {
const [user, setUesr] = useState<User>(new User({name: '', age: 0, favorites: []}))
const addFavorite = () => {
const computedNewFavorite = ...// 色々前処理がある
setUser({...user, favorites: user.favorites.push(computedNewFavorite)})
}
return {
user,
setUser,
addFavorite
}
}
// in Component
...
const {user, setUser, addFavorite} = useUser()
addFavorite('hoge')
...
このように hooks に処理を押し込むのが無難でしょうか?
この程度のロジック量なら custom hooks を使えば思い通りの処理ができ、コードも読みやすいです。
しかし、hooks 内でグローバル state を扱う場合にContext
やRedux
を用いるとコードの記述量が増え、hooks のスコープも広くなってしまいます。
具体的には、hooks ファイルの中に以下のような処理が混在し、ファットになってしまうと思います。
-
Context
やRedux
からのリストアや更新 - View で用いる state の更新
- 更新の際に行う複雑な処理や副作用のある処理
- etc...
atomFamily と selectorFamily, useRecoilCallback を活用してスマートな hooks を作ろう!
- タイトル(title)
- 詳細(description)
- ステータス(isDone)
の値を持つ Todo Entity に
- API レスポンスのデータをセット
- 全件表示
-
isDone
フラグのトグル - 新しい TODO の追加
をするサンプルを用いて説明します。
サンプルコード
Recoil Frontend DDD Sample
atomFamily, selectorFamily とは
atom
, selector
と基本の動作は変わりませんが、引数を受け取ることができます。
そして、引数からユニークなキーを自動的に生成してくれます。class
のように扱えて便利です。
type Hoge = string;
type HogeId = number;
// genericsの第一引数にatomの値,第二引数に引数の型を指定
const stateHoge = atomFamily<Hoge, HogeId>({
key: "state-hoge",
default: "",
});
// これら2つは異なる状態を持つ
// 同じatomFamilyを用いて同じ型を持つ別の値の読み取り・書き込みができる
const [hoge1, setHoge1] = useRecoilState(stateHoge(1));
const [hoge2, setHoge2] = useRecoilState(stateHoge(2));
// 引数はObjectでも可。idかid2が違えば別のstateとして扱われる
const stateFuga = atomFamily<string, { id: string; id2: number }>({
key: "state-fuga",
default: "",
});
const [fuga, setFuga] = useRecoilState(stateFuga({ id: "fizz", id2: 10 }));
Recoil で行うドメイン駆動設計イメージ
コード上でのドメイン駆動設計との関係は以下のようになっています
ドメイン駆動設計での名前 | 実装(Recoil) | 補足 |
---|---|---|
Value Object | atomFamily | Entity の identifier を key として値を保持する |
Entity | selectorFamily | Entity を identifier を受け取って Entity の値を返すまたは更新する |
Repository | custom hooks, useRecoilCallback | Entity の作成・更新・削除を行う |
atomFamily, selectorFamily による Entity,Value Object の宣言
型情報
export type TodoId = number;
export type TodoTitle = string;
export type TodoDescription = string;
export type TodoIsDone = boolean;
export type Todo = {
id: TodoId;
title: TodoTitle;
description?: TodoDescription;
isDone: TodoIsDone;
};
Value Object の宣言
型通りに atomFamily を宣言します。
export const stateTodoTitle = atomFamily<TodoTitle, TodoId>({
key: "state-todo-title",
default: "",
});
export const stateTodoDescription = atomFamily<TodoDescription, TodoId>({
key: "state-todo-description",
default: "",
});
export const stateTodoIsDone = atomFamily<TodoIsDone, TodoId>({
key: "state-todo-is-done",
default: false,
});
もし、引数によってデフォルト値を変えたい場合はdefault
にselectorFamily
をセットするという応用も可能です。
export const stateTodoTitle = atomFamily<TodoIsDone, TodoId>({
key: "state-todo-title",
default: selectorFamily<string, TodoId>({
key: "state-todo-title-default-value",
get: (todoId) => () => {
return `todo-${todoId}`;
},
}),
});
// in Component
...
const todo = useRecoilValue(stateTodoTitle(100))
console.log(todo) // Log: todo-100
...
Entity の宣言
Value Object をまとめて返します。
set
では Value Object の更新処理と、必要に応じてリセット処理や他の state に対する副作用を記述します。
export const stateTodo = selectorFamily<Todo, TodoId>({
key: "state-todo",
get: (todoId) => ({ get }) => {
return {
id: todoId,
title: get(stateTodoTitle(todoId)),
description: get(stateTodoDescription(todoId)),
isDone: get(stateTodoIsDone(todoId)),
};
},
set: (todoId) => ({ get, set, reset }, newValue) => {
if (newValue instanceof DefaultValue) {
// NOTE: DefaultValue型のときはresetから呼ばれたとき
reset(stateTodoTitle(todoId));
reset(stateTodoDescription(todoId));
reset(stateTodoIsDone(todoId));
return;
}
set(stateTodoTitle(todoId), newValue.title);
newValue.description &&
set(stateTodoDescription(todoId), newValue.description);
set(stateTodoIsDone(todoId), newValue.isDone);
if (get(stateTodoIds).find((todoId) => todoId === newValue.id)) return; // NOTE: 更新のときはskip
set(stateTodoIds, (prev) => [...prev, newValue.id]); // NOTE: 全件取得・全リセット用にIDの配列を保持しておくと便利
},
});
Entity が複数存在する場合には、全件取得用に Family の引数のリスト(stateTodoIds
)のatom
と, 全件取得用のselector
(stateTodos
)を用意します。
export const stateTodoIds = atom<TodoId[]>({
key: "state-todo-ids",
default: [],
});
export const stateTodos = selector<Todo[]>({
key: "state-todos",
get: ({ get }) => {
const todoIds = get(stateTodoIds);
return todoIds.map((todoId) => get(stateTodo(todoId)));
},
}
Custom Hooks の宣言
値の読み取りは各 View で必要なものをuseRecoilValue
を用いて行います。
waitForAll
,waitForAny
などまとめてRecoil
の state を取得するの機能が用意されているので hooks 内では各 Entity の追加・更新・削除等の関数のみ実装します。
Recoil
の関数を作成する場合は、useCallback
と同じようにパフォーマンスを向上させるためのuseRecoilCallback
関数が用意されているのでそちらを使います。
export const useTodo = () => {
// NOTE: サーバからデータを取得してstateに反映するときなど
const setFromArray = useRecoilCallback(({ set }) => (todoArray: Todo[]) => {
todoArray.forEach((todo) => {
set(stateTodo(todo.id), todo);
});
});
const upsertTodo = useRecoilCallback(({ set }) => (newTodo: Todo) => {
set(stateTodo(newTodo.id), newTodo);
});
const removeTodo = useRecoilCallback(({ set, reset }) => (todoId: TodoId) => {
reset(stateTodo(todoId));
set(stateTodoIds, (prev) => prev.filter((id) => id !== todoId));
});
return {
setFromArray,
upsertTodo,
removeTodo,
};
};
おわりに
atomFamily
, selectorFamily
が引数によってユニークな値を返すという点がドメイン駆動設計の Entity が identifier によって識別され、Value Object は変化する点に似ており実装してみました。
Recoil
はまだ本番運用するべきでないという声もありますが、今回紹介したメソッドについてはほぼ完成形であるため、全然アリかなという印象を持っています。
まだ安定版でないけれどatom
が変更された際に副作用の処理を行うといったメソッドも着々と開発が進んでおり、本リリースがとても楽しみです。
Discussion