JavaScript
React
redux

Reduxのサンプルのショッピングカートをステップ・バイ・ステップで Part.3

Reduxのサンプルのショッピングカートをステップ・バイ・ステップで Part.2のつづき。

1.カートに入れるアクション追加

アクションのtypeを追加

constants\ActionTypes.js
export const RECEIVE_PRODUCTS = 'RECEIVE_PRODUCTS'
export const ADD_TO_CART = 'ADD_TO_CART'

ADD_TO_CARTアクションを発行する

ADD_TO_CARTアクションを発行するaddToCart関数をつくる。

actions\index.js
import shop from '../api/shop'
import * as types from '../constants/ActionTypes'

//引数1個を受ける関数を返す関数。 この引数には実行時、dispatchが割り当てられる。
export const getAllProducts = () => d => {
    // コールバックが引数。実行時、商品データの配列が入る。api/products.json
    shop.getProducts(
        products => d(
            {
                type: types.RECEIVE_PRODUCTS,
                products
            }
        )
    )
}
//引数2個を受ける関数を返す関数。
// この引数には実行時、dとgにはdispatchとgetStateが割り当てられる。
export const displayState = () => (d, g) => {
    console.log(g());
}
//実行時、dとgにはdispatchとgetStateが割り当てられる。
//g すなわち getState() は state tree を返す。
export const addToCart = (productId) => (d, g) => {
    if (g().products.byId[productId].inventory > 0) {
        d({
            type: types.ADD_TO_CART,
            productId
        })
    }
}

productsリデューサをADD_TO_CARTに対応させる

変更したところは、byIdリデューサにADD_TO_CARTアクションのときの対応を追加。
"カートに入れる"ボタンを押された商品のinventroyを1減じて、もとのbyIdステートにマージ。
products関数という補助的な関数も追加して、byIdの内部で利用できるようにしている。

reducers\products.js
import { combineReducers } from 'redux';
import { RECEIVE_PRODUCTS, ADD_TO_CART } from '../constants/ActionTypes'

//stateは特定の商品のオブジェクトを想定
// ADD_TO_CARTのときは、引数のオブジェクトのinventoryプロパティの値を
//  1 減じたオブジェクトにして返す。
const products = (state, action) => {

    switch (action.type) {
        case ADD_TO_CART:
            return {
                ...state,
                inventory: state.inventory - 1
            }

        default:
            return state;
    }

}



// stateはbyId
const byId = (state = {}, action) => {
    switch (action.type) {
        case RECEIVE_PRODUCTS:
            return {
                ...state,
                ...action.products.reduce(
                    (obj, product) => {
                        obj[product.id] = product;
                        return obj;
                    }
                    , {}
                )
            }
        // ADD_TO_CARTはこちら
        default:
            const { productId } = action;
            return {
                ...state,
                [productId]: products(state[productId], action)
            }
    }
}
// stateはvisibleIds
const visibleIds = (state = [], action) => {
    switch (action.type) {
        case RECEIVE_PRODUCTS:
            return action.products.map(product => product.id);
        default:
            return state
    }
}

export default combineReducers({ byId, visibleIds })

//idでstateから特定の商品オブジェクトを取得
export const getProduct = (state, id) => {
    return state.byId[id]
}
//商品idの配列から、商品オブジェクトの配列取得
export const getVisibleProducts = state => {
    return state.visibleIds.map(id => getProduct(state, id))
}

ProductItemコンポーネントのクリックを有効化

buttonのonClickのイベントハンドルに onAddToCartClicked を指定。これの中身は
ProductsContainerコンテナでわたされる、addToCart(product.id)の実行を命令する関数。

components\ProductItem.js
import React from 'react'
import PropTypes from 'prop-types'
import Product from './Product'

//個々の商品データを入れる入れ物
// カートに入れるためのボタンもつける
const ProductItem = ({product,onAddToCartClicked})=>(
    <div className="clearfix p-1 rounded mx-auto" style={{ marginBottom: 5, width: '30em', border:'5px solid #eee'}} >
    <Product
      title={product.title}
      price={product.price}
      quantity={product.inventory} />
    <hr />
    <button className="btn btn-warning float-right"
    onClick={onAddToCartClicked}
      disabled={product.inventory > 0 ? '' : 'disabled'}>
      {product.inventory > 0 ? 'カートに入れる' : '売り切れです'}
    </button>
  </div>   
)


ProductItem.propTypes = {
    product: PropTypes.shape({
      title: PropTypes.string.isRequired,
      price: PropTypes.number.isRequired,
      inventory: PropTypes.number.isRequired
    }).isRequired,
    onAddToCartClicked:PropTypes.func.isRequired
  }

  export default ProductItem

ProductsContainerコンテナ修正

ProductsContainerコンテナは、addToCart関数をプロップスとして受け取り、これを一つ一つのProductItemコンポーネントのonAddToCartClickedプロップスに
() => addToCart(product.id)
という関数を指定して渡している。

connect関数の第2引数のオブジェクトにaddToCartを追加するのを忘れないよう注意。

containers\ProductsContainer.js
const ProductsContainer = ({ products, displayState, addToCart }) => (
    <ProductsList title="商品一覧" onClick_displayState={() => displayState()}>
        {products.map(product =>
            <ProductItem
                key={product.id}
                product={product}
                onAddToCartClicked={() => addToCart(product.id)}
            />
        )}
    </ProductsList>
)

ProductsContainer.propTypes = {
    products: PropTypes.arrayOf(PropTypes.shape({
        id: PropTypes.number.isRequired,
        title: PropTypes.string.isRequired,
        price: PropTypes.number.isRequired,
        inventory: PropTypes.number.isRequired
    })).isRequired,
    displayState: PropTypes.func.isRequired,
    addToCart: PropTypes.func.isRequired
}

