Coding Hero

We solve your problems

React hooks 進階 - custom hooks

2019-10-24 Frankreact

Banner

使用了 hooks 之後,我就再也回不去了…

本篇要介紹的是如何使用 custom hooks 做狀態的封裝與 code reuse,並與 Redux 做比較。

在正文開始前,先說個小故事。還記得一年多前,React 16.8.0-alpha 剛問世,公司第一個採用 React 開發的案子也即將上線,剛好因為主管的人脈,全公司的工程師到一家專做 React 接案的新創公司做參訪,順便技術交流,看能不能從對方那邊學點東西過來。

我們第一個案子使用的是 React + Redux,第一個案子即將上線在即,自家工程師對於 Redux 的痛點也踩得差不多了,不外乎就是資料流動的方式對於新手來說,似乎過於抽象難以理解、除了要了解 Redux 架構本身之外,還要了解 HOC 和 Context api 的結合與 curry 函數,理解前要先備好一堆先備知識,前期學習曲線陡峭、滿滿的模板代碼、寫一堆重複的 code 後老鳥開始感到無聊…

但不得不說,Redux 的缺點同時也是它的優點,因為它把 action creator, reducer, dispatch, middleware, mapStateToProps, mapDispatchToProps 等各兄弟各司其職,功能執掌切分得很好,大家在同樣的模板框架下寫作,寫出來的程式碼也比較有一致性。但當時 hooks 剛出,對於這個新東西,除了好奇之外還是不免會有疑問,hooks 真的好用嗎?

因此我們就把這個問題拿來問對方的 CTO,對方給我們的回答是「覺得 Redux 就很好用了,而且公司現有的 code base 已經可以大量重複利用做快速開發。」

我想台灣的公司對於業界新趨勢與新技術的接受程度,基於既有架構的限制,還是趨向保守或觀望的佔大多數。

光陰似箭,一年的時間過去,國外的開發者已經大量使用 hooks,主流的開源套件也紛紛轉向支持 hooks,而最近公司案量龐大人力吃緊,公司高層討論後決定外包找外援。一問之下,才知道台灣大概有九成的公司都還在使用 Redux,甚至 freelance 的接案個體戶,會用 hooks 的也還是鳳毛麟角。我身為一個 hooks 剛出沒多久就在使用的先行者,一定要多做一點推廣,推動一下風氣,且最近公司同事也在問我要如何用 custom hooks 做狀態共用,因此有了這篇文章的誕生。

接下來我們將會實作一個小型 Form 的專案,模擬開發時的使用情境,讓你對於 hooks 對比 Redux 的差異(用 hooks 來寫到底有多簡潔!),有個初步的概觀。

專案架構

專案架構

hooks-demo 的起始點是 HooksDemo,裡面包裹了 FormWrapper,而 FormWrapper 裡面則包裹了多個 InputGroup component。而 old-school 的起始點是 OldSchoolDemo,基本架構大同小異,不同的地方在於 index.js 裡包裹了 Redux 的 Provider,還有 Redux 所需的 action creator 與 reducer 等。

我們會實作一個簡易陽春的 form 表單,上方使用 hooks,下方使用 Redux,功能一模一樣。

按下按鈕後,會將內部狀態 alert 到畫面上,如下所示:

demo.gif

我們在使用 React 開發專案時,有兩個最主要的思維考量:

  1. 組件內部狀態的決定,是否需要內部狀態?需要內部狀態的話,狀態是否需要跟別人共用?需要共用的話,狀態是要往下傳遞給子類別,還是和同層級或者父層級以上的組件共用?
  2. 代碼的組件化與抽象

結合上述兩者的綜合考量,將會決定內部狀態是否需要往上提取,或者向下傳遞,或者把內部狀態留在自己家把玩,肥水不落外人田。

而傳遞狀態的方法不外乎是當作 props 直接往下傳,或者是使用 global 的管理機制,例如主流的 Redux,藉由 HOC,把需要的狀態用隔空抓藥的方式綁定給組件。而更新 state 的方式,懶一點可以直接使用一個 callback 包裹 setState 直接往下傳,示意如下:

