📝

Recoilで状態管理やってみた

2022/12/23に公開

こんにちは!

本記事は、TECH PLAY女子部 Advent Calendar 2022の23日目の記事です!

Reactにおける状態管理に関係するライブラリには、Reduxなどいくつかありますよね。
今回は、Recoilを扱いたいと思います。

最近、業務でRecoilを少し触る機会がありました。
折角なので、業務で扱ったもの以外にも基本的な部分は押さえておきたいな〜と思って、勉強してみました。
本記事では、Recoilについて、簡単なToDoリストを作りながら、ご紹介していこうと思います!

Recoilとは

Recoilとは、Meta(Facebook)が開発を行なっている、Reactの状態管理ライブラリです。

https://github.com/facebookexperimental/Recoil/#readme

公式のGitHubリポジトリを確認すると、experimental(実験的)となっており、正式リリースはまだ行われておりません。

Recoilの導入

1. インストール

まずは、インストールを行います。

使用しているパッケージマネージャがnpmの場合は、以下のコマンドでインストールします。

npm install recoil

yarnを使用している場合は、以下のコマンドです。

yarn add recoil

2. Recoilを使用できるようにする

Recoilは、インストールしただけでは使えません。
ルートコンポーネント(App.tsxなど)に、以下のように<RecoilRoot>要素を記述します。

import { RecoilRoot } from 'recoil';

const App = () => {
  return (
    <RecoilRoot>
      <div>
        <h1>Recoilサンプル</h1>
      </div>
    </RecoilRoot>
  );
};

export default App;

これで、Recoilを使う準備は完了です!

Recoilの基本の使い方

ここからは、早速、Recoilの基本的な使い方について紹介していきます。

1. atomとselector

まずは、atomとselectorについてです。

①atomとは

atomは、一意のkeyとデフォルト値を持ち、stateを管理しています。
Reduxだと、基本的にはstateは1箇所にまとめて管理する必要がありますが、Recoilだと、それぞれ管理することができます。管理したいstateごとにatom関数を使って作成するだけです。

公式ドキュメントにて、以下のような記載があります。

Atoms contain the source of truth for our application state.

出典)Recoil 公式ドキュメント Basic Tutorial Atoms

atomは、加工されていない、正式なstateの値を管理しています。(なぜ、このような言い方をしているのかは次の項目で何となくわかるかも?)

例えば、以下のように使います。

import { atom } from 'recoil';

const sampleAtom = atom<string[]>({
    key: 'sampleAtom',
    default: [
        'sample1',
        'sample2',
        'sample3'
    ]
});

export {
    sampleAtom 
};

上記の例では、文字列を要素とする配列をデフォルト値として持つatomを作成しています。

②selectorとは

selector関数を使い、atomや他のselectorの値を使って計算や加工を施した値を返したり、atomを更新したりできます。atomから派生した、派生stateと呼びます。(公式ではderived stateと記載されている。)

もう少し詳しく説明してみます。
getプロパティは、atomや他のselectorの値を利用して何らかの計算や加工を施した値を返します。こちらは、読み取り専用(RecoilValueReadOnly)の値です。また、setプロパティは、atomへ書き込みを行い、更新することができます。getプロパティは必須ですが、setプロパティはオプションなので、不要であれば使わないで大丈夫です。

例えば、以下のように使います。

import { atom, selector } from 'recoil';

const sampleAtom = atom<string[]>({
    key: 'sampleAtom',
    default: [
        'sample1',
        'sample2',
        'sample3'
    ]
});

// selector
const sampleSelector = selector<string[]>({
    key: 'sampleSelector',
    get: ({ get }) => {
        return get(sampleAtom);
    }
});

export {
    sampleAtom,
    sampleSelector
};

作成したselectorsampleSelectorに注目してください。
ここでは、state(atomsampleAtom)の値を取得しています。

get(state)

get関数に、state(atomもしくはselector)を渡すことで、指定したatomもしくはselectorの値を取得することができます。

setプロパティを利用する場合、例えば以下のような記述になります。

const sampleSelector = selector<string[]>({
    key: 'sampleSelector',
    get: ({ get }) => {
        return get(sampleAtom);
    },
    set: ({ get, set }, newValue) => {
        // 例えば、newValueとして「さんぷる」が入ってくるとする。
        set(sampleAtom, [...get(sampleAtom), newValue]);
    }
});

