Edited at

React.jsのhooks APIを使ってReduxのような事をしてみた

どうも、初めての方は初めまして、そうじゃない方はお久しぶりです、ヒロです!

この記事は SmartDrive Advent Calendar 2018 - Qiita の9日目です。

今回は 16.7.0-alpha.0 で追加されたhooks APIを使用して react-redux に似たような実装のサンプルを作成したのでご紹介します!

後一瞬だけ告知させてください、来たる 2018年12月30日(日)、コミックマーケット95 2日目(日) 東ト38b にてこれから始める ♡React.js 実践マニュアル という新刊出します。

過去本もいくつか持参するのでもし良ければ下記のページを見てみてください。

過去本一覧ページ

そして今回する紹介する内容はその新刊で紹介する予定だったけど、紙面オーバーして断念してしまった内容になります。汗


Reach The Sky, Without Redux

今回はHacker NewsのAPI を使用して、簡易的なHacker Newsクライアントを作成しました。

下記がサンプルの画面になります。

スクリーンショット 2018-12-09 21.06.18.png

こちらのリポジトリ から手元にクローンしてから yarn start:chapter2 をターミナルで叩く事で上記の画面を確認出来ます。


基本的な構成

リポジトリを見てもらうとわかりますが、基本的な構成は下記のようになっています。

./chapter2

├── index.html
└── src
├── Context.jsx
├── Provider.jsx
├── actions
│   └── index.js
├── components
│   ├── Header
│   │   ├── index.jsx
│   │   └── style.js
│   ├── List
│   │   ├── index.jsx
│   │   └── style.js
│   ├── ListArea
│   │   ├── index.jsx
│   │   └── style.js
│   └── index.js
├── connect.js
├── containers
│   └── App
│   └── index.js
├── index.js
├── modules
│   ├── fetchLogic.js
│   ├── getFavoItems.js
│   ├── getSearch.js
│   └── useWindowWidth.js
├── pages
│   └── MainView
│   └── index.jsx
├── reducers
│   ├── combineReducers.js
│   ├── favorite.js
│   ├── index.js
│   └── posts.js
└── store.js

一つ一つ解説して行きます。


Context.jsxについて

Context.jsx はContextを作成しているファイルになります。hooksを使用すると従来のConsumerの記述よりも少し記述方法が楽になります。

import { useContext } from 'react';

import Context from './Context';

const store = () => {
const { store, dispatch } = useContext(Context);
return {
getState: store,
dispatch
};
};

export default store;

またさらっとstore内のコードを紹介してしまいましたが、 store.js ファイル内ではContextを読み込んで getStatedispatch を返しています。


Provider.jsxについて

react-redux で提供されているProviderのような役割を担っています。


import React, { useState, useReducer, useEffect } from 'react';
import Context from './Context';

type Props = {
children: any,
reducer: () => {}
};

const Provider = ({ children, reducer }: Props) => {
const [store, dispatch] = useReducer(reducer);
const [state, setState] = useState({ isLoaded: false });

useEffect(() => {
dispatch({ type: '@init' });
setState({ isLoaded: true });
}, []);

return (
<Context.Provider value={{ dispatch, store }}>
{state.isLoaded ? children : false}
</Context.Provider>
);
};

export default Provider;

Provider コンポーネントでは propschildrenreducer を受け取っています。 provider コンポーネントがレンダリングされたら useEffect 経由で dispatch({ type: '@init' }); を発火して、 reducer 内で返しているオブジェクト(initialStore)をstoreに反映します。反映が完了したら setStateisLoadedtrue にし、 children をレンダリングします。


reducersについて

reducers には主に2つの役割があります。

- storeに反映させる為のreducer

- reducerを束ね上げるcombineReducers

storeに反映させる為のreducerとは通常のreducerになります。今回のサンプルの中で例を上げると posts.js などですね。

const init = {

data: []
};

const posts = (state = init, action) => {
if (action.type === 'ADD_POST') {
return { ...state, data: action.payload };
}
return state;
};

export default posts;

また combineReducers については reducer を一つの reducer にまとめる役割を持ちます。


const combineReducers = reducer => {
return (state = {}, action) => {
const keys = Object.keys(reducer);
const nextReducers = {};
for (let i = 0; i < keys.length; i++) {
const invoke = reducer[keys[i]](state[keys[i]], action);
nextReducers[keys[i]] = invoke;
}
return nextReducers;
};
};

export default combineReducers;

