8
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Reactの状態管理ライブラリって結局何を使えばいいの?

この記事は、サイバーウェーブ株式会社 Advent Calendar 2019 の11日目の記事です。

はじめに

Reactでフロントエンド開発を行う際、実際に開発を始める前に考慮しなければいけないことがたくさんあります。
ディレクトリ構成やコンポーネントの粒度、そして同時に使用するライブラリの選定など…
その中でも『状態管理のライブラリに何を使うか』という問題は開発のしやすさに影響が大きい部分だと感じています。
僕自身、React+Reduxというよくある構成を学びそれを使うことが多かったのですが、本当にこの選択肢が正しいのか?ということを常々疑問に思っていたので、この機会に選択肢に上がりそうなものを色々実装してみました。

以下が実装を行なったリポジトリです。

とてもシンプルですが、Todoアプリを以下の5つの状態管理方法で実装しました。

  • 単純なReact
  • React Context API
  • redux
  • mobx
  • unstated-next

リポジトリでは front/src ディレクトリ以下がそれぞれの実装を表していて、実装の際に感じたことや特徴を僕なりに整理します。

実装したTodoアプリの挙動・特徴

動作.gif

よくあるTodoアプリです。

APIはTodoの一覧をJSONで返したり、擬似的に追加や更新を行えるように実装しました。

サンプルとなるTodoアプリは以下ののようなコンポーネント構造をしています。

App
├── Loading
├── TodoAddForm
└── TodoList
    └── Todo

非常にシンプルですが、Reactを使って開発をする際によく管理するような状態をいくつか持つようにしました。

  • サービス全体に関わる状態 → ローディング
  • UIに関わる状態 → Todoのチェック状態、ちょっとシンプル過ぎる感じはしますが...
  • APIから取得されるデータ → ローディングの検証のために少し時間のかかるAPI通信を想定)

実装の解説

単純なReact

Reactのみを使って愚直に実装をした場合です。
一番親のコンポーネントであるAppコンポーネントに全ての状態やAPIへの通信を行うメソッドを実装しました。
ローディング状態によってリストの表示を出し分ける役割も持っています。

App.jsx
const App = () => {
  const [todos, setTodos] = useState([]);
  const [todoName, setTodoName] = useState('');
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetch('/api/todos')
      .then(res => res.json())
      .then(json => {
        setTodos(json);
        setIsLoading(false);
      });
  }, []);

  const handleToggleTodoDone = id => {
    const data = { id };
    fetch('/api/todos', {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json; charset=utf-8'
      },
      body: JSON.stringify(data)
    })
      .then(res => res.json())
      .then(json => setTodos(json));
  };

  const handleChangeTodoName = name => {
    setTodoName(name);
  };

  const handleAddTodo = () => {
    const data = { name: todoName };
    fetch('/api/todos', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json; charset=utf-8'
      },
      body: JSON.stringify(data)
    })
      .then(res => res.json())
      .then(json => {
        setTodoName('');
        setTodos([...todos, json]);
      });
  };

  return (
    <div>
      <h1>React Pure</h1>
      {isLoading ? (
        <Loading />
      ) : (
        <TodoList todos={todos} handleToggleTodoDone={handleToggleTodoDone} />
      )}
      <TodoAddForm
        todoName={todoName}
        handleChangeTodoName={handleChangeTodoName}
        handleAddTodo={handleAddTodo}
      />
    </div>
  );
};

次に、TodoListコンポーネントです。
親コンポーネントから受け取ったpropsTodoコンポーネントの表示に利用しています。

TodoList.jsx
const TodoList = props => {
  const { todos, handleToggleTodoDone } = props;

  return (
    <ul>
      {todos.map(todo => (
        <Todo
          key={todo.id}
          todo={todo}
          handleToggleTodoDone={handleToggleTodoDone}
        />
      ))}
    </ul>
  );
};

Todoコンポーネントでは、受け取ったpropsを元に表示を行なっています。
Appコンポーネントから渡されたhandleToggleTodoDoneがやっとここで使用され、いわゆるバケツリレーの状態です。

Todo.jsx
const Todo = props => {
  const { todo, handleToggleTodoDone } = props;

  return (
    <li>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => handleToggleTodoDone(todo.id)}
      />
      {todo.name}
    </li>
  );
};

最後に、TodoAddFormコンポーネントです。
ここでも、親から渡されたpropsを元に表示や処理を行います。

TodoAddForm.jsx
const TodoAddForm = props => {
  const { todoName, handleChangeTodoName, handleAddTodo } = props;

  return (
    <>
      <input
        type="text"
        value={todoName}
        onChange={e => handleChangeTodoName(e.target.value)}
      />
      <button type="button" onClick={() => handleAddTodo()}>
        Add
      </button>
    </>
  );
};

