Structuring Redux for Use in React: An Overview

devfish·2023년 2월 27일
0

React

목록 보기
7/10
post-thumbnail

An Overview of State Management using Redux

  • Folder Structure: Actions, Reducers, Store (Single store)

Individual components (Pages) import actions, reducers, store, and react-redux functions to reference and update states!

Store

  • store/store.js : Where we create the store that we want to manage
    • createStore function - function to create the store and put it into the variable. argument is the representative combined reducer function we want to use to update the stores)
  • index.js : where we render the App
    • Provider component - makes the redux store available to any nested components that need to access the redux store
      • You just need to make Provider the parent component like below!
//store.js - createStore, composeEnhancers
import { compose, createStore, applyMiddleware } from "redux";
import rootReducer from '../reducers/index';
import thunk from "redux-thunk";

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?
  window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({})
  : compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)));

export default store;
//index.js - wrap in Provider tag 
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import store from './store/store';
import { Provider } from 'react-redux';
import * as serviceWorker from './serviceWorker';

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

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();

Redux-devtools & Redux-thunk

  • composeEnhancers - Redux devtools extension - to use with chrome devtools (makes it easier to test & debug?)

  • Redux-thunk - Redux Middleware, code that lets us intercept redux actions before they reach the reducer

In Redux, action creators are expected to return objects. However, using Redux Thunk allows us to pass functions within our action creators to create an asynchronous Redux.
... This means that Redux Thunk can be used to make API requests, delay a dispatch, or set dispatch conditions. Essentially, it provides full control over the dispatch method.
(link)

In our code below, the notification dispatch actions are delayed with setTimeout to be triggered after other actions have already been completed.

Actions

  • actions/index.js - where we create actions for dispatching to reducers
    • action objects generally contain two types of keys: type, and payload (it can contain as many keys as we want, but must contain type)

      • action.type - a must include. reducer does something different with the payload depending on what action type is received
      • action.payload - the data passed to the reducer (apparently, calling it payload is just convention and you can name the key whatever you want)

// action types
export const ADD_TO_CART = "ADD_TO_CART";
export const REMOVE_FROM_CART = "REMOVE_FROM_CART";
export const SET_QUANTITY = "SET_QUANTITY";
export const NOTIFY = "NOTIFY";
export const ENQUEUE_NOTIFICATION = "ENQUEUE_NOTIFICATION";
export const DEQUEUE_NOTIFICATION = "DEQUEUE_NOTIFICATION";

// actions creator functions
export const addToCart = (itemId) => {
  return {
    type: ADD_TO_CART,
    payload: {
      quantity: 1,
      itemId
    }
  }
}

export const removeFromCart = (itemId) => {
  return {
    type: REMOVE_FROM_CART,
    payload: {
      itemId
    }
  }
}

export const setQuantity = (itemId, quantity) => {
  return {
    type: SET_QUANTITY,
    payload: {
      quantity: quantity,
      itemId
    }
  }
}

export const notify = (message, dismissTime = 5000) => dispatch => {
  const uuid = Math.random()
  dispatch(enqueueNotification(message, dismissTime, uuid))
  setTimeout(() => {
    dispatch(dequeueNotification())
  }, dismissTime)
}

export const enqueueNotification = (message, dismissTime, uuid) => {
  return {
    type: ENQUEUE_NOTIFICATION,
    payload: {
      message,
      dismissTime,
      uuid
    }
  }
}

export const dequeueNotification = () => {
  return {
    type: DEQUEUE_NOTIFICATION
  }
}

Reducers

  • reducers/initialState.js - where we set the initial 'state' object, for use in reducers
  • reducers/index.js: where the main reducer is defined and linked to the store
    • combineReducers : to combine multiple reducers into a single reducer which can then be passed to the createStore method
  • Individual reducers (reducers/itemReducer.js, reducers/notificationReducer.js) - where we define how we want to be updating the state based on the action payload received
//reducers/initialState.js