setプロパティを利用してatomへの書き込みを行う場合、実行にはuseSetRecoilStateフックを利用します。(useSetRecoilState自体の使い方については、後述します。)
このフックが返す関数を使ってこのselectorを実行する時に、引数として値を渡すと第2引数(上記コードで言うnewValueにあたる)にその値が入ってくることになり、その入ってきた値を使ってstateの更新を行うことができます。

2. stateの取得と更新

ここからは、stateの取得・更新で使うフックをいくつかご紹介します。
使うatomは先ほどと同じ、以下になります。

import { atom } from 'recoil';

const sampleAtom = atom<string[]>({
    key: 'sampleAtom',
    default: [
        'sample1',
        'sample2',
        'sample3'
    ]
});

export {
    sampleAtom 
};

①useRecoilValue

useRecoilValueは、stateを返すだけです。
stateが更新されるたびに、再描画されます。

useRecoilValue(state)

引数に、state(atomもしくはselector)を渡します。

例えば、以下のように使います。

import { useRecoilValue } from 'recoil';
import { sampleAtom, sampleSelector } from '../recoil/sampleState';

const Sample = () => {
    // useRecoilValue
    const sampleAtomVal = useRecoilValue<string[]>(sampleAtom);

    return (
        <div>
            <ul>
                {sampleAtomVal.map((sampleAtom: string, index: number) => (
                    <li key={index}>{sampleAtom}</li>
                ))}
            </ul>
        </div>
    );
};

export default Sample;

useRecoilValueの引数にatomsampleAtomを渡しています。
画面では、以下の画像のように、sampleAtomが持つstateの配列が展開されて表示されます。

②useSetRecoilState

useSetRecoilStateは、stateを更新するだけです。新しい値をセットするだけで、新しい値を返したりはしません。

useSetRecoilState(state)

引数に、state(atomもしくはselector)を渡します。

例えば、以下のように使います。

import { useRecoilValue, useSetRecoilState } from 'recoil';
import { sampleAtom } from '../recoil/sampleState';

const Sample = () => {
    const sampleAtomVal = useRecoilValue<string[]>(sampleAtom);

    // useSetRecoilState
    const setSampleAtomVal = useSetRecoilState<string[]>(sampleAtom);
    
    // 新しい値をセットして、stateを更新
    const changeSampleAtomVal = (): void => {
        // 新しい値
        const newItem: string = 'サンプル4';

        // 型が文字列を要素とする配列なので、配列にする
        const newArray : string[]= [newItem];

        // atomに新しい値を入れた配列をセットして、stateを更新
        setSampleAtomVal(newArray);
    };

    return (
        <div>
            <ul>
                {sampleAtomVal.map((sampleAtom: string, index: number) => (
                    <li key={index}>{sampleAtom}</li>
                ))}
            </ul>

            {/* このボタンを押すと、stateを更新できる */}
            <button onClick={changeSampleAtomVal}>チェンジ!</button>
        </div>
    );
};

export default Sample;

useSetRecoilStateの引数にatomsampleAtomを渡しています。
「チェンジ!」ボタン押下前と後の画面では、以下の画像のように表示されます。

【ボタン押下前】

【ボタン押下後】

「チェンジ!」ボタン押下後には、stateの配列の要素は、サンプル4のみになっています。
useSetRecoilStateでは、stateの更新はできるようになりますが、値は返しません。そのため、stateの取得には、useRecoilValueを使っています。

③useRecoilState

useRecoilStateは、stateの取得と更新の両方ができます。新しい値でstateを更新して、更新された値を取得することができます。

useRecoilState(state)

引数に、state(atomもしくはselector)を渡します。

例えば、以下のように使います。

import { useRecoilState } from 'recoil';
import { sampleAtom } from '../recoil/sampleState';

const Sample = () => {
    // useRecoilState
    const [sampleAtomVal, setSampleAtomVal] = useRecoilState(sampleAtom);

    // 新しい値をセットして、stateを更新
    const changeSampleAtomVal = (): void => {
        // 新しい値
        const newItem: string = 'new sample';

        // 現在のstateの配列に、新しい値を追加
        const newArray : string[]= [...sampleAtomVal, newItem];

        // 新しい値が追加された配列をセットして、stateを更新
        setSampleAtomVal(newArray);
    };

    return (
        <div>
            <ul>
                {sampleAtomVal.map((sampleAtom: string, index: number) => (
                    <li key={index}>{sampleAtom}</li>
                ))}
            </ul>

            {/* このボタンを押すと、stateを更新できる */}
            <button onClick={changeSampleAtomVal}>チェンジ!</button>
        </div>
    );
};

export default Sample;