このくらいの規模のアプリケーションであれば、むしろ実装が楽だなと感じるくらいですが、もし状態が増えた時、またコンポーネントの階層がより深くなって行った時には混乱しそうです。

React Context API

次に、React Context APIを使った場合です。(ここからは主に他の実装との差分を確認します。)

先ほどには無い要素としてcontextsフォルダを作成しました。
ここには、root.jsとしてContextを作成するファイルを置いています。

root.js
import { createContext } from 'react';

const RootContext = createContext();

export default RootContext;

そして、Appコンポーネント内でこれを利用します。

App.jsx
import RootContext from '../contexts/root';
// ~~~ 省略

  return (
    <RootContext.Provider
      value={{
        todos,
        todoName,
        setTodoName,
        handleToggleTodoDone,
        handleChangeTodoName,
        handleAddTodo
      }}
    >
      <div>
        <h1>React Context</h1>
        {isLoading ? <Loading /> : <TodoList />}
        <TodoAddForm />
      </div>
    </RootContext.Provider>
  );
};

先ほど子コンポーネントに渡していた状態やメソッドを全てRootContext.Providervalueとして渡しています。
そして、TodoListコンポーネントやTodoコンポーネントでは次のような実装を行なっています。

TodoList.jsx
const TodoList = () => (
  <RootContext.Consumer>
    {({ todos }) => (
      <ul>
        {todos.map(todo => (
          <Todo key={todo.id} todo={todo} />
        ))}
      </ul>
    )}
  </RootContext.Consumer>
);
Todo.jsx
const Todo = props => {
  const { todo } = props;

  return (
    <RootContext.Consumer>
      {({ handleToggleTodoDone }) => (
        <li>
          <input
            type="checkbox"
            checked={todo.done}
            onChange={() => handleToggleTodoDone(todo.id)}
          />
          {todo.name}
        </li>
      )}
    </RootContext.Consumer>
  );
};

RootContext.Consumerを用いることでRootContext.Providerに渡した値やメソッドを使用することができるため、propsのバケツリレーがなくなります。先ほどの単純なReactの例と比べても、階層の深いコンポーネントなどを利用する際に楽になりそうです。
個人的には、このようにコンポーネントに直接状態やメソッドを使えるようにする場合は、ContainerコンポーネントとPresentationalコンポーネントに分けたくなります。

redux

次に、reduxを使った場合です。
reduxを使う場合には関連ファイルをmodulesというフォルダにまとめています。
modulesの中にはTodoに関する状態を管理するtodos.jsとアプリ全体の状態を管理するapp.jsを作りました。
例としてapp.jsの内容を示します。

app.js
// Actions
const COMPLETE_LOADING = 'COMPLETE_LOADING';

// Reducer
const initialState = {
  isLoading: true
};

const reducer = (state = initialState, action) => {
  switch (action.type) {
    case COMPLETE_LOADING:
      return { ...state, isLoading: false };
    default:
      return state;
  }
};

export default reducer;

// Action Creators
export const completeLoading = () => {
  return {
    type: COMPLETE_LOADING
  };
};

これらに実装したものをコンポーネントにて利用していきます。
例えば、Appコンポーネントにて上記で実装したような状態を利用する場合は

App.jsx
const App = props => {
  const { isLoading, fetchTodos } = props;

  useEffect(() => {
    fetchTodos();
  }, []);

  return (
    <div>
      <h1>Redux</h1>
      {isLoading ? <Loading /> : <TodoList />}
      <TodoAddForm />
    </div>
  );
};

というように実装したAppコンポーネントに対してContainerを用意してあげることになります。

AppContainer.jsx
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import App from './App';
import { completeLoading } from '../modules/app';
import { fetchTodos } from '../modules/todos';

const mapStateToProps = state => {
  return {
    isLoading: state.app.isLoading
  };
};

const mapDispatchToProps = dispatch => {
  return bindActionCreators(
    {
      completeLoading,
      fetchTodos
    },
    dispatch
  );
};

export default connect(mapStateToProps, mapDispatchToProps)(App);

reduxの特徴として、圧倒的に記述量が増え、開発の効率といった面で他の方法と比べてデメリットがあるかもしれません。
その一方、それぞれの責務を明確にしながら開発を進めていけ、開発したいものの規模と相談しながらフォルダ構成の工夫などを行うことで開発スピードを保ちながらも堅牢なコードを書いていけるのでは無いかと思います。

mobx

mobxは、個人的には今回実装してみて一番面白かったです。
mobxではStoreというものを利用して状態を管理します。
今回のTodoアプリではstoresディレクトリのAppStore.jsにて全ての状態を一元管理するStoreを作成しました。
デコレータを用いて実装を行い、@observableで状態を定義し、observableな値を変更する際や副作用のある処理を行う際に@actionを使うことで動作します。

