Function as State, Take useState as Example

・9min

作為 React 使用者,你的 useState 過去都放了什麼?useState 的官方敘述是這樣:

useState is a React Hook that lets you add a state variable to your component.

而 useState 的型別是這樣的

function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];

看起來 useState 對於 state 的類型好像沒有任何限制,換句話說所有 JavaScript 中可以用的值都可以使用。那接著複習一下 JS 有哪些 data type1

Javascript value data types
Javascript value data types

咦?你有把 function 放進 state 過嗎?

#如何將 function 放入 useState

首先,function 放進 useState 是完全可行的,只是有些地方要注意。

第一直覺你可能想到的是這樣的寫法:

const [fun, setFun] = useState(() => {
    console.log('init func')
  })
console.log(fun) //undefined

但實際上你儲存的值是 undefined。還記得剛剛的型別嗎?

function useState&lt;S&gt;(initialState: S | (() =&gt; S)): [S, Dispatch&lt;SetStateAction&lt;S&gt;&gt;];

在 React 的文件2中寫到

If you pass a function as initialState, it will be treated as an initializer function.

當在 useState 傳入 function,會在 component 初始化的時候執行並帶入 return 值。因此不能直接傳入 function,而是要用下面的形式:在 function 中 return function

const [fun, setFun] = useState(() => () => {
    console.log('init func');
  });

  console.log(fun); //  f () => { console.log('init func'); }

同樣的,在 setState 也有一樣的狀況,你不能直接用下面的形式來設定 function

setFun(() => {console.log('set func')})

If you pass a function as nextState, it will be treated as an updater function.

useState 的 setter 當中直接使用 function 的形式,代表傳入的是 updater,會依照 return 的值來決定下一個 state。所以應該這樣作:

setFun(() => () => {console.log('set func')})

知道一些小訣竅之後,可以來看一些使用上例子

#如何使用

舉個例子,今天有個簡單的計算機:

Calculator UI
Calculator UI

過去可能會這樣寫(刪除了與主題不相關的程式碼)