useSetRecoilStateの引数にatomsampleAtomを渡しています。
「チェンジ!」ボタン押下前と後の画面では、以下の画像のように表示されます。

【ボタン押下前】

【ボタン押下後】

「チェンジ!」ボタン押下後には、stateの配列の要素に、new sampleが追加されています。
useRecoilStateは、stateの更新だけでなく取得もできるので、ここではuseRecoilValueは不要です。

Recoilを使ってToDoリストを作ってみる!

ここからは、上記でご紹介したことについて、ToDoリストを作りながら、もう少し細かく見ていきたいと思います。

Recoil公式ドキュメントにおいても、ToDoリストアプリを作成するチュートリアルが紹介されています。

こういうのを作ります。

※冒頭でお伝えしていたように、スタイリング関係については触れません

1. atomを用意

まずは、stateを管理するatomを用意するところから始めます。

import { atom } from 'recoil';

export type TaskAtomType = {
    id: number;
    title: string;
    edit: boolean;
    isCompleted: boolean;
};

export type AllTasksAtomType = TaskAtomType[];

// 全てのタスクを格納しておくatom
const allTasksAtom = atom<AllTasksAtomType>({
    key: 'allTasksAtom',
    default: []
});

全てのタスクを配列にして格納しておくためのatomallTasksAtomを作成します。
それぞれのタスクについては、idタスク名編集可否の真偽値完了状況の真偽値を持つオブジェクトになっています。
タスクの編集については、今回は触れません。

2. タスクを追加する

それではまず、タスク追加処理をやっていきたいと思います。
フォームに入力されたタスクを追加していきます。

以下のようなselectorを用意します。

// タスク追加のselector
const taskAddSelector = selector<AllTasksAtomType>({
    key: 'taskAddSelector',
    get: ({ get }) => {
        return get(allTasksAtom); // 読み取り専用
    },
    set: ({ get, set }, title: any) => {
        const currentAtom: AllTasksAtomType = get(allTasksAtom); // 現在のstate(atom)を取得
        const currentAtomLength: number = currentAtom.length; // 現在のstate(atom)の配列の長さを取得
        let addTaskObj: TaskAtomType | undefined = undefined; // set関数実行時に渡す
        
        if (currentAtomLength === 0) {
            /* 
                現在のstate(atom)の配列に何も要素が入っていない場合、
                追加するタスクのidは1とする
            */
            addTaskObj = {
                id: 1,
                title: title,
                edit: false,
                isCompleted: false
            };
        } else {
            /* 
                現在のstate(atom)の配列に1つ以上要素が入っている場合、
                追加するタスクのidは現在の配列の長さ+1とする(最後尾に追加する)
            */
            addTaskObj = {
                id: currentAtomLength + 1,
                title: title,
                edit: false,
                isCompleted: false
            };
        }

        set(allTasksAtom, [...currentAtom, addTaskObj]);
    }
});

ここでは、setプロパティを利用しています。atomを更新できます。
引数として渡されているtitleには、入力された値が入ってきます。

if文の後にある、set関数に注目してみます。

set(`更新したいstate`, `新しい値`);

第1引数には、更新したいstate(atom)のallTasksAtomを渡します。そして、第2引数には、更新後の新しい値(上記で言う、タスクが追加された状態の配列)を渡します。
これにより、指定したstateに新しい値が書き込まれ、stateが更新されます。

それでは、タスク追加フォーム側の処理もやっていきます。
次のように、AddForm.tsxファイルにAddFormコンポーネントを作っていきます。

import { useForm } from 'react-hook-form';
import { useSetRecoilState } from 'recoil';
import { taskAddSelector } from '../recoil/recoilState';
import { AddTaskType } from '../types/addTasksType';
import { formContent, registerButton } from '../styles/addForm';

const AddForm = () => {
    const { register, handleSubmit, reset } = useForm({
        defaultValues: {
            title: ''
        }
    });

    const setNewAllTasks = useSetRecoilState<any>(taskAddSelector);

    // タスクを追加
    const addTask = (data: AddTaskType): void => {
        const taskTitle: string = data.title;

        // selectorの実行
        setNewAllTasks(taskTitle);

        reset(); // フォームを空にする
    }

    return (
        <div>
            <form>
                <input {...register('title')} placeholder='ここにタスク名を入力' />
                <button type='submit' onClick={handleSubmit(addTask)}>登録</button>
            </form>
        </div>
    );
};

export default AddForm;

stateの更新を行うため、以下のようにuseSetRecoilStateを利用しています。

const setNewAllTasks = useSetRecoilState<any>(taskAddSelector);