AppStore.js
import { action, observable } from 'mobx';

class AppStore {
  @observable isLoading = true;

  @action
  completeLoading = () => {
    this.isLoading = false;
  };

  @observable todoName = '';

  @observable todos = [];

  @action
  changeTodoName = todoName => {
    this.todoName = todoName;
  };

  @action
  fetchTodos = () => {
    fetch('/api/todos')
      .then(res => res.json())
      .then(json => {
        this.todos = json;
        this.completeLoading();
      });
  };

  @action
  toggleTodoDone = id => {
    const data = { id };
    fetch('/api/todos', {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json; cahrset=utf-8'
      },
      body: JSON.stringify(data)
    })
      .then(res => res.json())
      .then(json => {
        this.todos = json;
      });
  };

  @action
  postTodo = todoName => {
    const data = { name: todoName };
    fetch('/api/todos', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json; charset=utf-8'
      },
      body: JSON.stringify(data)
    })
      .then(res => res.json())
      .then(json => {
        this.todos = [...this.todos, json];
        this.todoName = '';
      });
  };
}

export default new AppStore();

そして、これをindex.jsProviderに渡します。

index.js
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'mobx-react';
import App from './components/App';
import store from './stores/AppStore';

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('app')
);

storeとして渡したものを使いたい時には、コンポーネントにinjectします。
また、observerを利用することで、状態の変化に反応するようになります。

App.js
const App = inject('store')(
  observer(({ store }) => {
    const { isLoading, fetchTodos } = store;

    useEffect(() => {
      fetchTodos();
    }, []);

    return (
      <div>
        <h1>MobX</h1>
        {isLoading ? <Loading /> : <TodoList />}
        <TodoAddForm />
      </div>
    );
  })
);

mobxは、デコレータを用いてサクサクStoreを作っていけるので、秩序を保ちながらもスピード感のある開発が両立出来るのでは無いかと思いました。僕がもし個人でサービスを作りたいとなった時にはmobxを使う気がします。

unstated-next

最後に、unstated-nextです。
他のライブラリと比べてものすごくシンプルなのが特徴です。

unstated-next独自の実装は、containers/index.jsが主です。

index.js
import { useState } from 'react';
import { createContainer } from 'unstated-next';

function useRoot() {
  const [todos, setTodos] = useState([]);
  const [todoName, setTodoName] = useState('');
  const [isLoading, setIsLoading] = useState(true);

  const fetchTodos = () => {
    fetch('/api/todos')
      .then(res => res.json())
      .then(json => {
        setTodos(json);
        setIsLoading(false);
      });
  };

  const toggleTodoDone = id => {
    const data = { id };
    fetch('/api/todos', {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json; charset=utf-8'
      },
      body: JSON.stringify(data)
    })
      .then(res => res.json())
      .then(json => setTodos(json));
  };

  const postTodo = () => {
    const data = { name: todoName };
    fetch('/api/todos', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json; charset=utf-8'
      },
      body: JSON.stringify(data)
    })
      .then(res => res.json())
      .then(json => {
        setTodoName('');
        setTodos([...todos, json]);
      });
  };

  return {
    todos,
    todoName,
    isLoading,
    setTodoName,
    setIsLoading,
    fetchTodos,
    toggleTodoDone,
    postTodo
  };
}

const RootContainer = createContainer(useRoot);
export default RootContainer;

単純なReactのところで実装した状態やメソッドを丸々外に出すようなContainerを作成します。
そして、index.jsなどで以下のように実装することで、子コンポーネントで利用可能になります。

index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';
import RootContainer from './containers';

ReactDOM.render(
  <RootContainer.Provider>
    <App />
  </RootContainer.Provider>,
  document.getElementById('app')
);

例えば、TodoAddForm.jsxなどでRootContainer.useContainer()を実行することでrootContainerを使うことが出来るようになります。

TodoAddForm.jsx
const TodoAddForm = () => {
  const rootContainer = RootContainer.useContainer();

  return (
    <>
      <input
        type="text"
        value={rootContainer.todoName}
        onChange={e => rootContainer.setTodoName(e.target.value)}
      />
      <button type="button" onClick={() => rootContainer.postTodo()}>
        Add
      </button>
    </>
  );
};

非常にシンプルで、READMEも少ないので全体をすぐに把握して使うことができました。一方、どのように使えばこのライブラリを活かせるのかという点が今回のTodoアプリ実装ではわかりづらかったので、もう少し使ってみる必要がありそう?と感じました。

最後に

かなり大雑把な解説が多くなってしまったので、実際のリポジトリをみないと実装の詳細が掴みづらいかもしれません。
色々実装を行い触ってみたことで、これから技術選定が必要な場面においてより良い選択ができたらと思います。

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
Sign upLogin
8
Help us understand the problem. What are the problem?