Help us understand the problem. What is going on with this article?

話題の「Recoil」を使ってTodoアプリ作ってみた with TypeScript

はじめに

Facebook公式から新しいステート管理のためのライブラリが公開されました。

その名もRecoilです。

特徴としては

  • ミニマム
  • Hooksではお馴染みのuseStateと同じようなAPI
  • パフォーマンス問題が解決できる(Viewの再レンダリングを抑える)

が挙げられるかと思います。

早速Todoアプリを作ってみましょう。

環境構築

$ npx create-react-app todo-recoil --typescript
$ cd todo-recoil
$ yarn add recoil

型定義しておく

意気揚々とTSで始めようとしましたが、型定義ファイルは2020/05/15 10時現在は無いようです。

ですが、TS supportがIssueで上がっていて、既に予定しているようです。

https://github.com/facebookexperimental/Recoil/issues/6

有志が型定義ファイルを作ってくださったみたいなので、今回はとりあえずそれを利用させていただきます。

https://github.com/csantos42/DefinitelyTyped/blob/recoil-types/types/recoil/index.d.ts

型定義ファイル(押すと開くよ)
src/react-app-env.d.ts
/// <reference types="react-scripts" />

// Type definitions for recoil 0.0
// Project: https://github.com/facebookexperimental/recoil#readme
// Definitions by: Christian Santos <https://github.com/csantos42>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
// Minimum TypeScript Version: 3.7

/// <reference types="react" />

declare module 'recoil' {
    // Nominal Classes
    export import DefaultValue = __Recoil.DefaultValue;

    // Components
    export import RecoilRoot = __Recoil.RecoilRoot;

    // RecoilValues (aka Recoil state)
    export import atom = __Recoil.atom;
    export import selector = __Recoil.selector;

    // Hooks for working with Recoil state
    export import useRecoilValue = __Recoil.useRecoilValue;
    export import useRecoilValueLoadable = __Recoil.useRecoilValueLoadable;
    export import useRecoilState = __Recoil.useRecoilState;
    export import useRecoilStateLoadable = __Recoil.useRecoilStateLoadable;
    export import useSetRecoilState = __Recoil.useSetRecoilState;
    export import useResetRecoilState = __Recoil.useResetRecoilState;
    export import useRecoilCallback = __Recoil.useRecoilCallback;

    // Other
    export import isRecoilValue = __Recoil.isRecoilValue;
}

declare module 'recoil/utils' {
    // Convenience RecoilValues
    export import atomFamily = __Recoil.atomFamily;
}

declare namespace __Recoil {
    // Recoil_Node.js
    export class DefaultValue {}

    // Recoil_State.js
    type NodeKey = string;

    // Recoil_RecoilValue.js
    class AbstractRecoilValue<T> {
        tag: 'Writeable';
        key: NodeKey;
        constructor(newKey: NodeKey);
    }

    class AbstractRecoilValueReadonly<T> {
        tag: 'Readonly';
        key: NodeKey;
        constructor(newKey: NodeKey);
    }

    class RecoilState<T> extends AbstractRecoilValue<T> {}

    class RecoilValueReadOnly<T> extends AbstractRecoilValueReadonly<T> {}

    type RecoilValue<T> = RecoilValueReadOnly<T> | RecoilState<T>;

    export function isRecoilValue(val: unknown): val is RecoilValue<any>;

    // Recoil_State.js
    type AtomValues = Map<NodeKey, Loadable<any>>;
    type ComponentCallback = (state: TreeState) => void;
    type TreeState = Readonly<{
        // Information about the TreeState itself:
        isSnapshot: boolean;
        transactionMetadata: object;
        dirtyAtoms: Set<NodeKey>;

        // ATOMS
        atomValues: AtomValues;
        nonvalidatedAtoms: Map<NodeKey, unknown>;

        // NODE GRAPH -- will soon move to StoreState
        // Upstream Node dependencies
        nodeDeps: Map<NodeKey, Set<NodeKey>>;

        // Downstream Node subscriptions
        nodeToNodeSubscriptions: Map<NodeKey, Set<NodeKey>>;
        nodeToComponentSubscriptions: Map<NodeKey, Map<number, [string, ComponentCallback]>>;
    }>;