class Foo extends Component {
  state = { name: "" }

  // ...處理其他的邏輯/操作

  handleNameChange = e => this.setState({ name: e.target.value })

  return (
    <>
      <ComponentA />
      <ComponentB setName={this.handleNameChange} />
    </>
  )
}

雖然這樣的寫法比較簡單,但平心而論這不是一個很理想的方式,它適用的場景僅限於簡單的組件架構,不然經過了層層傳遞加上一定程度的抽象化之後,時常會導致忘記這個組件到底在傳遞什麼,而且追蹤困難,因此主流的做法通常會採用 Redux 做統一的派發處理,因此我們會這樣寫:

import React, { Component } from "react"
import { connect } from "react-redux"
import FormWrapper from "./FormWrapper"

class OldSchoolDemo extends Component {
  render() {
    return (
      <div className="demo">
        Class:
        <FormWrapper />
        <button onClick={() => alert(`name: ${this.props.name}, address: ${this.props.address}`)}>
          Get Data
        </button>
      </div>
    )
  }
}

const mapStateToProps = state => {
  return {
    name: state.demo.name,
    address: state.demo.address
  }
}

export default connect(mapStateToProps)(OldSchoolDemo)

import React from "react"
import InputGroup from "./InputGroup"

const FormWrapper = () => {
  // 這邊用來模擬表單內很多重複的InputGroup,因此將重複的代碼做了組件化
  return (
    <form autoComplete="new-password">
      <InputGroup fieldName="Name" />
      <InputGroup fieldName="Address" />
    </form>
  )
}

export default FormWrapper

import React, { Component } from "react"
import { connect } from "react-redux"
import classnames from "classnames"
import { updateAddress, updateName } from "../actions"

class InputGroup extends Component {
  // focus為純內部狀態,用來控制Input onFocus/onBlur的變色與否
  state = { focus: false }

  render() {
    const { fieldName } = this.props

    return (
      <div className="demo__input-group">
        <label className="demo__input-group__label">{fieldName}:</label>
        <input
          className={classnames("demo__input-group__input", {
            "demo__input-group__input--focus": this.state.focus
          })}
          type="text"
          value={fieldName.toLowerCase() === "name" ? this.props.name : this.props.address}
          onChange={e => {
            if (fieldName.toLowerCase() === "name") {
              this.props.updateName(e.target.value)
            } else {
              this.props.updateAddress(e.target.value)
            }
          }}
          onFocus={() => this.setState({ focus: true })}
          onBlur={() => this.setState({ focus: false })}
          autoComplete="off"
        />
      </div>
    )
  }
}

const mapStateToProps = state => {
  return {
    name: state.demo.name,
    address: state.demo.address
  }
}

const mapDispatchToProps = {
  updateAddress,
  updateName
}

export default connect(
  mapStateToProps,
  mapDispatchToProps
)(InputGroup)

此外,再加入組件狀態共用都需要的 action creatorreducer

export const updateName = name => {
  return {
    type: "UPDATE_NAME",
    payload: name
  }
}

export const updateAddress = address => {
  return {
    type: "UPDATE_ADDRESS",
    payload: address
  }
}

const INITIAL_STATE = {
  name: "",
  address: ""
}

const reducer = (state = INITIAL_STATE, action) => {
  switch (action.type) {
    case "UPDATE_NAME":
      return { ...state, name: action.payload }

    case "UPDATE_ADDRESS":
      return { ...state, address: action.payload }
    default:
      return state
  }
}

export default reducer

前面有提到,內部狀態要考慮是否需要和別的組件共用的情形,在 Input.js 裡面有一個內部狀態 focus 用來控制 Input 組件 border 的變色,這是屬於純內部狀態不需要跟別人共用的情形,因此我們會傾向把它放在組件的內部,而 onFocusonBlur 這兩個 event 的 handler,因為和內部狀態 focus 耦合,因此也不適合搬到 global state 裡去做 code reuse。