一旦、型については置いておいて。(selector使うときの型指定の最適解が未だわからない...。)
useSetRecoilStateフックを使うことで、新しい値をセットするための関数が返されます。これが、setNewAllTasks関数です。

フォーム送信時にタスク追加を行うのですが、addTask関数のところを見て頂くと、

// タスクを追加
    const addTask = (data: AddTaskType): void => {
        const taskTitle: string = data.title;

        // selectorの実行
        setNewAllTasks(taskTitle);

        reset(); // フォームを空にする
    }

setNewAllTasks関数実行時に、引数として入力されたタスク名が渡されています。
これが、先程のselectorsetプロパティに、引数titleとして入っていき、selectortaskAddSelectorが実行されて、stateの更新が行われます。

AddFormコンポーネントを表示するため、App.tsxを次のようにします。

import { RecoilRoot } from 'recoil';
import AddForm from './components/AddForm';
import { contentsArea, title } from './styles/app';

const App = () => {
  return (
    <RecoilRoot>
      <div>
        <h1>ToDoリスト</h1>
        <AddForm />
      </div>
    </RecoilRoot>
  );
};

export default App;

3. タスクを一覧表示する

それでは、タスクを追加したので、一覧表示をしてみます。

次のように、List.tsxファイルにListコンポーネントを作っていきます。

import { useRecoilValue } from 'recoil';
import { allTasksAtom } from '../recoil/recoilState';
import { AllTasksAtomType, TaskAtomType } from '../types/recoilStateType';

const List = () => {
    const allTasks = useRecoilValue<AllTasksAtomType>(allTasksAtom);

    return (
        <div>
            {allTasks.map((task: TaskAtomType, index: number) => (
                <div key={index}>
                    <span>NO.{task.id}</span>
                    <span>{task.title}</span>
                </div>
            ))}
        </div>
    );
};

export default List;

タスクのidと名前を表示するようになっています。
一覧表示をするためには、タスク全体をまとめているstateの取得が必要です。stateの取得部分に注目してみます。

const allTasks = useRecoilValue<AllTasksAtomType>(allTasksAtom);

useRecoilValueを用いて、タスク全体を配列としてまとめているatomallTasksAtomの配列を取得しています。
取得した配列(allTasksに入っている)を、map関数で展開して表示しているのです。

Listコンポーネントを表示するため、App.tsxを次のようにします。

import { RecoilRoot } from 'recoil';
import AddForm from './components/AddForm';
import List from './components/List';
import { contentsArea, title, tasksDisplayArea } from './styles/app';

const App = () => {
  return (
    <RecoilRoot>
      <div>
        <h1>ToDoリスト</h1>
        <AddForm />
        <div>
          <List />
        </div>
      </div>
    </RecoilRoot>
  );
};

export default App;

ここで、タスク追加と一覧表示をやってみると、次のような状態になるかと思います。例えば、「あいうえお」と入れてみます。

無事、タスク追加されていますね。

4. 未完了のタスクと完了タスクを分けられるようにしておく

一覧で全て表示できても、未完了のタスクと完了タスクが混在してわかりにくいと思います。
ここで、未完了のタスクと完了タスクを分けて表示できるようにしましょう。

以下ような動きを想定して実装していきます。

  • タスクが追加されると、「未完了のタスク」に表示される。
  • 未完了のタスクが「完了」になれば、「未完了のタスク」エリアからは消えて、「完了したタスク」エリアに表示される。
  • 完了したタスクが「未完了」になれば、「完了したタスク」エリアからは消えて、「未完了のタスク」エリアに表示される。

①完了状態の切り替えのselector

先に、完了状態を切り替える処理を用意しておきます。
完了状態を切り替えるボタンが押下されたタスクの完了状態が切り替わります。

以下のようなselectorを用意します。

// タスクの完了状態を切り替えるSelector
const changeTaskIsCompletedSelector = selector<AllTasksAtomType>({
    key: 'changeTaskIsCompletedSelector',
    get: ({ get }) => {
        return get(allTasksAtom);
    },
    set: ({ get, set }, targetTaskId: any) => {
        const targetId: number = targetTaskId; // 切り替え対象のタスクid

        const newTasksArray: AllTasksAtomType = get(allTasksAtom).map((task: TaskAtomType) => {
            if (task.id === targetId) {
                // 切り替え対象のタスクの完了状態のboolean値を反対に切り替え。(eg: 現在true→false)
                return {...task, isCompleted: !task.isCompleted}
            } else {
                return task;
            }
        });

        set(allTasksAtom, newTasksArray);
    }
});