    // Recoil_Loadable.js
    type ResolvedLoadablePromiseInfo<T> = Readonly<{
        value: T;
        upstreamState__INTERNAL_DO_NOT_USE?: TreeState;
    }>;

    type LoadablePromise<T> = Promise<ResolvedLoadablePromiseInfo<T>>;

    type Accessors<T> = Readonly<{
        // Attempt to get the value.
        // If there's an error, throw an error.  If it's still loading, throw a Promise
        // This is useful for composing with React Suspense or in a Recoil Selector.
        getValue: () => T;

        toPromise: () => LoadablePromise<T>;

        // Convenience accessors
        valueMaybe: () => T | void;
        valueOrThrow: () => T;
        errorMaybe: () => Error | void;
        errorOrThrow: () => Error;
        promiseMaybe: () => Promise<T> | void;
        promiseOrThrow: () => Promise<T>;

        map: <T, S>(map: (val: T) => Promise<S> | S) => Loadable<S>;
    }>;

    export type Loadable<T> =
        | Readonly<Accessors<T> & { state: 'hasValue'; contents: T }>
        | Readonly<Accessors<T> & { state: 'hasError'; contents: Error }>
        | Readonly<
        Accessors<T> & {
        state: 'loading';
        contents: LoadablePromise<T>;
    }
        >;

    // Recoil_RecoilRoot.react.js
    type RecoilRootProps = {
        initializeState?: (options: {
            set: <T>(recoilVal: RecoilValue<T>, newVal: T) => void;
            setUnvalidatedAtomValues: (atomMap: Map<string, unknown>) => void;
        }) => void;
    };

    export const RecoilRoot: React.FC<RecoilRootProps>;

    // Recoil_atom.js
    type AtomOptions<T> = Readonly<{
        key: NodeKey;
        default: RecoilValue<T> | Promise<T> | T;
        // persistence_UNSTABLE?: PersistenceSettings<T>,
        dangerouslyAllowMutability?: boolean;
    }>;

    export function atom<T>(options: AtomOptions<T>): RecoilState<T>;

    // Recoil_selector.js
    type GetRecoilValue = <T>(recoilVal: RecoilValue<T>) => T;
    type SetRecoilState = <T>(
        recoilVal: RecoilState<T>,
        newVal: T | DefaultValue | ((prevValue: T) => T | DefaultValue),
    ) => void;
    type ResetRecoilState = <T>(recoilVal: RecoilState<T>) => void;

    type ReadOnlySelectorOptions<T> = {
        key: string;
        get: (opts: { get: GetRecoilValue }) => Promise<T> | RecoilValue<T> | T;

        // cacheImplementation_UNSTABLE?: CacheImplementation<Loadable<T>>,
        dangerouslyAllowMutability?: boolean;
    };

    type ReadWriteSelectorOptions<T> = ReadOnlySelectorOptions<T> & {
        set: (
            opts: // FIXME: these types are not working
                {
                    set: SetRecoilState;
                    get: GetRecoilValue;
                    reset: ResetRecoilState;
                },
            newValue: T | DefaultValue,
        ) => void;
    };

    export function selector<T>(options: ReadOnlySelectorOptions<T>): RecoilValueReadOnly<T>;
    export function selector<T>(options: ReadWriteSelectorOptions<T>): RecoilState<T>;

