LoginSignup
7
7

More than 3 years have passed since last update.

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

Posted at

はじめに

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

  1. Atomの取得の際に純粋関数を通すことで新たな値として扱えます。
  2. 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 を扱うためにContextReduxを使うとコードの記述量が増え、hooks のスコープが広くなってしまいます。

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

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

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

  • タイトル
  • 詳細
  • ステータス

の値を持つ Todo エンティティに

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

型通りに宣言だけを行います。

引数によって値を変えたい場合はdefaultselectorFamilyをセットすることもできるので応用してください。

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が変更された際に副作用の処理を行うといったメソッドも着々と開発が進んでおり、本リリースがとても楽しみです。

7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7