こちらのselectorは実行時に、切り替えを行いたいタスクのidが渡ってきます。
map関数を使って、渡されたidと一致するidのタスクであれば完了状態を切り替え、それ以外のタスクはそのままの状態で返しています。切り替えが行われた新しい配列(ここで言うnewTasksArray)を返しているので、最後のset関数で、引数に新しい配列newTasksArrayを渡して全タスクをまとめているatomallTasksAtomを更新しています。

②完了状態に応じたタスクの取得を行うselector

未完了のタスクと完了タスクを分けて表示するには、完了状態に応じてタスクの取得を行います。
コンポーネントにて、タスクを全件取得してきてfilter関数を使って取得する手段もありますが、今回はselectorを使っていきます。

次のように、未完了・完了タスク取得のselectorをそれぞれ作成してみます。

未完了のタスクを取得するselector

// 未完了のタスクを取得するSelector
const showTaskNotCompletedSelector = selector<AllTasksAtomType>({
    key: 'showTaskNotCompletedSelector',
    get: ({ get }) => {
        const targetTasks: AllTasksAtomType = get(allTasksAtom).filter((task: TaskAtomType) => {
            return task.isCompleted === false;
        });

        return targetTasks;
    }
});

読み取り専用の値を返す、getプロパティを使用します。
filter関数を使って、get関数でタスク全件を配列にまとめているatomallTasksAtomから取り出したisCompletedプロパティがfalseのタスクのみを要素とする新しい配列を返しています。

同じく完了したタスクも取得できるようにしてみます。

完了したタスクを取得するselector

// 完了したタスクを取得するSelector
const showTaskCompletedSelector = selector<AllTasksAtomType>({
    key: 'switchTaskCompletedSelector',
    get: ({ get }) => {
        const targetTasks: AllTasksAtomType = get(allTasksAtom).filter((task: TaskAtomType) => {
            return task.isCompleted === true;
        });

        return targetTasks;
    }
});

完了したタスクの場合は、filter関数を使って、isCompletedプロパティがtrueのタスクのみを要素とする新しい配列を返しています。

③完了状態に応じたタスクの取得と表示

完了状態の切り替えと完了状態に応じたタスクの取得のselectorができました。
ここからは、それらを取得して表示・切り替え処理を実行できるように実装していきます。

未完了のタスク

未完了のタスクからやっていきます。
「未完了のタスク」エリアを作っていこうと思います。タスクが追加されると、まずここに表示されます。

次のように、NotCompletedList.tsxファイルにNotCompletedListコンポーネントを作っていきます。

import { useRecoilValue, useSetRecoilState } from 'recoil';
import { changeTaskIsCompletedSelector, showTaskNotCompletedSelector } from '../recoil/recoilState';
import { AllTasksAtomType, TaskAtomType } from '../types/recoilStateType';
import { listArea, title, itemsArea, item, toDoneButton } from '../styles/notCompletedList';

const NotCompletedList = () => {
    const notCompletedTasks = useRecoilValue<AllTasksAtomType>(showTaskNotCompletedSelector);
    const setChangeTaskIsCompleted = useSetRecoilState<any>(changeTaskIsCompletedSelector);

    // タスクの完了状態を切り替える
    const changeTaskIsCompleted = (id: number): void => {
        setChangeTaskIsCompleted(id);
    };

    return (
        <div>
            <h2>未完了のタスク</h2>

            <div>
                {notCompletedTasks.map((task: TaskAtomType, index: number) => (
                    <div key={index}>
                        <p>
                            <span>NO.{task.id}</span>
                            <span>{task.title}</span>
                        </p>
                        <button onClick={() => changeTaskIsCompleted(task.id)}>完了</button>
                    </div>
                ))}
            </div>
        </div>
    );
};

export default NotCompletedList;

タスクのidと名前、完了状態を切り替えるボタンを表示します。
「完了」ボタンを押下すると、「未完了のタスク」エリアからは消えて「完了したタスク」エリアに表示されます。

まず、先程作ったselectorが実行される、完了状態の切り替え処理から見てみます。

const setChangeTaskIsCompleted = useSetRecoilState<any>(changeTaskIsCompletedSelector);

// タスクの完了状態を切り替える
const changeTaskIsCompleted = (id: number): void => {
    setChangeTaskIsCompleted(id);
};

~~

<button onClick={() => changeTaskIsCompleted(task.id)}>完了</button>

stateの更新を行うため、以下のようにuseSetRecoilStateを利用しています。
useSetRecoilStateフックを使うことで、新しい値をセットするための関数が返されます。これが、setChangeTaskIsCompleted関数です。

