reactのhooksを利用した状態管理ライブラリeasy-peasyを使ってみた

最近reactのhooksが話題になっていますね。

そのhooksを利用した状態管理ライブラリを見つけました。

github.com

easy-peasyの意味は、「とっても簡単」というらしいです! 今回はこのライブラリを使用して簡単なgithubリポジトリ検索機能を作成しようと思います。

完成したコードは以下になります。

GitHub - hayawata3626/easy-peasy-sample

以下からは、コード全てを説明するのではなく、主要な部分を中心に説明していきます。

環境構築

今回はcreate-react-appを使用しています。

プロジェクトの作成

create-react-app easy-peasy-sample

hooksは、React v16.7.0-alphaから使えるのでアップデートします。

yarn add -D react@16.7.0-alpha.0 react-dom@16.7.0-alpha.0

以下は今回使用するライブラリなのでインストールしてください。

これで環境構築は終了です。

このライブラリで使われるのが主にeffectactionです。 effectは副作用のあるもの、非同期にデータを取得する時などに使用します。

フォルダ構成

フォルダ構成は以下のようになっています。(最低限必要な箇所を表示)

├── App.js
├── components
│   ├── Item.js
│   ├── Result.js
│   └── Search.js
├── index.js
├── model.js

storeの作成

githubREADME.mdにも説明されていますが、以下のようにstoreを作成します。

公式から引用したコードです。

const model = {
  todos: {
    items: [],
  }
};

createStoreをimportします。

import { createStore } from 'easy-peasy';

const store = createStore(model);

これでreduxのstoreと同じようなものを作成できました。とても手軽ですね。

以下は実際にstoreを作成したコードです。

import React from "react";
import ReactDOM from "react-dom";
import * as serviceWorker from "./serviceWorker";
import { createStore, StoreProvider } from "easy-peasy";

import model from "./model";
import App from "./App";
import "./index.css"

const store = createStore(model);
function Root() {
  return (
    <StoreProvider store={store}>
      <App />
    </StoreProvider>
  );
}

ReactDOM.render(<Root />, document.getElementById("root"));
serviceWorker.unregister();

reduxとに似ていますね。

初期値の設定

必要なデータは以下になります。

export default {
  // data
  items: [],
  loading: false,
}

itemsにはapiから取得したデータが一つ一つ入ってきます。

loadingは検索中の時に表示したいので初期値はfalseです。

Actionの作成

今回メインとなるactionは以下になります。

export default {
  // data
  items: [],
  loading: false,

   // actions
  search: (state, payload) => {
    state.items = [];
    if (!payload.data.items.length) return;
    state.items = _.concat(state.items, payload.data.items);
  },
  isProgress: (state, payload) => {
    state.loading = payload;
  },
}

キーにはaction名、値には関数を書きます。 actionは第一引数にstate、第二引数にpayloadを受け取ります。 payloadにはactionが発火した時のデータが入ります。

Effect Actionの作成

これからeffect actionを作成していきます。 今回のようにgithubからデータを取得するときなど、非同期で何かをしたい時に使用します。

以下のコードをみてください。(公式ドキュメントから引用しています。)

import { effect } from 'easy-peasy'; // 👈 import the helper

const store = createStore({
  todos: {
    items: [],

    //          👇 define an action surrounding it with the helper
    saveTodo: effect(async (dispatch, payload, getState) => {
      //                      👆
      // Notice that an effect will receive the actions allowing you to dispatch
      // other actions after you have performed your side effect.
      const saved = await todoService.save(payload);
      // 👇 Now we dispatch an action to add the saved item to our state
      dispatch.todos.todoSaved(saved);
    }),

    todoSaved: (state, payload) => {
      state.items.push(payload)
    }
  }
});

見てわかるようにeffect action内では直接stateを変更していません。 代わりにdispatchを受け取り、actionにdispatchすることでstateを変更しています。 下記のコードを追加しました。

import { effect } from "easy-peasy"; // 追加

export default {
  ...  省略
  // effects
  fetched: effect(async (dispatch, payload) => {
    const SEARCH = "https://api.github.com/search/repositories";
    try {
      dispatch.isProgress(true);
      const result = await axios.get(
        `${SEARCH}?q=${payload}+in:name&sort=stars`
      );
      dispatch.isProgress(false);
      dispatch.search(result);
    } catch (err) {
      dispatch.isProgress(false);
    }
  })
};