    // Recoil_Hooks.js
    type SetterOrUpdater<T> = (valOrUpdater: ((currVal: T) => T) | T) => void;
    type Resetter = () => void;
    type CallbackInterface = Readonly<{
        getPromise: <T>(recoilVal: RecoilValue<T>) => Promise<T>;
        getLoadable: <T>(recoilVal: RecoilValue<T>) => Loadable<T>;
        set: <T>(recoilVal: RecoilState<T>, valOrUpdater: ((currVal: T) => T) | T) => void;
        reset: <T>(recoilVal: RecoilState<T>) => void;
    }>;

    export function useRecoilValue<T>(recoilValue: RecoilValue<T>): T;
    export function useRecoilValueLoadable<T>(recoilValue: RecoilValue<T>): Loadable<T>;
    export function useRecoilState<T>(recoilState: RecoilState<T>): [T, SetterOrUpdater<T>];
    export function useRecoilStateLoadable<T>(recoilState: RecoilState<T>): [Loadable<T>, SetterOrUpdater<T>];
    export function useSetRecoilState<T>(recoilState: RecoilState<T>): SetterOrUpdater<T>;
    export function useResetRecoilState<T>(recoilState: RecoilState<T>): Resetter;
    export function useRecoilCallback<Args extends ReadonlyArray<unknown>, Return>(
        fn: (interface: CallbackInterface, ...args: Args) => Return,
        deps?: ReadonlyArray<unknown>,
    ): (...args: Args) => Return;

    // Recoil_atomFamily.js
    type Primitive = void | null | boolean | number | string;
    type AtomFamilyParameter =
        | Primitive
        | ReadonlyArray<AtomFamilyParameter>
        | Readonly<{ [k: string]: AtomFamilyParameter }>;
    type AtomFamilyOptions<T, P extends AtomFamilyParameter> = Readonly<
        AtomOptions<T> & {
        default:
            | RecoilValue<T>
            | Promise<T>
            | T
            | ((param: any /*FIXME*/) => T | RecoilValue<T> | Promise<T>)
            | any; // FIXME
    }
        >;

    function atomFamily<T, P extends AtomFamilyParameter>(
        options: AtomFamilyOptions<T, P>,
    ): (param: P) => RecoilState<T>;
}

RecoilRootを設置する

Recoilでステート管理を使用するコンポーネントは、親ツリーのどこかに RecoilRoot を配置する必要があります!

これを配置するのに適した場所は、ルートコンポーネントの中です。

src/App.tsx にRecoilRootをおきます。

src/App.tsx
import React from 'react';
import {TaskList} from './Task';

import {
    RecoilRoot,
} from 'recoil';


function App() {
    return (
        <RecoilRoot>
            <TaskList/>
        </RecoilRoot>
    );
}

export default App;

Atomを作る

Atomとはデータストアです。

keyは一意にする必要があります。
defaultには初期値を設定しておきましょう。

$ mkdir src/atoms
$ touch src/atoms/Task.ts
src/Atoms/Task.ts
import {atom} from 'recoil';

export interface Task {
    title: string;
    completed: boolean;
}

const initialTasks: Task[] = [];

export const taskState = atom({
    key: 'task',
    default: initialTasks,
})

Todo入力部分を作る

Recoilのhookがいくつかあるのですが、代表的なものが useRecoilState です。
useStateと同じ感じのAPIでatomを引数にとり、setterとgetterを戻り値にします。

const [tasks, setTasks] = useRecoilState(taskState);

これの兄弟に useRecoilValueuseSetRecoilState があります。
名前から予想できると思いますが、それぞれgetterとsetterが独立したものです。

以下は先ほどのコードと同意です。どちらかのアクションしか無い場合はunused等で怒られないよう適切に使っていきましょう。

// const [tasks, setTasks] = useRecoilState(taskState);
const tasks = useRecoilValue(taskState);
const setTasks = useSetRecoilState(taskState);

useRecoilStateの使い方を踏まえて、Todo入力部分を作ってみましょう。

src/Task.tsx
import React, {useState} from "react";
import {useSetRecoilState} from "recoil";
import {taskState} from "./atoms/Task";