タスクの完了状態を切り替えるためのchangeTaskIsCompleted関数において、setChangeTaskIsCompleted関数実行時に、引数としてidが渡されてきています。これは、クリックした(完了状態を切り替えたい)タスクのidです。
これが、先程のselectorchangeTaskIsCompletedSelectorsetプロパティに、引数targetTaskIdとして入っていき、selectorchangeTaskIsCompletedSelectorが実行されて、タスクの完了状態が切り替えられます。
「完了」ボタンを押下すると、このchangeTaskIsCompleted関数が実行されるようになっています。

それでは、「未完了のタスク」エリアの表示部分について見てみます。

const notCompletedTasks = useRecoilValue<AllTasksAtomType>(showTaskNotCompletedSelector);

~~

return (
    <div>
        <h2>未完了のタスク</h2>

        <div>
            {notCompletedTasks.map((task: TaskAtomType, index: number) => (
                <div key={index}>
                    <p>
                        <span>NO.{task.id}</span>
                        <span>{task.title}</span>
                    </p>
                    <button onClick={() => changeTaskIsCompleted(task.id)}>完了</button>
                </div>
            ))}
        </div>
    </div>
);

未完了のタスクを取り出した配列を取得するselectorshowTaskNotCompletedSelectorで返される値を、useRecoilValueフックで取得しています。配列で返されるので、map関数で展開することで表示するようにしています。

ここで、再びタスク追加と表示を実行してみたいと思います。
先程は、Listコンポーネントを表示していましたが、未完了タスクを表示するNotCompletedListコンポーネントを作ったので、不要になります。App.tsxを次のように修正しておきましょう。

import { RecoilRoot } from 'recoil';
import AddForm from './components/AddForm';
import NotCompletedList from './components/NotCompletedList';
import { contentsArea, title, tasksDisplayArea } from './styles/app';

const App = () => {
  return (
    <RecoilRoot>
      <div>
        <h1>ToDoリスト</h1>
        <AddForm />
        <div>
          <NotCompletedList />
        </div>
      </div>
    </RecoilRoot>
  );
};

export default App;

タスク追加をしてみると、次のような状態になりました。

新しく追加されたタスクが「未完了のタスク」エリアに表示されています。

完了したタスク

次に、完了したタスクについてもやっていきます。
「完了したタスク」エリアを作っていこうと思います。完了したタスクが、こちらのエリアに表示されます。

次のように、CompletedList.tsxファイルにCompletedListコンポーネントを作っていきます。

import { useRecoilValue, useSetRecoilState } from 'recoil';
import { changeTaskIsCompletedSelector, showTaskCompletedSelector } from '../recoil/recoilState';
import { AllTasksAtomType, TaskAtomType } from '../types/recoilStateType';
import { listArea, title, itemsArea, item, backToDoButton } from '../styles/completedList';

const CompletedList = () => {
    const completedTasks = useRecoilValue<AllTasksAtomType>(showTaskCompletedSelector);
    const setChangeTaskIsCompleted = useSetRecoilState<any>(changeTaskIsCompletedSelector);

    // タスクの完了状態を切り替える
    const changeTaskIsCompleted = (id: number): void => {
        setChangeTaskIsCompleted(id);
    };

    return (
        <div>
            <h2>完了したタスク</h2>

            <div>
                {completedTasks.map((task: TaskAtomType, index: number) => (
                    <div key={index}>
                        <p>
                            <span>NO.{task.id}</span>
                            <span>{task.title}</span>
                        </p>
                        <button onClick={() => changeTaskIsCompleted(task.id)}>未完了</button>
                    </div>
                ))}
            </div>
        </div>
    );
};

export default CompletedList;

こちらも、タスクのidと名前、完了状態を切り替えるボタンを表示します。
「未完了」ボタンを押下すると、完了状態が未完了になるので、「完了したタスク」エリアからは消えて「未完了のタスク」エリアに表示されます。

タスクの完了状態を切り替えるためのchangeTaskIsCompleted関数については、未完了のタスクを表示する箇所で説明したものと同じものなので、省きます。

「完了したタスク」エリアの表示部分について見てみます。

const completedTasks = useRecoilValue<AllTasksAtomType>(showTaskCompletedSelector);

~~

return (
    <div>
        <h2>完了したタスク</h2>

        <div>
            {completedTasks.map((task: TaskAtomType, index: number) => (
                <div key={index}>
                    <p>
                        <span>NO.{task.id}</span>
                        <span>{task.title}</span>
                    </p>
                    <button onClick={() => changeTaskIsCompleted(task.id)}>未完了</button>
                </div>
            ))}
        </div>
    </div>
);