actionが発火されるとreducer内の全てのケースが発火されて該当するreducerには該当したreducer内の処理が行われ、それ以外は state内に保持してある値がそれぞれ割り振られているので、割り振った値がそのまま返されます。


connect.jsについて

connect.js についてはconnectを React Component に適用した際に mapStateToPropsmapDispatchToProps を適用させる為の処理が記述されています。

import React, { useContext } from 'react';

import Context from './Context';

const connect = (mapState, mapDispatch) => {
return WrappedComponent => {
return () => {
const { store, dispatch } = useContext(Context);
return (
<WrappedComponent {...mapState(store)} {...mapDispatch(dispatch)} />
);
};
};
};

export default connect;

特に難しい事はしてなく、至ってシンプルなHOCになっています。


actionsについて

actionsaction の記述がされています。


export const addTodo = (payload: Array<Object>) => ({
type: 'ADD_POST',
payload
});

export const addFavoItem = (payload: number) => ({
type: 'ADD_FAVORITE_ITEM',
payload: {
id: payload
}
});


componentsにとpagesについて

これはUI用のコンポーネントが記述されています。通常の React Component が記述されている為、ここでは割愛します。ちなみに pages は各page毎の statelessなコンポーネントを置く場所、という立ち位置ですが、今回の場合は1ページしかないので、あんまり分ける意味はなかったです。が、念の為補足します。


modules

ここには非同期周りの処理が記述されています。本当はredux-sagaみたいな事をする、もしくはredux-sagaと互換性を持たせたかったんですが、時間の都合上断念し、非同期周りの記述を modules 配下に設置しています。


containersについて

containers 内の App 内で下記のような記述をしています。


import React, { useEffect, useCallback } from 'react';
import MainView from '@chapter2/pages/MainView';
import useWindowWidth from '@chapter2/modules/useWindowWidth';
import { getSearch, getFavoItems } from '@chapter2/modules/fetchLogic';
import { addTodo, addFavoItem } from '@chapter2/actions';
import connect from '../../connect';

const App = ({ posts, addTodo, addFavoItem, favorite }) => {
const windowWidth = useWindowWidth();

const invokeSearch = async ({ searchValue, keyCode, type = 'default' }) => {
if (keyCode === 13) {
const search = await getSearch(searchValue);
addTodo(search.hits);
return;
}

if (type === 'submit') {
const search = await getSearch(searchValue);
addTodo(search.hits);
return;
}
};

const invokeFavoItems = async () => {
const getItems = await getFavoItems(favorite);
const addingFavoFlag = await getItems.map(item => ({
...item,
isFavorite: true
}));
await addTodo(addingFavoFlag);
};

const memoizePosts = useCallback(
() => {
const mapItem = posts.map(item => {
const checkFavo = favorite.includes(
item.id ? item.id : Number(item.objectID)
);
return {
...item,
isFavorite: checkFavo
};
});
return mapItem;
},
[posts, favorite]
);

useEffect(() => {
(async () => {
try {
const data = await getSearch();
addTodo(data.hits);
} catch (e) {
// error logic
}
})();
}, []);

const hundlers = {
invokeSearch,
posts: memoizePosts(),
windowWidth,
addFavoItem,
invokeFavoItems
};

return <MainView {...hundlers} />;
};

const mapStateToProps = store => ({
posts: store.posts.data,
favorite: store.favorite.favorite_posts
});

const mapDispathToProps = dispatch => ({
addTodo: param => dispatch(addTodo(param)),
addFavoItem: param => dispatch(addFavoItem(param))
});

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

整理されていない部分も多いですが、まず App コンポーネントの propsmapStateToPropsmapDispathToProps でpropsに埋め込む指定をした props を指定します。 invokeSearchinvokeFavoItems 検索した際とお気に入りした記事を一覧で表示させる際に非同期でリクエストしてデータを取得する為の記述です。 memoizePosts については useCallback を使用して、memoizationをしています。 また useEffect 内で初期のデータをリクエストして取得し、 addTodo action経由でstoreに反映させます。

最終的に子コンポーネントpropsを渡す為に hundlers というオブジェクトに渡すpropsをまとめ、 MainView コンポーネントにpropsを渡しています。

最後に connect する事によって完了です。


最後に

如何でしたでしょうか?かなりざっくりした形で紹介してしまいましたが、hooks APIを使用する事で簡易的なreduxのような実装が可能になりました。まだalphaリリースの段階ですが、これが正式にリリースされたら React Class Component を記述する回数が今までよりも減りそうな気がします。

smartdriveではReactが大好きなフロントエンドエンジニアを募集しています!