🦁

Recoilで始めるお手軽フロントエンドDDD

2021/02/23に公開

はじめに

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

  1. Atom の取得の際に純粋関数を通すことで新たな値として扱えます。
  2. 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') // 再描画はされない
...

僕の頭の中 ↓

  1. スプレッド構文で簡単に書けるな...
  2. インスタンスのfavoritesの中だけを編集したい、name, ageをいじる必要は無いから簡潔に書きたい
  3. クラスにsetter生やしたるか!
  4. 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 を扱う場合にContextReduxを用いるとコードの記述量が増え、hooks のスコープも広くなってしまいます。

具体的には、hooks ファイルの中に以下のような処理が混在し、ファットになってしまうと思います。

  • ContextReduxからのリストアや更新
  • View で用いる state の更新
  • 更新の際に行う複雑な処理や副作用のある処理
  • etc...

atomFamily と selectorFamily, useRecoilCallback を活用してスマートな hooks を作ろう!

  • タイトル(title)
  • 詳細(description)
  • ステータス(isDone)

の値を持つ Todo Entity に

  1. API レスポンスのデータをセット
  2. 全件表示
  3. isDoneフラグのトグル
  4. 新しい 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,
});

もし、引数によってデフォルト値を変えたい場合はdefaultselectorFamilyをセットするという応用も可能です。

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