接下來我們來看看使用 hooks + context 的情形,首先將內部狀態抽象化,並創建一個 context:

import { useState } from "react"

export const useFormInput = (initialValue = "") => {
  const [value, setValue] = useState(initialValue)
  const [focus, setFocus] = useState(false)

  return {
    value,
    focus,
    onChange: e => setValue(e.target.value),
    onFocus: () => setFocus(true),
    onBlur: () => setFocus(false)
  }
}

import { createContext } from "react"

const HooksContext = createContext()

export default HooksContext

這邊仔細觀察可以發現,在 useFormInput 裡,有前面提到的 focus 狀態,由於這個狀態只被 binding 的 Input 組件所依賴,我們可以把這個內部狀態放心地一起封裝到 custom hook 裡,並且連組件的 event handler 當作肯德基全家桶一樣地一起封裝。

接下來是組件主體的部分,使用 <HooksContext.Provider /> 包裹子組件與需要被共用的狀態:

import React from "react"
import FormWrapper from "./FormWrapper"
import { useFormInput } from "../hooks"
import HooksContext from "../HooksContext"

const HooksDemo = () => {
  const name = useFormInput("")
  const address = useFormInput("")
  const sharedContext = { name, address }

  return (
    <HooksContext.Provider value={sharedContext}>
      <div className="demo">
        Hooks:
        <FormWrapper />
        <button onClick={() => alert(`name: ${name.value}, address: ${address.value}`)}>
          Get Data
        </button>
      </div>
    </HooksContext.Provider>
  )
}

export default HooksDemo

import React from "react"
import InputGroup from "./InputGroup"

const FormWrapper = () => {
  return (
    <form autoComplete="new-password">
      <InputGroup fieldName="Name" />
      <InputGroup fieldName="Address" />
    </form>
  )
}

export default FormWrapper

InputGroup 裡還可以分開解構 border 變色需要的屬性,再把剩下的 properties 一口氣全丟到 Input 裡面:

import React, { useContext } from "react"
import classnames from "classnames"
import HooksContext from "../HooksContext"

const InputGroup = ({ fieldName }) => {
  const {
    [fieldName.toLowerCase()]: { focus, ...formInput }
  } = useContext(HooksContext)

  return (
    <div className="demo__input-group">
      <label className="demo__input-group__label">{fieldName}:</label>
      <input
        className={classnames("demo__input-group__input", {
          "demo__input-group__input--focus": focus
        })}
        type="text"
        autoComplete="off"
        {...formInput}
      />
    </div>
  )
}

export default InputGroup

看完比較後,可以發現用 custom hooks 真的簡潔許多。

再來還有一個要討論的重點是 被共用狀態的層級


「我已經把狀態往上提取,那假如這個被共用的狀態被層級更高的組件所需要怎麼辦?」

可以考慮的有幾個選擇:

  • 再跟著把共用狀態往上拉,就如 Redux 把狀態拉到 root 的位置一般
  • 使用 globe state management 的方案,現在不少套件都可以做到此點,如 Apollo Client
  • 謹慎思考一下自己的程式架構是否有點問題,因為若模組功能切分得好,不同模組間需要共用的狀態實在不多

若是要考慮例如多語系等的問題,其實大部分套件本身都有自己的 global state 了,基本上只要掛個 HOC 就可以正常取用了。唯一要注意的地方是 context provider 包裹的狀態只會跟到被包裹組件生命週期終止為止,要保留狀態的話,自己要多注意組件的層級與何時會被消滅。

結語

Custom hooks + context api 的威力已經宛如一個小型原子彈 Redux,且 custom hooks 可以盡可能地做邏輯的抽象與代碼重用,非常建議新的程式碼或迭代更新時使用。

👉 Github repo 連結在此

👉 若對 React hooks 不夠了解的可以參考: React hooks 簡介