現場で開発しているサービスでは、Reactの状態管理にReduxではなくContext(およびApollo Client)を使用しています。
ただ、「なぜContextを使用しているのか」「Reduxとの使い分けはどのように行うのか」「そもそものContextの使い方」などの理解が正直曖昧なままでした。
今月はQiitaのフロントエンド強化月間ということで、Contextについて理解が曖昧な部分を学びなおし、まとめてみることにしました。
ReduxとApollo Clientの比較記事も以前書いているので、あわせて参考にしていただければ幸いです。
#ContextとReduxの違い
Contextはネストしたコンポーネントにおいて、データをpropsを通して一階層ずつ手動で渡すことなく、コンポーネントツリー内であればデータを共有できるような仕組みを提供します。
このようにContextはReduxと同じ性質をもっているのですが、実装方法以外にもいくつかの違いがあります。
##Reduxの長短
【長所】
- ReduxはライブラリでありReact以外のフレームワークでも利用できる
- 1つのstoreですべてのStateを管理できる
- Middlewareが使えるため実装の自由度が高い(ex. Saga, Thunkなどを使えば非同期処理によるAPIからのデータ取得処理の実装が簡単、プラットフォームに依存する処理をMiddlewareに記述することで容易にテスト=保守性を向上)
【短所】
- Action CreatorやReducerなどがありプロジェクトの構造が複雑になる(コード量、バンドルサイズが大きい)
- データ管理のフローやAction CreatorやReducerなどの概念がイメージしづらく実装のコストが高い
##Contextの長短
【長所】
- Reduxよりもデータ管理のフローがイメージしやすく実装のコストが低い
- Reduxよりもプロジェクトの構造を理解しやすい(コード量、バンドルサイズが小さい)
【短所】
- Contextで呼び出したデータとコンポーネントとの依存度が高い(=コンポーネントの再利用に制限がかかる)
- contextを複数作れるためプロジェクトが大きくなるたびに新しいcontextを作成するのが面倒
大規模プロジェクトで非同期の処理が多いようならRedux、小~中規模のプロジェクトでチームメンバーのReact歴が浅いようならContextを利用するといったケースが多いようです。
このように、「どちらの方が優れている」といった関係ではなく、あくまで実装するエンジニアや開発するプロジェクトの規模などによって使い分けがされているような関係となっています。
#Contextの実装方法(functionalコンポーネント)
ReactのuseContext Hookを利用して、Contextをfunctionalコンポーネントで実装する方法を記述します。
##contextオブジェクトの作成
contextオブジェクトは、コンポーネント間でデータ(state, function)を運ぶ役割をするものです。
Reactがcontextオブジェクトの登録されているコンポーネントをレンダリングする場合、ツリー内の最も近い位置にあるProviderから現在のcontextの値を読み取ります。
export const CartContext = createContext(defaultValue);
createContextに与えているdefaultValueは、コンポーネントがツリー内の上位に対応するProviderを持っていない場合のみ使用されます。
defaultValueは単独でのコンポーネントのテストで役に立つことが多いようです。
const defaultValue = {
hidden: true,
toggleHidden: () => {},
cartItems: [],
addItem: () => {},
removeItem: () => {},
clearItemFromCart: () => {},
cartItemsCount: 0,
};
##Context.Providerの作成
contextオブジェクトには、Providerというコンポーネントが付属しています。
コンポーネントをProviderでラッピングしてあげると、Providerのvalueプロパティに与えたデータ(state, function)がそのコンポーネント(Consumer)に渡されます。
また、Providerはラッピングした内部のすべてのコンポーネント(Consumer)と接続することができ、Providerのvalueプロパティが変更されるたびにそれらのコンポーネントは再レンダリングされます。
ちなみに、Providerからその子孫Consumerへの伝播は、shouldComponentUpdateメソッド(もしくはReact.memo)の影響を受けません。
つまり、Consumerは祖先のコンポーネントが更新をスキップしている場合でも更新されます。
*React.memoによる更新スキップについては以下の記事をみていただければと思います。
以下のケースでは、CartProvider
内でvalueに与える値と値を変更する関数を定義しています。
<CartContext.Provider value={{...}}>{children}</CartContext.Provider>
をreturnしてあげることで、CartProvider
でラッピングしたコンポーネント(Consumer)にvalueのデータが渡るようになります。
const CartProvider = ({ children }) => {
const [hidden, setHidden] = useState(true);
const [cartItems, setCartItems] = useState([]);
const [cartItemsCount, setCartItemsCount] = useState(0);
const addItem = (item) => setCartItems(addItemToCart(cartItems, item));
const removeItem = (item) =>
setCartItems(removeItemFromCart(cartItems, item));
const toggleHidden = () => setHidden(!hidden);
const clearItemFromCart = (item) =>
setCartItems(filterItemFromCart(cartItems, item));
useEffect(() => {
setCartItemsCount(getCartItemsCount(cartItems));
}, [cartItems]);
return (
<CartContext.Provider
value={{
hidden,
toggleHidden,
cartItems,
addItem,
removeItem,
clearItemFromCart,
cartItemsCount,
}}
>
{children}
</CartContext.Provider>
);
};
export default CartProvider;
ファイルの中身全体は以下のようになります。
import React, { createContext, useState, useEffect } from 'react';
import {
addItemToCart,
removeItemFromCart,
filterItemFromCart,
getCartItemsCount,
} from './cart.utils';
const defaultValue = {
hidden: true,
toggleHidden: () => {},
cartItems: [],
addItem: () => {},
removeItem: () => {},
clearItemFromCart: () => {},
cartItemsCount: 0,
};
export const CartContext = createContext(defaultValue);
const CartProvider = ({ children }) => {
const [hidden, setHidden] = useState(true);
const [cartItems, setCartItems] = useState([]);
const [cartItemsCount, setCartItemsCount] = useState(0);
const addItem = (item) => setCartItems(addItemToCart(cartItems, item));
const removeItem = (item) =>
setCartItems(removeItemFromCart(cartItems, item));
const toggleHidden = () => setHidden(!hidden);
const clearItemFromCart = (item) =>
setCartItems(filterItemFromCart(cartItems, item));
useEffect(() => {
setCartItemsCount(getCartItemsCount(cartItems));
}, [cartItems]);
return (
<CartContext.Provider
value={{
hidden,
toggleHidden,
cartItems,
addItem,
removeItem,
clearItemFromCart,
cartItemsCount,
}}
>
{children}
</CartContext.Provider>
);
};
export default CartProvider;
##Context.Providerのラッピング
最上位のコンポーネントを作成したProvider(CartProvider
)でラッピングします。
これでvalueのデータを<App/>
内のコンポーネントすべてで使用することができるようになります。
import CartProvider from './providers/cart/cart.provider';
ReactDOM.render(
<CartProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</CartProvider>,
document.getElementById('root')
);
##useContextによるコンポーネント内でのデータ取得
const { toggleHidden, cartItemsCount } = useContext(CartContext)
のようにuseContext
にCartContextオブジェクトを与えてあげることで、Providerのvalueのデータを取得することができます。
データの現在値は、ツリー内でこのuseContextを呼んだコンポーネントの直近にあるProviderのvalueの値によって決定されます。
import React, { useContext } from 'react';
import { CartContext } from '../../providers/cart/cart.provider';
import { ReactComponent as ShoppingIcon } from '../../assets/shopping-bag.svg';
import './cart-icon.styles.scss';
const CartIcon = () => {
const { toggleHidden, cartItemsCount } = useContext(CartContext);
return (
<div className="cart-icon" onClick={toggleHidden}>
<ShoppingIcon className="shopping-icon" />
<span className="item-count">{cartItemsCount}</span>
</div>
);
};
export default CartIcon;
#参考資料