40
52

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Reactステート管理ライブラリ Recoil入門(typescript)(UseContextの比較も)

Posted at

Recoilとは

RecoilはReact向けのステート管理ライブラリです。
Reactを開発したFacebook製のライブラリです。

Reactのステート管理ライブラリといったらReduxですが、
プロジェクト途中で導入する場合、非常にコストがかかります。

一方、RecoilはReact Hooksライクにコーディングが行えるため、学習コストも低く、
コード量も非常に軽量なので、導入が容易に行えるのが特徴です。

やること

Recoilを用いて、メモ管理Webアプリを作ります。
また、類似ライブラリであるUseContextとの比較も行います。

前提条件

環境情報は以下になります。

端末環境

OS チップ
Mac Big Sur Apple M1

各種バージョン

% node -v
v14.17.0
% yarn -v
1.22.4
% npx create-react-app --version
4.0.3

1. メモ管理Webアプリ作成

シンプルにメモの表示、追加、変更が行えるWebアプリになります。

プロジェクト作成

create-react-appにてプロジェクト作成を行います。
今回はtypescriptで行います。

% npx create-react-app react-recoil-demo --template typescript
% cd react-recoil-demo

Recoil インストール

% yarn add recoil
% yarn add @types/recoil
% npm install recoil
% npm install @types/recoil

react-router-dom インストール

% yarn add react-router-dom
% yarn add @types/react-router-dom
% npm install react-router-dom
% npm install @types/react-router-dom

tsconfig.json

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": [
    "src"
  ]
}

RecoilRootの追加

RecoilRootをインポートし、ルートコンポーネントを囲います。
ここではAppコンポーネントを囲います。

UseContextではContext.Provider
ReduxではProviderで、Appコンポーネントを囲うのと同じですね。

index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { RecoilRoot } from 'recoil';