export const initialState =
{
  "items": [
    {
      "id": 1,
      "name": "노른자 분리기",
      "img": "../images/egg.png",
      "price": 9900
    },
   
//...
    ],
  "cartItems": [
    {
      "itemId": 1,
      "quantity": 1
    },
    {
      "itemId": 5,
      "quantity": 7
    },
    {
      "itemId": 2,
      "quantity": 3
    }
  ]
}
//reducers/index.js
import { combineReducers } from 'redux';
import itemReducer from './itemReducer';
import notificationReducer from './notificationReducer';

const rootReducer = combineReducers({
  itemReducer,
  notificationReducer
});

export default rootReducer;
//reducers/itemReducer.js
import { REMOVE_FROM_CART, ADD_TO_CART, SET_QUANTITY } from "../actions/index";
import { initialState } from "./initialState";

const itemReducer = (state = initialState, action) => {

  switch (action.type) {
    case ADD_TO_CART:{
      return Object.assign({}, state, {
        cartItems: [...state.cartItems, action.payload]
      })
    }
    case REMOVE_FROM_CART:{
      return Object.assign({}, state, {
        cartItems: state.cartItems.filter(el => el.itemId !== action.payload.itemId)
      })
    }
    case SET_QUANTITY:{
      let idx = state.cartItems.findIndex(el => el.itemId === action.payload.itemId);
      let obj = { itemId: action.payload.itemId, quantity: action.payload.quantity};
      return Object.assign({}, state, {
        cartItems: [
          ...state.cartItems.slice(0, idx),
          obj,
          ...state.cartItems.slice(idx+1)
        ]
      })
    }
    default:
      return state;
  }
}

export default itemReducer;
//reducers/notificationReducer.js
import { ENQUEUE_NOTIFICATION, DEQUEUE_NOTIFICATION } from "../actions/index";
import { initialState } from "./initialState";

const notificationReducer = (state = {notifications:[]}, action) => {

  switch (action.type) {
    case ENQUEUE_NOTIFICATION:
      return Object.assign({}, state, {
        notifications: [...state.notifications, action.payload]
      })
    case DEQUEUE_NOTIFICATION:
      return Object.assign({}, state, {
        notifications: state.notifications.slice(1)
      })
    default:
      return state;
  }
}

export default notificationReducer;

Accessing and Updating State in Components

Two pages both display and update states based on user interaction:
ItemListContainer & ShoppingCart

  • useSelector Hook - takes a function argument to return the part of the state you want
    • If you have multiple reducers defined, you enter the reducer name you're using to manage that particular part of the state as an argument
  • useDispatch Hook - assigns a dispatch function, which delivers the actions to the reducer
  • Imports actions from actions/index.js to dispatch to reducers
  • Event handlers (click interaction with individual items) contain dispatch ations
//pages/ItemListContainer.js
import React from 'react';
import { addToCart, notify } from '../actions/index';
import { useSelector, useDispatch } from 'react-redux';
import Item from '../components/Item';

function ItemListContainer() {
  const state = useSelector(state => state.itemReducer); //state we want to update
  const { items, cartItems } = state;
  const dispatch = useDispatch(); //dispatch function

  const handleClick = (item) => {
    if (!cartItems.map((el) => el.itemId).includes(item.id)) {
      dispatch(addToCart(item.id));
      dispatch(notify(`장바구니에 ${item.name}이(가) 추가되었습니다.`))
    }
    else {
      dispatch(notify('이미 추가된 상품입니다.'))
    }
  }

  return (
    <div id="item-list-container">
      <div id="item-list-body">
        <div id="item-list-title">쓸모없는 선물 모음</div>
        {items.map((item, idx) => <Item item={item} key={idx} handleClick={() => {
          handleClick(item)
        }} />)}
      </div>
    </div>
  );
}

export default ItemListContainer;

The checkedItems state is managed on the ShoppingCart Component level (and not by Redux.) Whenever a checkbox interaction occurs, it triggers a state change, which re-calculates the object total (price, quantity) used and rendered by OrderSummary Component. In short, any checkbox interaction triggers a re-rendering of the ShoppingCart & all its nested components (OrderSummary included.)

//pages/ShoppingCart.js
import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { removeFromCart, setQuantity } from '../actions';
import CartItem from '../components/CartItem'
import OrderSummary from '../components/OrderSummary'