完了したタスクを取り出した配列を取得するselectorshowTaskCompletedSelectorで返される値を、useRecoilValueフックで取得しています。配列で返されるので、map関数で展開することで表示するようにしています。

ここで、再びタスク追加と表示、そして完了状態の切り替えを実行してみたいと思います。
完了したタスクを表示するCompletedListコンポーネントを作ったので、App.tsxを次のように修正しておきましょう。

import { RecoilRoot } from 'recoil';
import AddForm from './components/AddForm';
import NotCompletedList from './components/NotCompletedList';
import CompletedList from './components/CompletedList';
import { contentsArea, title, tasksDisplayArea } from './styles/app';

const App = () => {
  return (
    <RecoilRoot>
      <div>
        <h1>ToDoリスト</h1>
        <AddForm />
        <div>
          <NotCompletedList />
          <CompletedList />
        </div>
      </div>
    </RecoilRoot>
  );
};

export default App;

タスク追加・完了状態切り替えを行なってみると、次のような状態になりました。

「完了」ボタンを押下したタスク「かきくけこ」が「完了したタスク」エリアに表示されています。

5. タスクを削除する

これで、タスクを追加して表示する、完了状態を切り替える処理は実装できました。最後に、タスクを削除する処理も実装しましょう。
「削除」ボタンが押下されたタスクが削除されて表示されなくなります。

以下のようなselectorを用意します。

// タスク削除のSelector
const deleteTaskSelector = selector<AllTasksAtomType>({
    key: 'deleteTaskSelector',
    get: ({ get }) => {
        return get(allTasksAtom);
    },
    set: ({ get, set }, targetTaskId: any) => {
        const targetId: number = targetTaskId; // 削除対象のタスクのid

        // 削除対象のタスクのidとは異なるidのタスクのみを要素とする配列を返す
        const deletedArray: AllTasksAtomType = get(allTasksAtom).filter((task: TaskAtomType) => {
            return task.id !== targetId;
        });

        set(allTasksAtom, deletedArray);
    }
});

引数として渡されているtargetTaskIdには、削除対象のタスクのidが入ってきます。
filter関数を使って、get関数でタスク全件を配列にまとめているatomallTasksAtomから取り出した削除対象のタスクのidとは異なるidを持つタスクを要素とする新しい配列を返しています。

set関数に注目してみます。
第1引数には、更新したいstate(atom)のallTasksAtomを渡します。そして、第2引数には、更新後の新しい値(上記で言う、対象のタスクが除かれた状態の配列)を渡します。
これにより、指定したstateに新しい値が書き込まれ、stateが更新されます。

それでは、タスク表示側の処理もやっていきます。

未完了のタスク

まずは、未完了のタスクから。次のように、NotCompletedList.tsxを修正します。

import { useRecoilValue, useSetRecoilState } from 'recoil';
import { changeTaskIsCompletedSelector, deleteTaskSelector, showTaskNotCompletedSelector } from '../recoil/recoilState';
import { AllTasksAtomType, TaskAtomType } from '../types/recoilStateType';
import { listArea, title, itemsArea, item, toDoneButton, deleteButton } from '../styles/notCompletedList';

const NotCompletedList = () => {
    const notCompletedTasks = useRecoilValue<AllTasksAtomType>(showTaskNotCompletedSelector);
    const setChangeTaskIsCompleted = useSetRecoilState<any>(changeTaskIsCompletedSelector);
    const setDeleteTask = useSetRecoilState<any>(deleteTaskSelector);

    ~~

    // タスクを削除する
    const deleteTask = (id: number): void => {
        setDeleteTask(id);
    };

    return (
        <div>
            <h2>未完了のタスク</h2>

            <div>
                {notCompletedTasks.map((task: TaskAtomType, index: number) => (
                    <div key={index}>
                        <p>
                            <span>NO.{task.id}</span>
                            <span>{task.title}</span>
                        </p>
                        <button onClick={() => changeTaskIsCompleted(task.id)}>完了</button>

                        {/* 「削除」ボタンを追加 */}
                        <button onClick={() => deleteTask(task.id)}>削除</button>
                    </div>
                ))}
            </div>
        </div>
    );
};

export default NotCompletedList;

「削除」ボタンが追加されています。

先程作ったselectorが実行される処理を見ていきます。

const setDeleteTask = useSetRecoilState<any>(deleteTaskSelector);

