はじめに
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で上がっていて、既に予定しているようです。
有志が型定義ファイルを作ってくださったみたいなので、今回はとりあえずそれを利用させていただきます。
**型定義ファイル(押すと開くよ)**
/// <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をおきます。
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
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);
これの兄弟に useRecoilValue
と useSetRecoilState
があります。
名前から予想できると思いますが、それぞれgetterとsetterが独立したものです。
以下は先ほどのコードと同意です。どちらかのアクションしか無い場合はunused等で怒られないよう適切に使っていきましょう。
// const [tasks, setTasks] = useRecoilState(taskState);
const tasks = useRecoilValue(taskState);
const setTasks = useSetRecoilState(taskState);
useRecoilStateの使い方を踏まえて、Todo入力部分を作ってみましょう。
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のリストは取得できます。
+ 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の表示部分を作る
注意点は useRecoilState
や useRecoilValue
で取り出す値はread onlyな点です。
最初実装した時に以下のようなコードを書いていました。
tasks[index].completed = !tasks[index].completed
しかし、ステートはread onlyで再代入不可なので、sliceを使って新たな配列を作り、そこに新しいオブジェクトを挿入する感じにしています。
....
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で培ってきた、データフローを一方向にするという考えに背く羽目になるからです。
export const taskState = atom({
key: 'task',
default: initialTasks,
dangerouslyAllowMutability: true
})
完成
はい、こんな感じのしょぼいTodoアプリができました。
この程度だとGlobalなステートの恩恵が全くありませんが、Recoilの入門には丁度良いのかな〜という感じです。
全部のコードが置いてあるリポジトリも置いておきました。
まとめ
まだRecoilはexperimentalですが、Recoilは非常に馴染みのあるAPIで、使いやすいと個人的には思います。
Reduxが重すぎると思う方は、今後Recoilを使う機会があるかもしれません。
本当はまだ紹介していない、 selector
という概念があるのですが、この記事が好評だったら書くかもしれません。