# React - Building custom React hooks ###### tags: `Javascript, React` # 這個 module 在說什麼? 先回憶一下 Hooks 的規則 基本上只有 Functional components 以及 Custom hooks 可以使用 React Hooks ![](https://i.imgur.com/ilCXu8j.png) * rules of hooks * how to build your own hooks * why you need to do that * custom hook rules & practices # What are Custom Hooks 其實最終 custom hooks 也只是一般的函式但他可以: * 包含 stateful logic * 可以使用其他 React Hooks 甚至其他的 custom hooks 在內 * 可以影響 state 不管是 useState, useReducer 以及 useEffect 所以這之中的重點就是 custom hooks 擷取出 reusing stateful logic 讓不同的 component 都可以使用 ![](https://i.imgur.com/gNRhWv6.png) # Creating Custom Hooks 這邊會用一個簡單的計數器來做 demo 上方格子會往正數走,下方走負數 ![](https://i.imgur.com/0ZRSIo5.png) App 架構 * App.js 使用所有 component 在這邊 * ForwardCounter.js 每秒 +1 * BackwardCounter.js 每秒 -1 * Card.js - 主要把 style 放在這邊當作外框 主要介紹要處理的邏輯所在處 ForwardCounter.js/ BackwardCounter.js 除了下方程式的差異之外,其餘部分兩邊的程式碼是一樣的 ```javascript= useEffect(() => { const interval = setInterval(() => { setCounter((prevCounter) => prevCounter + 1);// 在 BackwardCounter.js 中這邊是 -1 }, 1000); ``` # Using Custom Hooks 為了使用 custom hooks 首先建立一個資料夾給他 ![](https://i.imgur.com/q3Xy6Q2.png) ## use-counter.js 先示範把 ForwardCounter.js 的內容拉進去 * 記得 custom hooks 的命名必須以 use + 任意 * 最後可以 return 想要當 custom hooks 被呼叫時使用的東西,不管是陣列、物件、純值都可以 ```javascript= import { useState, useEffect } from 'react'; const useCounter = () => { const [counter, setCounter] = useState(0); useEffect(() => { const interval = setInterval(() => { setCounter((preCounter) => preCounter + 1) }, 1000); return () => clearInterval(interval); },[]) return counter; } export default useCounter ; ``` 接下來簡化 ForwardCounter.js 的內容 操作 useCounter 進去,跟使用 useState 一樣會回傳陣列,而內容就是剛剛在 custom hooks 內回傳的內容,再把內容呈現到 jsx 中就成功摟! ```javascript= import useCounter from '../hooks/use-counter'; import Card from './Card'; const ForwardCounter = () => { const counter = useCounter(); return <Card>{counter}</Card>; }; export default ForwardCounter; ``` # Configuring Custom Hooks 想當然爾,custom hooks 怎麼可能只能操作在加一的邏輯呢,那當然也要可操作在 BackwardCounter.js 摟!但是目前在 custom hooks 內部邏輯只有寫入往上的邏輯,這邊我們可以透過帶入參數的方式來改變,就像是 components 接受 props 一樣 ## use-counter.js 這邊帶入了參數 forwards 並且使其預設值為 true 為的是讓其為 true 時,數字會往上加,當參數改為 false 時,則會往下 ```javascript= import { useState, useEffect } from 'react'; const useCounter = ( forwards = true ) => { const [ counter, setCounter ] = useState(0); useEffect( () => { const interval = setInterval( () => { if ( forwards ) { setCounter( preState => preState +1 ); } else { setCounter( preState => preState -1 ); } }, 1000); return () => clearInterval( interval ); },[ forwards ]) return counter; } export default useCounter; ``` ## BackwardCounter.js 這邊因為要修改 forward 為 false 因此帶進去參數而已 ```javascript= const BackwardCounter = () => { const counter = useCounter(false); return <Card>{counter}</Card>; }; ``` ## ForwardCounter.js 因為預設值帶入的 forward 是 true 所以不用改動 # Onwards To A More Realistic Example 本篇範例使用的是類似 [http request](https://hackmd.io/CcPfQxwTQ0CLE74fm5A_Nw) 那篇的成品,使用 firebase 當作 server 儲存資料並且 fetch 下來呈現到網頁上 ![](https://i.imgur.com/UHzlWhO.png) ## App.js > 從 firebase fetch 資料下來,並且把 tasks 資料傳下去 component 中,以及當新的 tasks POST 上去 firebase 時,taskAddHandler 會把新的 tasks concat 到當前的 tasks 更新到頁面上 重要 state : * isLoading - 預設值為 true ,當取得資料或是錯誤訊息結束時會設為 false 結束 loading 狀態 * error - 預設值為 null ,當 catch 到錯誤時設定為錯誤訊息或是已設定好的字串,並且傳入 Tasks 當作 props * tasks - 預設值為空陣列,當 fetchTasks 函式從 firebase 撈回來資料的時候,會把資料推進去 tasks 內並且傳入 Tasks 當作 props fetchTasks 函式主要處理: 1. 一開始 isLoading 設為 true 因為資料還沒開始抓 2. 把 Error 設為 null 原因同上 3. try 的部分在抓取資料下來,如果資料失敗則顯示字串 Request failed! 會丟到下的 catch 4. 成功則推進去 loadedTasks 並且 setTasks 填入 tasks 的空陣列中 5. 失敗則讓 error 的狀態抓取 throw 剛剛推的字串或是不知名錯誤則顯示 'Something went wrong! 6. 載完資料、錯誤訊息後設定 setIsLoading 為 false ,因為東西都載完啦! useEffect 的部分則是在剛載入畫面的時候,會操作一次 fetchTasks 函式 taskAddHandler 函式的部分則是當作 props 傳下去 component 中,並且會把他拿到的參數 concat 進去目前的 tasks 中 ```javascript= import React, { useEffect, useState } from 'react'; import Tasks from './components/Tasks/Tasks'; import NewTask from './components/NewTask/NewTask'; function App() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [tasks, setTasks] = useState([]); const fetchTasks = async (taskText) => { setIsLoading(true); setError(null); try { const response = await fetch( 'https://more-realistic-project-react-default-rtdb.asia-southeast1.firebasedatabase.app/tasks.json' ); if (!response.ok) { throw new Error('Request failed!'); } const data = await response.json(); const loadedTasks = []; for (const taskKey in data) { loadedTasks.push({ id: taskKey, text: data[taskKey].text }); } setTasks(loadedTasks); } catch (err) { setError(err.message || 'Something went wrong!'); } setIsLoading(false); }; useEffect(() => { fetchTasks(); }, []); const taskAddHandler = (task) => { setTasks((prevTasks) => prevTasks.concat(task)); }; return ( <React.Fragment> <NewTask onAddTask={taskAddHandler} /> <Tasks items={tasks} loading={isLoading} error={error} onFetch={fetchTasks} /> </React.Fragment> ); } export default App; ``` ## NewTasks.js > 會把資料 POST 到 firebase 上面,並且把傳上去的那些資料丟做去 taskAddHandler 藉由 props 的方式往上傳 基本上內部的 state 跟函式內容都跟上面的 App.js 差不多 ```javascript= import { useState } from 'react'; import Section from '../UI/Section'; import TaskForm from './TaskForm'; const NewTask = (props) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const enterTaskHandler = async (taskText) => { setIsLoading(true); setError(null); try { const response = await fetch( 'https://more-realistic-project-react-default-rtdb.asia-southeast1.firebasedatabase.app/tasks.json', { method: 'POST', body: JSON.stringify({ text: taskText }), headers: { 'Content-Type': 'application/json', }, } ); if (!response.ok) { throw new Error('Request failed!'); } const data = await response.json(); const generatedId = data.name; // firebase-specific => "name" contains generated id const createdTask = { id: generatedId, text: taskText }; props.onAddTask(createdTask); } catch (err) { setError(err.message || 'Something went wrong!'); } setIsLoading(false); }; return ( <Section> <TaskForm onEnterTask={enterTaskHandler} loading={isLoading} /> {error && <p>{error}</p>} </Section> ); }; export default NewTask; ``` # Building a Custom Http Hook > 這邊的 http hooks 他的目的在於可以處理資料的 POST, GET 之外,也有兩個 state 處理是否正在 loading, 以及 error message 的呈現 不過 custom hooks 中該放入些什麼呢? 1. 首先是 isLoading, error 這兩個 state(去除 tasks 因為這只能用在 GET method 中) 2. 整個 fetch 資料的邏輯包含 catch 以及 try 首先把整個 App.js 的 fetchTasks 函式整個複製進來,並且把 tasks state 相關的內容刪除 ```javascript= import React, {useState} from 'react'; const useHttp = () =>{ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const sendRequest = async (taskText) => { setIsLoading(true); setError(null); try { const response = await fetch( 'https://more-realistic-project-react-default-rtdb.asia-southeast1.firebasedatabase.app/tasks.json' ); if (!response.ok) { throw new Error('Request failed!'); } const data = await response.json(); const loadedTasks = []; for (const taskKey in data) { loadedTasks.push({ id: taskKey, text: data[taskKey].text }); } } catch (err) { setError(err.message || 'Something went wrong!'); } setIsLoading(false); }; } export default useHttp; ``` * fetch 網址的部分有兩種方法因此做出客製化因應,也就是帶入參數 requestConfig 來執行,並且 App.js 不會使用到 headers, body 所以做了判斷進去 * 第二點是關於 data 的部分必須拉出去給 NewTasks component 去做處理,因為他也是個比較特定的功能不好做在共用元件中,這邊會使用參數的方式帶入函式 applyData ,在使用到該 data 的 component 去處理 for...in loop 的內容 * 最後要把 state 以及 sendRequest return 出去使用 ```javascript= import React, {useState} from 'react'; const useHttp = (requestConfig, applyData) =>{ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const sendRequest = async (taskText) => { setIsLoading(true); setError(null); try { const response = await fetch( requestConfig.url, { method: requestConfig.method ? requestConfig.method : 'GET', headers: requestConfig.headers ? requestConfig.headers : {}, body: JSON.stringify(requestConfig.body) ? JSON.stringify(requestConfig.body) : null, } ); if (!response.ok) { throw new Error('Request failed!'); } const data = await response.json(); applyData(data); } catch (err) { setError(err.message || 'Something went wrong!'); } setIsLoading(false); }; return { isLoading, error, sendRequest } } export default useHttp; ``` # Using the Custom Http Hook > 做好了 custom hook 了,這邊要來操作它啦! 1. 使用到 custom hook 的位置有兩個 App.js, NewTasks ,先把共通的扣全部刪掉, state 以及 fetch 相關函式 1. 使用 useHttp 為了操作它 return 的內容使用 {} 做解構,就可以把 isLoading, error 以及 sendRequest 帶出來使用 2. 需要注意的是,applyData 在 App.js 中對應的函式是 transformTasks 也就是接受 data 的地方 3. 並且使用 ES6 的特性在參數位置賦值給 fetchTasks 就不需要修改變數名稱摟 ## App.js ```javascript= import React, { useEffect, useState } from 'react'; import Tasks from './components/Tasks/Tasks'; import NewTask from './components/NewTask/NewTask'; import useHttp from './hooks/use-http'; function App() { const [tasks, setTasks] = useState([]); const transformTasks = tasksObj => { const loadedTasks = []; for (const taskKey in tasksObj) { loadedTasks.push({ id: taskKey, text: tasksObj[taskKey].text }); } setTasks(loadedTasks); } const {isLoading, error, sendRequest: fetchTasks} = useHttp({url:'https://more-realistic-project-react-default-rtdb.asia-southeast1.firebasedatabase.app/tasks.json'}, transformTasks) useEffect(() => { fetchTasks(); }, []); const taskAddHandler = (task) => { setTasks((prevTasks) => prevTasks.concat(task)); }; return ( <React.Fragment> <NewTask onAddTask={taskAddHandler} /> <Tasks items={tasks} loading={isLoading} error={error} onFetch={fetchTasks} /> </React.Fragment> ); } export default App; ``` 最後值得注意的是, useEffect 的 dependencies 的位置,這邊如果放入 fetchTasks 會造成 infinite loop ,原因在於在 custom hook 中有使用 useState 來操作 * 當畫面載入 useEffect,它會執行其內容 fetchTasks (也就是 custom hook) * 下一步 fetchTasks 內部執行 useState 於是重設 state 導致畫面 re-evaluate, re-executed * 這時候 useEffect 偵測到 fetchTasks dependencies 有更新它就會重新開始執行內容 (因為函式是物件,每次重新執行都會指向新的位置) * 於是造成了 infinite loop # Adjusting the Custom Hook Logic > 這邊會來處理上面提到 infinite loop 的問題 為了讓 useEffect 可以放入dependencies 進去使用,就得去改寫 useHttp ,使用 useCallback 來包裹著 sendRequest 來讓 fetchTasks 的函式不會重新執行產生,但是尷尬的事情發生了! ## use-http.js 在 custom hook 中使用 useCallback 後因為使用到帶入的參數的關係所以帶入的參數依舊是物件!代表 applyData, requestConfig 這兩個物件同樣必須操作 useCallback 上去才能避免他們也 re-evaluate, re-executed ```javascript= const sendRequest = useCallback(async (taskText) => { setIsLoading(true); setError(null); try { ... } catch (err) { ... } setIsLoading(false); },[applyData, requestConfig]); ``` ## App.js 先處理 applyData 對應的函式 transformTasks 這邊針對 transformTasks 也就是 applyData 代表的函式做處理,使用 useCallback 包住,並且因為使用到外部的內容只有 useState 的函式,不過這邊提過很多次 useState 提供的函式是不會改變的因此這邊不用放入 dependencies ```javascript= const transformTasks = useCallback( tasksObj => { const loadedTasks = []; for (const taskKey in tasksObj) { loadedTasks.push({ id: taskKey, text: tasksObj[taskKey].text }); } setTasks(loadedTasks); },[]); ``` ## useHttp.js 處理 requestConfig 物件 接下來要處理 requestConfig 這個物件,修改其參數位置進去 sendRequest 畢竟這個函式是真正使用這個參數,這麼一來 requestConfig 就不是外部的參數因此就可以從 useCallback 的 dependencies 中移除了 ```javascript= const sendRequest = useCallback(async (requestConfig) => { setIsLoading(true); setError(null); try { const response = await fetch( requestConfig.url, { method: requestConfig.method ? requestConfig.method : 'GET', headers: requestConfig.headers ? requestConfig.headers : {}, body: JSON.stringify(requestConfig.body) ? JSON.stringify(requestConfig.body) : null, } ); if (!response.ok) { throw new Error('Request failed!'); } const data = await response.json(); applyData(data); } catch (err) { setError(err.message || 'Something went wrong!'); } setIsLoading(false); },[applyData]); // 這邊待會會被拉掉 ``` 這邊的 applyData 會在待會把 useHttp 參數帶入整個拉進去 fetchTasks 函式使用時會被拉掉 ## 回到 App.js 我們把整個 useHttp 的參數全部拉到 fetchTasks (也就是 sendRequest )帶入使用,如此一來 sendRequest 的 depedencies 的 applyData 也可拉掉了 如此一來就可以使用 fetchTasks 這個 dependencies 摟! ```javascript= function App() { const [tasks, setTasks] = useState([]); const {isLoading, error, sendRequest: fetchTasks} = useHttp(); useEffect(() => { const transformTasks = tasksObj => { const loadedTasks = []; for (const taskKey in tasksObj) { loadedTasks.push({ id: taskKey, text: tasksObj[taskKey].text }); } setTasks(loadedTasks); }; fetchTasks({url:'https://more-realistic-project-react-default-rtdb.asia-southeast1.firebasedatabase.app/tasks.json'}, transformTasks); }, [fetchTasks]); ``` # Using The Custom Hook In More Components > 最後終於可以把 cumstom hook 使用到 NewTasks.js 1. 把 isLoading, error 相關 state 刪掉 1. 引入使用 useHttp 並且解構出需要使用的 state 以及 sendRequest 函式 1. 這邊因為是根據 form 的 OnSubmit 來更新 state 以及頁面所以不需要操作 useEffect 1. 接下來在 enterTaskHandler 帶入 sendTasksRequest 函式並且輸入使用到的資料內容 1. applyData 的部分則是寫在上方的 createTask 這邊比較特別的是因為 taskText 為 enterTaskHandler 的參數所以在引用處直接使用 bind 帶入當作預設參數 ```javascript= import useHttp from "../../hooks/use-http"; import Section from "../UI/Section"; import TaskForm from "./TaskForm"; const NewTask = (props) => { const { isLoading, error, sendRequest: sendTasksRequest } = useHttp(); const createdTask = ( taskText, taskData ) => { const generatedId = taskData.name; // firebase-specific => "name" contains generated id const createdTask = { id: generatedId, text: taskText }; props.onAddTask(createdTask); }; const enterTaskHandler = async (taskText) => { sendTasksRequest({ url: "https://more-realistic-project-react-default-rtdb.asia-southeast1.firebasedatabase.app/tasks.json", method: "POST", headers: { "Content-Type": "application/json", }, body: { text: taskText }, }, createdTask.bind( null, taskText )); // 這邊使用 bind 設置第一個預設的參數,之後加入的參數會被 append 進去參數 list 內 }; return ( <Section> <TaskForm onEnterTask={enterTaskHandler} loading={isLoading} /> {error && <p>{error}</p>} </Section> ); }; export default NewTask; ``` # bind() 使用 簡單介紹一下 bind() * 使用情境可以操作 this 的對象之外 * 還可以設定預設的參數 * 或是當作指向函式引入參數使用 這邊指向函式可以想像 addEventListener 的第二個參數函式,為了畫面載入時不要直接執行,所以沒有使用 () 但是卻需要使用到參數時,可以使用 bind 來解決的這個問題,因為 bind 可以設置參數的預設值,但是記得 bind() 一定要使用兩個參數,第一個 this 如不使用可放入 null 即可