iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 27
2
Modern Web

從比入門再往前一點開始,一直到深入React.js系列 第 27

【Day.27】React進階 - 用useReducer定義state的更動原則

通常當我們要設定state時,都是透過setState(要指定的值)。但這樣做有兩個問題:

  • 使用setState的元件可以任意指定值給state
  • state結構複雜、但我們又只有要修改其中部分值時,很容易出錯。

舉例來說,這次有個鐵人賽參賽者(我忘記在哪看到的了)提及他想要用這樣的方式來處理資料:

const [data, setData] = useState({ A: a, B: b });

然後他想分別建立兩個單獨設定AB的按鍵:

<button onClick={()=>{ setData({A: newA}) }}></button>
<button onClick={()=>{ setData({B: newB}) }}></button>

然而這樣的寫法是錯的。因為useState給出來的setData函式並不會自動去修改物件中的單一屬性,而是直接把你丟給setData的參數整個變成data新的值。以A為例,按下設定A的按鍵後,新的data不會是{ A: newA, B: b },而是{ A: newA }

最後,那位參賽者用ES6的spread operator展開原始的data物件,解決這個問題。

<button onClick={()=>{ setData({...data, A: newA}) }}></button>
<button onClick={()=>{ setData({...data, B: newB}) }}></button>

雖然這樣做的確解決了他的case,但是如果物件資料變的很複雜呢?如果我們要修改的結構散佈在物件各層呢? 要如何才能確保state的修改不會被同事改錯呢?

action | reducer | dispatch

因為剛剛的問題在大型網站上常常出現,Facebook的開發者針對這點提出了Flux設計模式。這裡我們不會詳述Flux,不過簡而言之就是當我們在做資料管理、流程設計時,不應該讓別人能夠隨意修改,而是我們要預先定義好修改的規則,並讓其他開發者透過這些規則來操作

在Flux觀念下,我們操作流程和資料的過程大概變成像這樣:

  1. 管理者預先定義好有哪些規則(action)可以使用
  2. 管理者預先定義好規則(action)對應到的邏輯運算(store/reducer)是什麼。
  3. 操作者透過一個溝通用的函式(dispatch),把他選擇的規則(action)和需要的參數(payload)丟給管理者
  4. 流程/資料透過管理者規定好的方式更新

由於React最通用的狀態管理工具Redux(下一篇會講它)是採用Flux結構,而在Redux中reducer跟store扮演的角色是一樣的,所以我們這裡放入說明的同一個地方。接下來的說明我們也會以Redux的架構為主

上圖截自Facebook對於flux的說明影片

useReducer

useReducer是React提供用來簡易實現Flux架構的React hook,基本上它就是一個「能夠預先定義state設定規則」的useState。

和useState不同的是,useReducer必須要接收兩個參數。第一個是函式,要定義有哪些規則、規則對應的邏輯。第二個則是state的初始值。useReducer的語法為下:

const [state, dispatch] = useReducer(reducerFunc, initStateValue);

操作者可以透過dispatch函式傳送參數:

dispatch({type: "ADD"})

當操作者呼叫dispatch後,reducerFunc會被呼叫並接收到兩個參數。第一個是state先前的值,第二個則是操作者剛剛傳入dispatch的參數。reducerFunc必須要接收一個回傳值,這個回傳值會變成state新的值:

const reducerFunc = function(state, action){
    // action get { type:"ADD" }
    switch(action.type){
        case "ADD":
            return state+1; // new State
        case "SUB":
            return state-1; // new State
        default:
            throw new Error("Unknown action");
    }
}

以上面的那位參賽者的狀況來說,它可以改成這樣:

const reducer = function(state, action){
    // 由於JS物件類似call by ref,先複製一份避免直接修改造成非預期錯誤
    const stateCopy = Object.assign({}, state);
    
    switch(action.type){
        case "SET_A":
            stateCopy.A = action.A;
            return stateCopy; // new State
        case "SET_B":
            stateCopy.B = action.B;
            return stateCopy; // new State
        default:
            throw new Error("Unknown action");
    }
}