payloadには検索フォームで入力した文字列が入ってきます。 あとはプログレスを表示・非表示するだけです。

これで主要なロジックの部分が終わりです。

コンポーネントの作成

コンポーネントを作成していきます。

Saearhコンポーネント

import React, { useRef } from "react";
import Button from "@material-ui/core/Button";
import { useAction } from "easy-peasy";
import styled from "@emotion/styled";

const Wrapper = styled("div")`
  display: flex;
  justify-content: center;
`;

const Input = styled("input")`
  padding: 10px 20px;
  font-size: 20px;
`;

const SearchButton = styled(Button)`
  && {
    margin-left: 18px;
  }
`;
export default function Search() {
  const search = useAction(dispatch => dispatch.fetched);
  const inputEl = useRef(null);

  const handleSearchClick = () => {
    search(inputEl.current.value);
  };

  return (
    <Wrapper>
      <Input ref={inputEl} type="text" />
      <SearchButton
        variant="contained"
        color="primary"
        onClick={handleSearchClick}
      >
        Search
      </SearchButton>
    </Wrapper>
  );
}

大事な箇所は以下の部分です。

const search = useAction(dispatch => dispatch.fetched);

useActionコンポーネント側でstoreのactionがほしい時に使います。 上記ではactionを一つしか呼んでいませんが、もう少し複雑なアプリケーションになると複数のactionを一つのコンポーネントで使いたいケースが多いと思います。

そのような場合は以下のように使用します。 (公式ドキュメントから引用)

import { useState } from 'react';
import { useAction } from 'easy-peasy';

const EditTodo = ({ todo }) => {
  const [text, setText] = useState(todo.text);
  const { saveTodo, removeTodo } = useAction(dispatch => ({
    saveTodo: dispatch.todos.save,
    removeTodo: dispatch.todo.toggle
  }));
  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <button onClick={() => saveTodo(todo.id)}>Save</button>
      <button onClick={() => removeTodo(todo.id)}>Remove</button>
    </div>
  );
};

Resultコンポーネントの作成

import React from "react";
import { useStore } from "easy-peasy";
import Item from "./Item";
import Typography from "@material-ui/core/es/Typography/Typography";

export default function Result() {
  const items = useStore(state => state.items);
  return (
    <>
      {items.length ? (
        items.map((todo, index) => <Item key={index} todo={todo} />)
      ) : (
        <Typography>検索結果は0です</Typography>
      )}
    </>
  );
}

ここでキーとなるのがuseStoreです。 コンポーネント側でstoreに格納されているstateにアクセスしたい時に使用します。 引数にはstateを引数に持つ関数を渡します。 またactionの時と同じように複数のstateを利用したい場合は以下のようにします。

(公式ドキュメントから引用)

import { useStore } from 'easy-peasy';

const BasketTotal = () => {
  const totalPrice = useStore(state => state.basket.totalPrice);
  const netPrice = useStore(state => state.basket.netPrice);
  return (
    <div>
      <div>Total: {totalPrice}</div>
      <div>Net: {netPrice}</div>
    </div>
  );
};

👇分割代入での方法

import { useStore } from 'easy-peasy';

const BasketTotal = () => {
  const { totalPrice, netPrice } = useStore(state => ({
    totalPrice: state.basket.totalPrice,
    netPrice: state.basket.netPrice
  }));
  return (
    <div>
      <div>Total: {totalPrice}</div>
      <div>Net: {netPrice}</div>
    </div>
  );
};

useStoreuseActionはワード的にもわかりやすいですし、複数のactionやstateを取得したい時にも便利ですね。

補足

今回のコードには使用されていませんが、もう一つ紹介します。 reduxのreselectのような機能です。

公式サイトから引用

import { select } from 'easy-peasy'; // 👈 import then helper

const store = createStore({
  shoppingBasket: {
    products: [{ name: 'Shoes', price: 123 }, { name: 'Hat', price: 75 }],
    totalPrice: select(state =>
      state.products.reduce((acc, cur) => acc + cur.price, 0)
    )
  }
}

stateを引数にもつ関数を引数にもちます。 便利なapiが提供されていますね。

react-nativeとも連携することができます。

https://github.com/ctrlplusb/easy-peasy#usage-with-react-native

tsの対応は以下のissueにのっています。

https://github.com/ctrlplusb/easy-peasy/issues/21

以上でhooksで作成されたライブラリeasy-peasyの紹介を終わります。 本当に簡単に実装することができました。