この記事は、サイバーウェーブ株式会社 Advent Calendar 2019 の11日目の記事です。
はじめに
Reactでフロントエンド開発を行う際、実際に開発を始める前に考慮しなければいけないことがたくさんあります。
ディレクトリ構成やコンポーネントの粒度、そして同時に使用するライブラリの選定など…
その中でも『状態管理のライブラリに何を使うか』という問題は開発のしやすさに影響が大きい部分だと感じています。
僕自身、React+Reduxというよくある構成を学びそれを使うことが多かったのですが、本当にこの選択肢が正しいのか?ということを常々疑問に思っていたので、この機会に選択肢に上がりそうなものを色々実装してみました。
以下が実装を行なったリポジトリです。
とてもシンプルですが、Todoアプリを以下の5つの状態管理方法で実装しました。
- 単純なReact
- React Context API
- redux
- mobx
- unstated-next
リポジトリでは front/src ディレクトリ以下がそれぞれの実装を表していて、実装の際に感じたことや特徴を僕なりに整理します。
実装したTodoアプリの挙動・特徴
よくあるTodoアプリです。
APIはTodoの一覧をJSONで返したり、擬似的に追加や更新を行えるように実装しました。
サンプルとなるTodoアプリは以下ののようなコンポーネント構造をしています。
App
├── Loading
├── TodoAddForm
└── TodoList
└── Todo
非常にシンプルですが、Reactを使って開発をする際によく管理するような状態をいくつか持つようにしました。
- サービス全体に関わる状態
→ ローディング - UIに関わる状態
→ Todoのチェック状態、ちょっとシンプル過ぎる感じはしますが... - APIから取得されるデータ
→ ローディングの検証のために少し時間のかかるAPI通信を想定)
実装の解説
単純なReact
Reactのみを使って愚直に実装をした場合です。
一番親のコンポーネントであるApp
コンポーネントに全ての状態やAPIへの通信を行うメソッドを実装しました。
ローディング状態によってリストの表示を出し分ける役割も持っています。
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
コンポーネントです。
親コンポーネントから受け取ったprops
をTodo
コンポーネントの表示に利用しています。
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
がやっとここで使用され、いわゆるバケツリレーの状態です。
const Todo = props => {
const { todo, handleToggleTodoDone } = props;
return (
<li>
<input
type="checkbox"
checked={todo.done}
onChange={() => handleToggleTodoDone(todo.id)}
/>
{todo.name}
</li>
);
};
最後に、TodoAddForm
コンポーネントです。
ここでも、親から渡されたprops
を元に表示や処理を行います。
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
を作成するファイルを置いています。
import { createContext } from 'react';
const RootContext = createContext();
export default RootContext;
そして、App
コンポーネント内でこれを利用します。
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.Provider
のvalue
として渡しています。
そして、TodoList
コンポーネントやTodo
コンポーネントでは次のような実装を行なっています。
const TodoList = () => (
<RootContext.Consumer>
{({ todos }) => (
<ul>
{todos.map(todo => (
<Todo key={todo.id} todo={todo} />
))}
</ul>
)}
</RootContext.Consumer>
);
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
の内容を示します。
// 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
コンポーネントにて上記で実装したような状態を利用する場合は
const App = props => {
const { isLoading, fetchTodos } = props;
useEffect(() => {
fetchTodos();
}, []);
return (
<div>
<h1>Redux</h1>
{isLoading ? <Loading /> : <TodoList />}
<TodoAddForm />
</div>
);
};
というように実装したApp
コンポーネントに対してContainer
を用意してあげることになります。
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
を使うことで動作します。
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.js
でProvider
に渡します。
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
を利用することで、状態の変化に反応するようになります。
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
が主です。
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
などで以下のように実装することで、子コンポーネントで利用可能になります。
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
を使うことが出来るようになります。
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アプリ実装ではわかりづらかったので、もう少し使ってみる必要がありそう?と感じました。
最後に
かなり大雑把な解説が多くなってしまったので、実際のリポジトリをみないと実装の詳細が掴みづらいかもしれません。
色々実装を行い触ってみたことで、これから技術選定が必要な場面においてより良い選択ができたらと思います。