之後只要這樣使用,程式碼就會更直觀,也能避免不小心在哪個地方寫錯導致state被覆蓋:

<button onClick={()=>{ dispatch({type: "SET_A", A: newA}) }}></button>
<button onClick={()=>{ dispatch({type: "SET_B", B: newB}) }}></button>

加入useReducer到我們的程式中吧

現在,我們來試著讓先前的isOpen改成用useReducer改變。

在src/page/MenuPage.js中,先定義reducer:

const reducer = function(state, action){
    switch(action.type){
        case "SWITCH": 
            return !state; // 只有開/關
        default:
            throw new Error("Unknown action");
    }
}

接著引入useReducer,並在元件中取得state和dispatch

import React, { useState, useReducer,useMemo,useEffect} from 'react';
const reducer = function(state, action){
    switch(action.type){
        case "SWITCH":
            return !state;
        default:
            throw new Error("Unknown action");
    }
}

const MenuPage = () =>{
    const [isOpen, isOpenDispatch] = useReducer(reducer,true);

然後綁定在本來的Context的setOpenContext

  • src/page/MenuPage.js
import React, { useState, useReducer,useMemo } from 'react';
import useMouseY from '../util/useMouseY';

import MenuItem from '../component/MenuItem';
import Menu from '../component/Menu';
import { OpenContext } from '../context/ControlContext';

let menuItemWording=[
    "Like的發問",
    "Like的回答",
    "Like的文章",
    "Like的留言"
];

const reducer = function(state, action){
    switch(action.type){
        case "SWITCH":
            return !state;
        default:
            throw new Error("Unknown action");
    }
}

const MenuPage = () =>{
    const [isOpen, isOpenDispatch] = useReducer(reducer,true);
    const [menuItemData, setMenuItemData] = useState(menuItemWording);
    let menuItemArr = useMemo(()=>menuItemData.map((wording) => <MenuItem text={wording} key={wording}/>),[menuItemData]);

    return (
        <OpenContext.Provider value={{ 
            openContext: isOpen, 
            setOpenContext: isOpenDispatch
        }} >
            <Menu title={"Andy Chang的like"}>
                {menuItemArr}
            </Menu>
            <button onClick={()=>{
                let menuDataCopy = ["測試資料"].concat(menuItemData);
                setMenuItemData(menuDataCopy); 
            }}>更改第一個menuItem</button>
        </OpenContext.Provider>
    );
}

export default MenuPage;

最後讓呼叫dispatch的Menu不是直接傳isOpen新的值,而是傳入要使用的type:SWITCH即可:

  • src/component/Menu.js
import React, {useContext, useMemo} from 'react';
import { OpenContext } from '../context/ControlContext';

const menuContainerStyle = {
    position: "relative",
    width: "300px",
    padding: "14px",
    fontFamily: "Microsoft JhengHei",
    paddingBottom: "7px",
    backgroundColor: "white",
    border: "1px solid #E5E5E5",
};

const menuTitleStyle = {
    marginBottom: "7px",
    fontWeight: "bold",
    color: "#00a0e9",
    cursor: "pointer",
};

const menuBtnStyle = {
    position: "absolute",
    right: "7px",
    top: "33px",
    backgroundColor: "transparent",
    border: "none",
    color: "#00a0e9",
    outline: "none"
}

function Menu(props){
    const isOpenUtil = useContext(OpenContext);
    return (
        <div style={menuContainerStyle}>
            <p style={menuTitleStyle}>{props.title}</p>
            <button style={menuBtnStyle} onClick={
                ()=>{isOpenUtil.setOpenContext({type: "SWITCH"})}}>
                {(isOpenUtil.openContext)?"^":"V"}
            </button>
            <ul>{props.children}</ul>
        </div>
    );
}

export default Menu;

這樣我們就能保護isOpen,不會哪天出現isOpen被變成非布林值的狀況。


上一篇
【Day.26】React進階 - useEffect v.s useLayoutEffect
下一篇
【Day.28】React進階 - 導入Redux,讓元件溝通更簡潔
系列文
從比入門再往前一點開始,一直到深入React.js30

尚未有邦友留言

立即登入留言