はじめに
React でコードを書いているときに、ある程度大きな規模のプロジェクトになるクラスインスタンスの getter/setter
で値を変更したときにも再描画して欲しいと感じたことはありませんか?
フロントエンド DDD をする際に、バックエンド Entity の形を揃えたいけどクラスで宣言すると再描画がされないため、state に入れ直す等の操作が必要になり、View 層での取り回しに困りますよね...
今回は僕が Recoil
を使ってコードを書いてみて、プチフロントエンド DDD に似たことを実現できたので気づきを共有していきたいと思います。
Recoil の簡単な紹介
公式ドキュメントや検索して出てきた技術記事を読んでいただければ hooks を使ったことのある人なら、Atom
, Selector
に関しては簡単に理解いただけるかなと思います。
既にRecoil
の使い方を知っているよという人はAtom
, Selector
の章は飛ばしても ok です。
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
const setUser = useSetRecoilValue(stateUser) // 値のwrite関数
// or
const [user, setUser] = useRecoilState(stateUser) // read, writeセットで useStateと同じ形
...
Selector
-
Atom
の取得の際に純粋関数を通すことで新たな値として扱えます。 - Api 等の非同期処理を書いて使うこともできます(応用・今回は省略)
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<boolean>({
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(newValue * 2); // 引数の2倍の値をstateCountにセット
},
});
// in Component
...
const doubleCount = useRecoilValue(stateDoubleCount)
const setDoubleCount = useSetRecoilValue(stateDoubleCount) // setプロパティを設定していない場合はエラーが起こる
// or
const [doubleCount, setDoubleCount] = useRecoilState(stateDoubleCount) // setプロパティを設定していない場合はエラーが起こる
...
Recoil を使って状態変更検知可能なクラスインスタンスのように振る舞う
本題ですが、まずはクラスインスタンスを使ってうまく行かないパターンを見てみましょう。
僕がよく悩んでいたのはクラスインスタンスが配列の値を持つときでした。
頭の中では、以下のような考えを持っていました。
- スプレッド構文で簡単に書けるな...
- インスタンスの
favorites
の中だけを編集したい、name
,age
をいじる必要は無いから簡潔に書きたい - クラスに
setter
生やしたるか! -
setter
で編集しても画面上の値変わらんやんけ・・・
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.setUser('hoge')
...
結局このパターンだとインスタンスとして扱うことはできないので型だけ当てたオブジェクトを hooks 等でラップしてその中にロジックを書くような実装に落ち着くと思います。
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 を使うだけでコードが綺麗になります。
ただし、hooks 内でグローバル state を扱うためにContext
やRedux
を使うとコードの記述量が増え、hooks のスコープが広くなってしまいます。
具体的には、hooks ファイルの中に以下のような様々な処理が混在し、ファットになってしまうと思います。
-
Context
やRedux
からのリストアや更新 - View で用いる state の更新
- 更新の際に行う複雑な処理や副作用のある処理
- etc...
atomFamily と selectorFamily, useRecoilCallback を活用してスマートな hooks を作ろう!
- タイトル
- 詳細
- ステータス
の値を持つ Todo エンティティに
- API レスポンスのデータをセット
- 全件表示
-
isDone
フラグのトグル - 新しい TODO の追加
をするサンプルを用いて説明します。
サンプルコード
Recoil Frontend DDD Sample
atomFamily, selectorFamily とは
atom
, selector
と基本の動作は変わりませんが、引数を受け取ることができます。
type Hoge = string;
type HogeId = number;
// genericsの第一引数にatomの値,第二引数に引数の型を指定
const stateHoge = atomFamily<Hoge, HogeId>({
key: "state-hoge",
default: "",
});
// これら2つは異なるstateだが同じインターフェースで読み取り・書き込みができる
const [hoge1, setHoge1] = useRecoilState(stateHoge(1));
const [hoge2, setHoge2] = useRecoilState(stateHoge(2));
// 引数はObjectでも可
const stateFuga = atomFamily<string, { id: string; id2: number }>({
key: "state-fuga",
default: "",
});
const [fuga, setFuga] = useRecoilState(stateFuga({ id: "fizz", id2: 10 }));
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 の宣言
型通りに宣言だけを行います。
引数によって値を変えたい場合はdefault
にselectorFamily
をセットすることもできるので応用してください。
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,
});
Entity の宣言
get で 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 の引数のリストのatom
と, 全件取得用のselector
を用意します。
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 で必要なものを読み出します。
waitForAll
,waitForAny
などの機能が用意されており、state の読み出しが完了したら表示するといったことができるため、値の読み出しは各 view で行うのがよいです。
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
が変更された際に副作用の処理を行うといったメソッドも着々と開発が進んでおり、本リリースがとても楽しみです。