export default function ShoppingCart() {

  const state = useSelector(state => state.itemReducer);
  const { cartItems, items } = state;
  const dispatch = useDispatch();
  const [checkedItems, setCheckedItems] = useState(cartItems.map((el) => el.itemId))

  const handleCheckChange = (checked, id) => {
    if (checked) {
      setCheckedItems([...checkedItems, id]);
    }
    else {
      setCheckedItems(checkedItems.filter((el) => el !== id));
    }
  };

  const handleAllCheck = (checked) => {
    if (checked) {
      setCheckedItems(cartItems.map((el) => el.itemId))
    }
    else {
      setCheckedItems([]);
    }
  };

  const handleQuantityChange = (quantity, itemId) => {
    dispatch(setQuantity(itemId, quantity))
  }

  const handleDelete = (itemId) => {
    setCheckedItems(checkedItems.filter((el) => el !== itemId));
    dispatch(removeFromCart(itemId));
  }

  const getTotal = () => {
    let cartIdArr = cartItems.map((el) => el.itemId)
    let total = {
      price: 0,
      quantity: 0,
    }
    for (let i = 0; i < cartIdArr.length; i++) {
      if (checkedItems.indexOf(cartIdArr[i]) > -1) {
        let quantity = cartItems[i].quantity
        let price = items.filter((el) => el.id === cartItems[i].itemId)[0].price

        total.price = total.price + quantity * price
        total.quantity = total.quantity + quantity
      }
    }
    return total
  }

  const renderItems = items.filter((el) => cartItems.map((el) => el.itemId).indexOf(el.id) > -1)
  const total = getTotal()

  return (
    <div id="item-list-container">
      <div id="item-list-body">
        <div id="item-list-title">장바구니</div>
        <span id="shopping-cart-select-all">
          <input
            type="checkbox"
            checked={
              checkedItems.length === cartItems.length ? true : false
            }
            onChange={(e) => handleAllCheck(e.target.checked)} >
          </input>
          <label >전체선택</label>
        </span>
        <div id="shopping-cart-container">
          {!cartItems.length ? (
            <div id="item-list-text">
              장바구니에 아이템이 없습니다.
            </div>
          ) : (
              <div id="cart-item-list">
                {renderItems.map((item, idx) => {
                  const quantity = cartItems.filter(el => el.itemId === item.id)[0].quantity
                  return <CartItem
                    key={idx}
                    handleCheckChange={handleCheckChange}
                    handleQuantityChange={handleQuantityChange}
                    handleDelete={handleDelete}
                    item={item}
                    checkedItems={checkedItems}
                    quantity={quantity}
                  />
                })}
              </div>
            )}
          <OrderSummary total={total.price} totalQty={total.quantity} />
        </div>
      </div >
    </div>
  )
}

SET_QUANTITY reducer implementation

The SET_QUANTITY action type is supposed to change the quantity of an existing item in in cartItems.

  • First implementation creates an object with the payload and sandwiches it into copies of the existing array (just replacing the index item) to update the state
  • Second implementation creates a copy of the state array, mutates the copy array, then returns it to update the actual state.
//Using slice() and findIndex 
case SET_QUANTITY:{
  let idx = state.cartItems.findIndex(el => el.itemId === action.payload.itemId);
  let obj = { itemId: action.payload.itemId, quantity: action.payload.quantity};
  return Object.assign({}, state, {
    cartItems: [
      ...state.cartItems.slice(0, idx),
      obj,
      ...state.cartItems.slice(idx+1)
    ]
  })
}
//Copy->mutate->assign 
case SET_QUANTITY:{
  let idx = state.cartItems.findIndex(el => el.itemId === action.payload.itemId);
  const arr = [...state.cartItems];
  arr[idx].quantity = action.payload.quantity;
  return Object.assign({}, state, cartItems: arr})
}

//OR 
case SET_QUANTITY:{
let idx = state.cartItems.findIndex(el => el.itemId === action.payload.itemId)
      let newArr = [...state.cartItems];
      newArr[idx] = action.payload;
      return {...state, cartItems: newArr}
}

References

Redux Thunk

How Does Redux-Thunk Work?
Asynchronous Redux using Redux Thunk

Structuring Redux

Redux Selectors Structure

profile
la, di, lah

0개의 댓글