~~

// タスクを削除する
const deleteTask = (id: number): void => {
    setDeleteTask(id);
};

return (
    <div>
        <h2>未完了のタスク</h2>

        <div>
            {notCompletedTasks.map((task: TaskAtomType, index: number) => (
                <div key={index}>
                    <p>
                        <span>NO.{task.id}</span>
                        <span>{task.title}</span>
                    </p>
                    <button onClick={() => changeTaskIsCompleted(task.id)}>完了</button>

                    {/* deleteTask関数実行 */}
                    <button onClick={() => deleteTask(task.id)}>削除</button>
                </div>
            ))}
        </div>
    </div>
);

stateの更新を行うため、以下のようにuseSetRecoilStateを利用しています。
useSetRecoilStateフックを使うことで、新しい値をセットするための関数が返されます。これが、setDeleteTask関数です。
「削除」ボタンを押下すると、タスク削除を行うdeleteTask関数に対象のタスクのidが引数として渡され、setDeleteTask関数に渡されてselectordeleteTaskSelectorが実行されます。これで、対象のidを持つタスクが削除されます。

完了したタスク

次は、完了したタスク。未完了のタスクと同じように、CompletedList.tsxを修正します。
削除処理deleteTask関数など全く同じです。

import { useRecoilValue, useSetRecoilState } from 'recoil';
import { changeTaskIsCompletedSelector, deleteTaskSelector, showTaskCompletedSelector } from '../recoil/recoilState';
import { AllTasksAtomType, TaskAtomType } from '../types/recoilStateType';
import { listArea, title, itemsArea, item, backToDoButton, deleteButton } from '../styles/completedList';

const CompletedList = () => {
    const completedTasks = useRecoilValue<AllTasksAtomType>(showTaskCompletedSelector);
    const setChangeTaskIsCompleted = useSetRecoilState<any>(changeTaskIsCompletedSelector);
    const setDeleteTask = useSetRecoilState<any>(deleteTaskSelector);

    ~~

    // タスクを削除する
    const deleteTask = (id: number): void => {
        setDeleteTask(id);
    };

    return (
        <div>
            <h2>完了したタスク</h2>

            <div>
                {completedTasks.map((task: TaskAtomType, index: number) => (
                    <div key={index}>
                        <p>
                            <span>NO.{task.id}</span>
                            <span>{task.title}</span>
                        </p>
                        <button onClick={() => changeTaskIsCompleted(task.id)}>未完了</button>
                        <button onClick={() => deleteTask(task.id)}>削除</button>
                    </div>
                ))}
            </div>
        </div>
    );
};

export default CompletedList;

未完了のタスク・完了したタスクそれぞれの表示で、削除処理の実装を行いました。
以下のような内容で、タスク追加〜タスクを削除を行なってみます。

  1. 追加するタスクは、「あいうえお」、「かきくけこ」、「なにぬねの」、「たちつてと」の4つ。
  2. 「かきくけこ」は完了。
  3. 未完了の「なにぬねの」は削除。

実行すると、次のような状態になりました。

  1. タスクを追加

    タスクが4つ、追加されています。

  2. タスクを1つ完了する

    タスク「かきくけこ」が完了して、「完了したタスク」のエリアに表示されています。

  3. タスクを1つ削除する

    タスク「なにぬねの」が削除されています。


これで、ToDoリストの基本的な機能が1通り実装できました🎉
お疲れ様でした〜!

おわりに

Reactで開発を行なっていると、stateをどう管理していくかが問題になることがあると思います。
Recoilは、stateをまとめて管理する必要がなく、atom関数を使って複数管理できるという利点があったりします。
Recoilが提供している関数などは、他にもたくさんあるのですが、今回は基本的なものだけに絞りました。また機会があれば、今回紹介した以外のものについても使ってみたいです。

まだまだ使い慣れておらず、色々と不足している知識などあるかもしれないです。
認識の誤り・補足などがあれば、是非、コメントして頂けますと助かります〜🙇‍♀️

長文お読み下さり、ありがとうございました!

参考資料

Recoil 公式ドキュメント
Recoil 公式リポジトリ
株式会社ヌーラボHPヌーラボブログ Recoil Syncでさらに快適フロントエンド開発 #ヌーラボ真夏のブログリレー Recoilはいつまでexperimentalなのか?
When to use Writeable Selectors in RecoilJS
Facebook製の新しいステート管理ライブラリ「Recoil」を最速で理解する
【Recoil入門】Atom、useRecoilStateの使い方

GitHubで編集を提案

Discussion