export function OldArithmetic() {
  const [operation, setOperation] = useState('+');
  const operationsMap: Record<string, (a: number, b: number) => number> = {
    '+': (a: number, b: number) => a + b,
    '-': (a: number, b: number) => a - b,
    '*': (a: number, b: number) => a * b,
    '/': (a: number, b: number) => a / b,
    '**': (a: number, b: number) => a ** b,
  };

  return (
      {//...}
      <button onClick={() => {setOperation('+')}}>+</button>
      <button onClick={() => {setOperation('-')}}>-</button>
      <button onClick={() => {setOperation('*')}}>*</button>
      <button onClick={() => {setOperation('/')}}>/</button>
      <button onClick={() => {setOperation('**')}}>**</button>
      <span >{`= ${operationsMap[operation](operands[0], operands[1])}`}</span>
  );
}

github: https://github.com/Lauviah0622/Fun-as-state/blob/main/src/example/LegacyAtithmetic.tsx

但其實可以省掉 operationsMap,直接在 operation 中放入 function

function Arithmetic() {
  const [operation, setOperation] = useState(
    () => (a, b) => a + b
  );

  const [operands, setOperands] = useState([1, 2] as [number, number]);
  return (
      <button onClick={() => {setOperation(() => (a, b) => a + b)}}>+</button>
      <button onClick={() => {setOperation(() => (a, b) => a - b)}}>-</button>
      <button onClick={() => {setOperation(() => (a, b) => a * b)}}>*</button>
      <button onClick={() => {setOperation(() => (a, b) => a / b)}}>/</button>
      <button onClick={() => {setOperation(() => (a, b) => a ** b)}}>**</button>
      <span className='result'>{`= ${operation(operands[0], operands[1])}`}</span>
  );
}

github: https://github.com/Lauviah0622/Fun-as-state/blob/main/src/example/Arithmetic.tsx

沒有其他考量的狀況下,這樣的寫法會比 operationsMap 更簡潔,並帶來幾個好處

  1. 每個 Operation 之間是完全獨立的
  2. Operation 自己更加內聚

在原本的寫法中,不同的 operation 被放在同一個 operationsMap 中,透過不同的 key 來辨識。但這也意味著 key 之間不能重複(當然你也可以用 symbol 解決這個問題),使用 function 的形式減少了各 operation 之間的耦合

除此之外,Operation 的 UI 以及行為可以進一步的都包含在一個元件當中,像這樣:

const Add = ({setOperation}) => <button onClick={() => {setOperation(() => (a: number, b: number) => a + b)}}>+</button>
<Add  setOperation={setOperation}/>
<Minus  setOperation={setOperation}/>
<Multiply  setOperation={setOperation}/>
<Devide  setOperation={setOperation}/>
<Pow  setOperation={setOperation}/>

這樣的寫法比起將操作和介面當需求有任何變動,像是突然不需要 Pow 了,那就直接刪除 <Pow/> 的程式碼就好,不需要再對 operationsMap 進行改動。

#實際應用:在 react 中將非同步函數變成同步的形式

假設有這樣一個需求:

Amount exchange UI
Amount exchange UI

匯率轉換 API 的格式會像這樣

(amountsList: number[], Currency) => exchangedAmountList: number[]

一開始可能會思考這裡有幾個狀態

所以可能會這樣寫

const [amount , setAmount] = useState(0) // 金額的 input 輸入框
 const [amountsMap, setAmountsMap] = useState(
    new Map<number, number | null>([
      [5, null],
      [10, null],
      [100, null],
      [1000, null],
    ])
  ); // 轉換後的金額,還沒轉換所以放入 null
  const [currency, setCurrency] = useState<Currency>('USD'); // 指定的貨幣

有了狀態之後再串上 User 的互動,包含金額的 input 以及貨幣的 select。並且加上呼叫 API 的時機,包含元件初始化、貨幣被選擇以及輸入金額之後。簡單的實作會像下面這樣:

github: https://github.com/Lauviah0622/Fun-as-state/blob/main/src/example/LegacyExchange.tsx

這樣的實作沒有問題。但做到這裡的時候不禁會想

要是匯率的轉換是一個簡單的同步 function 就好了

如果是一個同步的 function,我們只需在要轉換的地方簡單的加上下面這行就萬事 OK

exchange = (number) => number

exchange(amount)

如果是這樣的形式,即使要轉換的數字事先不確定個數、不知道數字,全部都直接用一個 exchange(price) 就可以解決。

有可能做到嗎?或許可以用將 function 存入 state 的作法來試看看。但首先,先思考理想中的使用方法會是什麼?讓我們回到剛剛 API 的 interface:

(amountsList: number[], Currency) => exchangedAmountList: number[]

用了類似 Currying 的概念,我們可以把這個 interface 轉換成這樣,先指定貨幣,然後產生出另一個「轉換的函數」

(Currency) => (amountsList: number[]) => exchangedAmountList: number[]

單單把後面這段擷取出來和期望的 interface 作比較。

// expect
exchange = (number) => number 

// now
exchangeAmounts = (amountsList: number[]) => number[]

其實已經很相似了,但一個是拿單一的值,另一個是轉換整個 Array。既然 input / output 都是 Array,那有個大膽的想法:我們或許可以透過某種以「 index 作 mapping 」的方式來解決。

到這裡確定了兩個想法:

  1. 可以將原本的 API 的 interface 拆分成兩個階段:先給貨幣,這樣就可以拿到轉換的函數。
  2. 需要某種以「 index 作 mapping 」的方式,將原本 Array 的介面轉成單一值的介面。

針對第一點,在 React 中可以利用 Custom hooks 來實作出這樣的一個介面:

const useExchange = (Currency) => (number) => number

// In component
exchange = useExchange()

// in render
exchange(amount)

如此,可以開始思考 useExchange 的實作。Function 之所以比起純粹的 Value 還要有彈性,在於設定是「行為」,而不單只是「值」。回到需求,可以這樣分析整個功能的狀態:

UI state diagram
UI state diagram

透過 function 可以設定「行為」的優點,我們可以在不同的狀態設定 function 不同的「行為」:

從這個想法出發的實作會像這樣:

const useExchange = (targetCurrency: Currency) => {
  // 用來儲存需要轉換的金額,以及轉換後對應的結果
  const amountsRef = useRef<Map<number, number | null>>(new Map());
  const [exchange, setExchange] = useState<(v: number) => number | null>(
    // 在一開始還沒有轉換後金額的階段,function 做的是把值放入 Ref 中
    () => (value: number) => {
      amountsRef.current.set(value, null);

      return value;
    }
  );

  const fetchExchange = () => {
    const amounts = [...amountsRef.current.keys()];
    asyncAmountExchange(amounts, targetCurrency).then((res) => {
      const nextMap = new Map();

      // 把轉換前轉換後的金額儲存在 Map 當中
      res.forEach((exchangedAmount, i) => {
        const originAmount = amounts[i];
        nextMap.set(originAmount, exchangedAmount);
      });

      amountsRef.current = nextMap

      setExchange(
        // 既然有了值,那 function 的行為就變成拿取轉換後的金額
        () => (value: number) => {
          return amountsRef.current.get(value) ?? null
        }
      );
    });
  };

  return exchange
};

最後再加上一點小東西

下面就是我們最後的成果:

github: https://github.com/Lauviah0622/Fun-as-state/blob/main/src/example/AsyncAsSync.tsx

#Conclusion

現在大多數的語言都支持 First-class function,也就是 function 和其他資料結構是沒有差異的,同樣可以作為參數、return 值,也當然可以儲存在變數中。但這樣的特性可以帶來什麼樣的意義,一開始對我而言是難以理解的。

或許可以思考函數有哪些特性:

思考一下這些特性有沒有被能夠被使用在狀態的可能性?這樣的應用雖然與過去對於「狀態」的思考方式大相逕庭,但從另一個角度來看「狀態」也是蠻有趣的 🤔

最後值得一提的是,每次下手前或許可以再稍微想想:

恩?是不是有更方便使用以及更好維護介面

總之,不論是什麼方法論,讓程式碼更加內聚並解耦是維持 Code base 彈性的不二法門。

#Footnotes

  1. ECMAScript® 2024 - Primitive value, ECMAScript® 2024 - Data Types and Values

  2. useState – React

# Comments