// ここで ステートの商品情報のオブジェクトの配列を
// productsプロップスに結びつける。
const mapStateToProps = state => {
    return {
        products: getVisibleProducts(state.products)
    }
}

export default connect(
    mapStateToProps, { displayState, addToCart }
)(ProductsContainer)

実行

ソースコード 03-1

2.cartステートをつくる

カート機能のview、目に見える部分は後回しにして、stateのところを先につくる。

cartリデューサをつくる

addedIds, quantityById を cartリデューサの内部でくくって、ひとまとめにしている。

reducers\cart.js
import {
    ADD_TO_CART
} from "../constants/ActionTypes";

// stateのプロパティ
const initialState = {
    addedIds: [],
    // idをプロパティとするオブジェクトで、数量管理
    quantityById: {}
}

// カートに追加されている商品のいdの配列をかえす
const addedIds = (state=initialState.addedIds,action)=>{
    switch (action.type) {
        case ADD_TO_CART:
            if (state.indexOf(action.productId) !== -1) {
                return state;
            } else {
                return [
                    ...state,
                    action.productId
                ]
            }
        default:
            return state;
    }
}
//商品idをプロパティとする数量を値とするオブジェクト
const quantityById = (state = initialState.quantityById, action) => {
    switch (action.type) {
        case ADD_TO_CART:
            const { productId } = action;
            return {
                ...state,
                [productId]: (state[action.productId] || 0) + 1
            }
        default:
            return state;
    }
}

//cartリデューサの実体
//内部で addedIds, quantityById リデューサをひとまとめにして利用している
const cart = (state = initialState, action) => {
    switch (action.type) {
            // ADD_TO_CARTはこちら
        default:
            return{
                addedIds: addedIds(state.addedIds,action),
                quantityById: quantityById(state.quantityById,action)
            }
    }
}

export default cart

index.jsリデューサの修正

すでにつくったproductsリデューサと新しくつくったcartリデューサをひとまとめにしてエクスポート

reducers\index.js
import { combineReducers }  from 'redux';
import products  from './products'
import cart from './cart'

export default combineReducers({products,cart});

これだけでcartステートは完成

実行結果

ソースコード 03-2

3.カートのViewをつくる

ショッピングカートを実際にページにくっつけて、cartステートとつないでみる。

Cartコンポーネントをつくる

商品オブジェクトの配列、productsがプロップスとして受け取ると想定。

components\Cart.js
import React from 'react'
import PropTypes from 'prop-types'
import Product from './Product'

const Cart  = ({ products}) => {
  const hasProducts = products.length > 0
  const nodes = hasProducts ? (
    products.map(product =>
      <Product
        title={product.title}
        price={product.price}
        quantity={product.quantity}
        key={product.id}
      />
    )
  ) : (
    <em>ご希望の商品をカートに追加してください</em>
  )

  return (
    <div className="border border-dark alert alert-secondary rounded p-1 clearfix mx-auto" style={{width:'30em'}}>
      <h5 className="mb-1 text-center">&#x1f6d2;ショッピングカート</h5>
      <div>{nodes}</div>
    </div>
  )
}

Cart.propTypes = {
  products: PropTypes.array
}

export default Cart

CartContainerコンテナをつくる

ここでcartステートをコンテナのプロップスにマップして、下層のCartコンポーネントでステートが使えるようにする。

ここで使っている getCartProducts はまだつくっていないので注意。

containers\CartContainer.js
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { getCartProducts } from '../reducers'
import Cart from '../components/Cart'

const CartContainer = ({ products}) => (
  <Cart
    products={products}
     />
)

CartContainer.propTypes = {
  products: PropTypes.arrayOf(PropTypes.shape({
    id: PropTypes.number.isRequired,
    title: PropTypes.string.isRequired,
    price: PropTypes.number.isRequired,
    quantity: PropTypes.number.isRequired
  })).isRequired
}
const mapStateToProps = (state) => ({
  products: getCartProducts(state)
})

export default connect(mapStateToProps)(CartContainer)

カートの情報を取得するための補助関数をつくる

cart.addedids と cart.quantityById を取得するための補助関数をつくる。
以下をcartリデューサのファイルに追加

reducers\cart.jsの追加部分
//外部で利用するユーティリティ
export const getAddedIds = state => {
    return state.addedIds;
}
//外部で利用するユーティリティ
export const getQuantity = (state, productId) => {
    return state.quantityById[productId] || 0
}

ルートのリデューサを編集

関数をいくつか追加している。

reducers\index.js
import { combineReducers } from 'redux';
// この products は、products.js の冒頭の const products ではない。
// combineReducersの戻り値の、 export defaultされたリデューサ
import products, * as fromProducts from './products'
import cart, * as fromCart from './cart'


// カート内のproductsのidを取得
//ここで stateやidと cart.jsリデュース内のユーティリティと関連付け
const getAddedIds = state => fromCart.getAddedIds(state.cart)
const getQuantity = (state, id) => fromCart.getQuantity(state.cart, id)
const getProduct = (state, id) => fromProducts.getProduct(state.products, id)

export default combineReducers({ products, cart });


export const getCartProducts = state => {
    return getAddedIds(state).map(id => ({
        ...getProduct(state, id),
        quantity: getQuantity(state, id)
    }));
}

CartContainerをDOMに追加

containers\App.js
import React from 'react'
import ProductsContainer from './ProductsContainer'
import CartContainer from './CartContainer';

const App = () => (
  <div className="mx-auto" style={{width:"32em"}}>
    <h4 className="text-center alert alert-danger">🍓フルーツ市場🍈</h4>
    <ProductsContainer />
    <CartContainer />
  </div>
)

export default App

実行結果

ソースコード 03-3