ReactDOM.render(
  <React.StrictMode>
    <RecoilRoot>
      <App />
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

App.tsxはルートの定義をしています。

App.tsx
import React from "react";
import { BrowserRouter as Router, Route } from "react-router-dom";

import MemoList from "./pages/MemoList";
import MemoAdd from "./pages/MemoAdd";
import MemoEdit from "./pages/MemoEdit";

function App() {
  return (
    <Router>
      <Route exact path="/" component={MemoList} />
      <Route path="/add" component={MemoAdd} />
      <Route path="/edit/:memoId" component={MemoEdit} />
    </Router>
  );
}

export default App;

atomの定義

Recoilは、atomというステートの定義ファイルを作成します。
ステートの型や初期値を定義しましょう。

atom.ts
import { atom } from "recoil";

export const memoAtomState = atom<string[]>({
  key: "memoAtom",
  default: [],
});

atomの読み書き

atomは、コンポーネントから読み書きすることができます。

atomを読み取るコンポーネントは、暗黙的にサブスクライブという状態になり
atomの値が変更されたら、対象のコンポーネントが再レンダリングされます。

よく使うコードの書き方はこちらです。

// 読み取りだけ
const memos = useRecoilValue<string[]>(memoAtomState);
// 書き込みだけ
const setMemos = useSetRecoilState<string[]>(memoAtomState);
// 読み込み書き込み両方
const [memos, setMemos] = useRecoilState<string[]>(memoAtomState);

読み取り専用書き込み専用読み書き両方の3種類の関数が用意されています。

1点注意が必要な点が、上記で述べたatomを読み取るコンポーネントは、再レンダリングの対象となることです。
つまり、atomに書き込みしかしないコンポーネントはサブスクライブされません。
そのため、書き込みしかしないコンポーネントは、useSetRecoilStateを使うことで、
不要なレンダリングを減らすことができます。

以下、残りの一覧画面、追加画面、変更画面のソースとなります。

pages/MemoList.tsx
import React from "react";
import { useRecoilValue } from "recoil";
import { memoAtomState } from "../atom";

import { useHistory } from "react-router-dom";

const MemoList: React.FC = () => {
  const memos = useRecoilValue<string[]>(memoAtomState);

  const history = useHistory();
  const handleLink = (path: string) => history.push(path);

  return (
    <>
      {memos.map((memo, index) => (
        <li key={index}>
          <label>{memo}</label>
          <button onClick={() => handleLink("/edit/" + index)}>編集</button>
        </li>
      ))}
      <button onClick={() => handleLink("/add")}>追加</button>
    </>
  );
};

export default MemoList;
pages/MemoAdd.tsx
import React, { useRef } from "react";
import { useSetRecoilState } from "recoil";
import { memoAtomState } from "../atom";

import { useHistory } from "react-router-dom";

const MemoAdd: React.FC = () => {
  const history = useHistory();
  const textAreaReff = useRef<HTMLTextAreaElement>(null);
  const setMemos = useSetRecoilState<string[]>(memoAtomState);

  const handleClick = () => {
    if (textAreaReff.current !== null && textAreaReff.current!.value !== "") {
      setMemos((currVal: string[]) => {
        return [textAreaReff.current!.value, ...currVal];
      });
      history.push("/");
    }
  };

  return (
    <>
      <textarea ref={textAreaReff}></textarea>
      <button onClick={handleClick}>追加</button>
    </>
  );
};

export default MemoAdd;
pages/MemoEdit.tsx
import React, { useEffect, useRef } from "react";
import { useRecoilState } from "recoil";
import { memoAtomState } from "../atom";

import { useParams, useHistory } from "react-router-dom";

const MemoEdit: React.FC = () => {
  const history = useHistory();
  const textAreaReff = useRef<HTMLTextAreaElement>(null);
  const [memos, setMemos] = useRecoilState<string[]>(memoAtomState);
  const { memoId } = useParams<{ memoId?: string }>();

  const handleClick = () => {
    if (textAreaReff.current !== null && textAreaReff.current!.value !== "") {
      setMemos((currVal: string[]) => {
        return [
          textAreaReff.current!.value,
          ...currVal.filter((d, i) => i !== Number(memoId)),
        ];
      });
      history.push("/");
    }
  };

  useEffect(() => {
    textAreaReff.current!.value = memos[Number(memoId)];
  }, [memoId, memos]);

  return (
    <>
      <textarea ref={textAreaReff}></textarea>
      <button onClick={handleClick}>追加</button>
    </>
  );
};

export default MemoEdit;

アプリ動作確認

デザインは置いておいて、このように動作します。
MemoApp.gif

2. Recoil vs UseContext

Recoilの説明はここまでとなりますが、
ここで思うことが、**RecoilってUseContextにすごい似てる!!**という点です。

ただ、UseContextはラップしたコンポーネントを全てレンダリングするため、
管理するステートが増えるとレンダリングコストが肥大化してしまうというデメリットがあります。

この点、Recoilはどうなのかを調査しました。

調査内容

RecoilとUseContextを使って、同じ動作をするコードを作成します。
動作時にレンダリングされるコンポーネントを観測します。

アプリは、ボタンをクリックするとカウントアップするという簡単なものです。

観測1.UseContext

まずは、UseContextから観測を行います。
実行コードはこちら

App.tsx
import React, { createContext, useContext, useState } from "react";

const CountContext = createContext(
  {} as {
    count: number;
    setCount: React.Dispatch<React.SetStateAction<number>>;
  }
);

function App() {
  const [count, setCount] = useState(0);
  console.log("rendered App.");
  return (
    <CountContext.Provider value={{ count, setCount }}>
      <ShowCount />
      <IncrementButton />
      <ResetButton />
    </CountContext.Provider>
  );
}

const ShowCount = () => {
  const { count } = useContext(CountContext);
  console.log("rendered ShowCount.");
  return <div>{count}</div>;
};

const IncrementButton = () => {
  const { setCount } = useContext(CountContext);
  console.log("rendered IncrementButton.");
  return (
    <>
      <button onClick={() => setCount((prevCount) => prevCount + 1)}>+</button>
    </>
  );
};

const ResetButton = () => {
  const { setCount } = useContext(CountContext);
  console.log("rendered ResetButton.");
  return (
    <>
      <button onClick={() => setCount(0)}>Reset</button>
    </>
  );
};

export default App;

結果1.UseContext

UseContextの実行結果がこちらになります。
ボタンをクリックする度に、全てのコンポーネントがレンダリングされているのが分かります。
本来であれば、ボタンのレンダリングは不要です。

UseContext.gif

観測2.Recoil

続いて、Recoilの観測です。
実行コードはこちら

App.tsx
import React from "react";
import { useRecoilValue, useSetRecoilState } from "recoil";
import { countAtomState } from "./atom";

const App: React.FC = () => {
  console.log("rendered App.");

  return (
    <>
      <ShowCount />
      <IncrementButton />
      <ResetButton />
    </>
  );
};

const ShowCount: React.FC = () => {
  const count = useRecoilValue(countAtomState);
  console.log("rendered ShowCount.");
  return <div>{count}</div>;
};

const IncrementButton: React.FC = () => {
  const setCount = useSetRecoilState(countAtomState);

  const handleOnClick = () => {
    setCount((prevValue) => prevValue + 1);
  };

  console.log("rendered IncrementButton.");
  return <button onClick={handleOnClick}>+</button>;
};

const ResetButton: React.FC = () => {
  const setCount = useSetRecoilState(countAtomState);

  const handleOnClick = () => {
    setCount(0);
  };

  console.log("rendered ResetButton.");
  return <button onClick={handleOnClick}>Reset</button>;
};

export default App;

結果2.Recoil

Recoilの実行結果はこちらになります。
ボタンをクリックしても、カウントを表示するコンポーネントしかレンダリングされません。
さらに、注目したいのがResetボタンの挙動です。
Resetボタンの1回目クリックでは、Countステータスが変更されたので、レンダリングされますが、
2回目以降のクリックは、Countステータスは変わらないので、レンダリングされません。

このことから、Recoilのレンダリングは最小限で済むことが分かります。

Recoil.gif

結論

UseContextは、ステート変更時にラップされた全てのコンポーネントをレンダリングします。
一方、Recoilは、ステートを読み込んでいるコンポーネントのみレンダリングします。
このことから、レンダリングコストはRecoilの方が低いことがわかりました。

最後に

Reactのステート管理ライブラリRecoilの紹介とUseContextの比較した調査結果の共有をさせていただきました。
読んでいただいた方のお力になれば幸いです。

宣伝

パーソルプロセス&テクノロジー株式会社(以下パーソルP&T)、システムソリューション(SSOL)事業部所属の堀江です。

私はモビリティソリューションデザインチームに所属しており、モビリティ(ここでは移動手段全般)に関するサービスを考えたり、アプリを構築したりしております。

いわゆる**「MaaS」**に取り組んでおります。

私たちが「MaaS」に取り組む中で、現在活用している、もしくは活用する予定の技術やサービスやとりあえず発信したいことなどなど、幅広くチームメンバーと共に執筆していきたいと思います。
メンバーごとに違った内容を発信していきますので、お楽しみに!

また、「MaaS」について詳しく知りたい方は、チームメンバーの吉田が記事を掲載しておりますので、
ぜひそちらをご覧ください。

「MaaSとは」でたどり着いて欲しい記事 (1/3 前編)
「MaaSとは」でたどり着いて欲しい記事 (2/3 中編)
「MaaSとは」でたどり着いて欲しい記事 (3/3 後編)

最後まで読んでいただき、ありがとうございました!

40
52
1

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
40
52

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?