const TaskInput = () => {
    const [title, setTitle] = useState('');
    const setTasks = useSetRecoilState(taskState);
    const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        setTitle(event.target.value)
    }
    const onClick = () => {
        setTasks(t => {
            return [...t, {title, completed: false}]
        })
        setTitle('')
    }
    return (
        <div>
            <label>
                タスク名
                <input type="text" value={title} onChange={onChange}/>
            </label>
            <button onClick={onClick}>登録</button>
        </div>
    )
}

export const TaskList = () => {
    return (
        <>
            <TaskInput/>
        </>
    )
}

Todoのリスト部分を作る

const tasks = useRecoilValue(taskState); でTodoのリストは取得できます。

src/Todo.tsx
+ import {useRecoilValue, useSetRecoilState} from "recoil";

.....

export const TaskList = () => {
+   const tasks = useRecoilValue(taskState);
+
    return (
        <>
            <TaskInput/>
+            <ul>
+                {tasks.map((t, index) => {
+                    return <TaskItem task={t} index={index} key={index}/>
+                })}
+            </ul>
        </>
    )
}

Todoの表示部分を作る

注意点は useRecoilStateuseRecoilValue で取り出す値はread onlyな点です。

最初実装した時に以下のようなコードを書いていました。

ダメなコード例
tasks[index].completed = !tasks[index].completed

しかし、ステートはread onlyで再代入不可なので、sliceを使って新たな配列を作り、そこに新しいオブジェクトを挿入する感じにしています。

src/App.tsx
....

interface TaskItemProps {
    task: Task;
    index: number;
}

const removeTasksAtIndex = (tasks: Task[], index: number) => {
    return [...tasks.slice(0, index), ...tasks.slice(index + 1)]
}

const replaceTasksAtIndex = (tasks: Task[], index: number, newTask: Task) => {
    return [...tasks.slice(0, index), newTask, ...tasks.slice(index + 1)]
}

const TaskItem: FC<TaskItemProps> = ({task, index}) => {
    const [tasks, setTasks] = useRecoilState(taskState);

    const onChange = () => {
        const newTasks = replaceTasksAtIndex(tasks, index, {
            ...task,
            completed: !task.completed
        });
        setTasks(newTasks);
    }

    const onClick = () => {
        const newTasks = removeTasksAtIndex(tasks, index);
        setTasks(newTasks);
    }

    return (
        <li key={index}>
            <input
                type="checkbox"
                checked={task.completed}
                onChange={onChange}
            />
            {task.title}
            <button onClick={onClick}>削除</button>
        </li>
    )
}
....

もし、「どうしても俺はread onlyが嫌なんだ!!!!!」って方は、atomを作る時に dangerouslyAllowMutability を trueにすれば良いです。

まあけど、React公式がdangerouslyというぐらいなので、素直にoffっといた方が良いです。
なぜならReduxで培ってきた、データフローを一方向にするという考えに背く羽目になるからです。

src/Atom/Tasks.ts
export const taskState = atom({
    key: 'task',
    default: initialTasks,
    dangerouslyAllowMutability: true
})

完成

はい、こんな感じのしょぼいTodoアプリができました。
この程度だとGlobalなステートの恩恵が全くありませんが、Recoilの入門には丁度良いのかな〜という感じです。
May-15-2020 13-28-18.gif

全部のコードが置いてあるリポジトリも置いておきました。

https://github.com/serinuntius/todo-recoil

まとめ

まだRecoilはexperimentalですが、Recoilは非常に馴染みのあるAPIで、使いやすいと個人的には思います。
Reduxが重すぎると思う方は、今後Recoilを使う機会があるかもしれません。

本当はまだ紹介していない、 selector という概念があるのですが、この記事が好評だったら書くかもしれません。

参考文献

noplan-inc
Webサイト、iOSアプリ、AndroidアプリなどWebサービス全般の開発から運用をワンストップで行っています。
https://noplan-inc.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした