React Intermediate Part

March 18, 2023

  • react
  • frontend
有任何問題,我能幫上忙的話都能到 github 發 issue 問我。

Preface

此篇是2022/01/19,所寫的文章,但我 update 了一下,所以移到近期,

由於平時自己比較常使用 Vue 來做前端開發,React 現在我也已經比較少在做使用,除非是在處理自己現在的這個部落,不然大多數是客戶或主管有要求要使用才會使用,現在應該是偶爾做使用,那這篇內容是我之前在 FrontendMaster 複習及學習 React 時紀錄的筆記,有點定時花錢強迫自己學習的概念 😅,最終我把內容整理並移到自己的部落格內,不過現在也偶爾加入自己的東西,總之就記錄一下自己的想法。

useState

import { useState } from "react";

function expensiveInitialState() {
  // do something
  return 0;
}

const Component = () => {
  const [isGreen, setIsGreen] = useState(true);
  // 也可以使用 function 的方式來初始化 state
  const [someState, setSomeState] = useState(() => expensiveInitialState())
  const [count, setCount] = useState(0);

  return (
    <div>
      <div>{someState}</div>
      <button
        onClick={() => setSomeState(!someState)}
        style={{ color: isGreen ? 'limegreen' : 'crimson'}}
      >Click</button>
      <div>{count}</div> 
      <button onClick={() => setCount((prevCount) => prevCount + 1)}>Click</button>
    </div>
  )
}

export default Component;

useEffect

useEffect 這項功用主要在 side effect 上,像是抓取資料,設定及取消計時器,直接更新 DOM 等,在 componentDidMount, componentDidUpdate, componentWillUnmount 這些生命週期內操作的行為。

在使用 dependency array 去做 diff 時,很多人都會把 setState 也放入,這究竟是錯還是對?坦白說,答案是沒錯的,但其實可以省略,因為 react 會保證每次的 setState 都是相同的,也就等同於沒作用,所以你不用再額外進行添加。但是為何很多 React 高手還是有將其加入呢?因為如果有裝 eslint 的話,沒將 setState 加入的話是會有警告的,所以如果你不想要看到警告的話就加入就好,警告是 react-hooks/exhaustive-deps

import { useEffect } from "react";

const Component = () => {
  const [time, setTime] = useState(true);

  useEffect(() => {
    const timer = setTimeout(() => setTime(new Date()), 1000);

    return () => clearTimeout(timer);
  }, [time, setTime])

  return <div>{time.toLocaleTimeString()}</div>
}

export default Component;

useContext

import { useState, useContext, createContext } from "react";

const UserContext = createContext([
  {
    firstName: "John",
    lasName: "Doe",
  },
  (obj) => obj
])

const LevelFive = () => {
  const [user, setContext] = useContext(UserContext);

  return (
    <div>
      <h5>{`${user.firstName} ${user.lastName}`}</h5>
    </div>
  )
}

const LevelFour = () => {
  return (
    <div>
      <h4>Fourth level</h4>
      <LevelFour />
    </div>
  )
}

const LevelThree = () => {
  return (
    <div>
      <h4>Third level</h4>
      <LevelFour />
    </div>
  )
}

const LevelTwo = () => {
  return (
    <div>
      <h4>Second level</h4>
      <LevelThree />
    </div>
  )
}

const LevelOne = () => {
  const userState = useState({
    firstName: "Mayvis",
    lastName: "Chen"
  })

  return (
    <UserContext.Provider value={userState}>
      <h1>First level</h1>
      <LevelTwo />
    </UserContext.Provider>
  )
}

export default LevelOne;

useRef

useRef 用法很多,像是他可以依附在 DOM 上,也可以用來儲存變數。

import { useState, useRef } from "react";

const RefComponent = () => {
  const [stateNumber, setStateNumber] = useState(0);
  const numRef = useRef(0);

  function incrementAndDelayLogging() {
    setStateNumber(stateNumber + 1);
    numRef.current++;

    setTimeout(() => {
      alert(`state: ${stateNumber} | ref: ${numRef.current}`) // state 0, ref 1
    }, 1000)
  }

  return (
    <div>
      <h1>useRef Example</h1>
      <button onClick={incrementAndDelayLogging}>delay logging</button>
      <h4>state: {stateNumber}</h4>
      <h4>ref: {numRef.current}</h4>
    </div>
  )
}

export default RefComponent;

useReducer

import { useReducer } from "react";

const limitRGB = (num) => (num < 0 ? 0 : num > 255 ? 255 : num);

const step = 50;

const reducer = (state, action) => {
  switch(action.type) {
    case "INCREMENT_R":
      return Object.assign({}, state, { r: limitRGB(state.r + step) }); // 也可以使用 spread operator
    case "DECREMENT_R":
      return Object.assign({}, state, { r: limitRGB(state.r - step) });
    case "INCREMENT_G":
      return Object.assign({}, state, { g: limitRGB(state.g + step) });
    case "DECREMENT_G":
      return Object.assign({}, state, { g: limitRGB(state.g - step) });
    case "INCREMENT_B":
      return Object.assign({}, state, { b: limitRGB(state.b + step) });
    case "DECREMENT_B":
      return Object.assign({}, state, { b: limitRGB(state.b - step) });
    default:
      return state;
  }
}

const ReducerComponent = () => {
  const [{ r, g, b }, dispatch] = useReducer(reducer, { r: 0, g: 0, b: 0 });

  return (
    <div>
      <h1 style={{ color: `rgb(${r}, ${g}, ${b})` }}>useReducer Example</h1>
      <div>
        <span>r</span>
        <button onClick={() => dispatch({ type: "INCREMENT_R" })}>+</button>
        <button onClick={() => dispatch({ type: "DECREMENT_R" })}>-</button>
      </div>
      <div>
        <span>g</span>
        <button onClick={() => dispatch({ type: "INCREMENT_G" })}>+</button>
        <button onClick={() => dispatch({ type: "DECREMENT_G" })}>-</button>
      </div>
      <div>
        <span>b</span>
        <button onClick={() => dispatch({ type: "INCREMENT_B" })}>+</button>
        <button onClick={() => dispatch({ type: "DECREMENT_B" })}>-</button>
      </div>
    </div>
  );
}

React.memo

當某個元件裡的狀態發生改變時,react會重新渲染該組件,如果有子組件,儘管它與該組件狀態無關,但是它也會被重新渲染,而這時候就可以使用 React.memo 來避免不必要的重新渲染。

React.memo 會比較前後兩次的 props,如果沒有改變,就不會重新渲染。預設是使用 shallow comparison,也就是所謂的淺比較,印象中,使用的方式是 Object.is(value1, value2);此外,比較方式也可以透過第二個參數來自訂,假使你想使用 deep comparison (深比較),可以使用 lodash 的 isEqual,算是滿常見的做法,如下:

Object.is([1, 2], [1, 2]) // false
_.isEqual([1, 2], [1, 2]) // true

但是比較時還有個問題,由於 React 只要重新渲染,組件內的 function 就會被重新定義,而這時便可以使用 React.useCallback 緩存 function 來解決該問題。

請盡量避免心智負擔:

  1. 只渲染一次,之後都不會更新的組件,不要使用 React.memo
  2. props 每次都會改變的組件,不要使用 React.memo
  3. 組件如果簡單,不要使用 React.memo,並不會提升多少效能
  4. 請盡量使用 React Profiler devtool 來檢測效能,判斷是否要使用 React.memo,畢竟緩存也會有成本

Tips: 可以寫一個簡單的 hook 去看渲染的次數

import { useRef } from 'react';

const useRenderCount = () => {
  const renderCount = useRef(0);

  console.log('render count: ', renderCount.current++);
};

export default useRenderCount;

useMemo

Performance optimization: handle very expensive computed.

🔥🔥 Only use this when you actually already have a problem. 🔥🔥

import { useState, useMemo } from "react";

// 因為遞迴的關係,當 num 越大,計算量就會越大,如果我們將前一個計算的結果存起來,就可以避免重複計算
// 而 React.useMemo 就是用來做這件事情的,它會接收兩個參數,第一個是 callback,第二個是依賴的值,當依賴的值改變時,才會重新計算 callback 的結果
// React.useCallback 也是一樣的道理,只是它是用來緩存 function 的
// fibonacci -> 1 1 2 3 5 8
const fibonacci = (n) => {
  if (n <= 1) return 1;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const MemoComponent = () => {
  const [num, setNum] = useState(1);
  const [isGreen, setIsGreen] = useState(true);
  // this will cause rerender problem, if num is very high like 40.
  // when you click h1 to change the color, this will recalculate the fibonacci 40 times again.
  // const fib = fibonacci(num);
  const fib = useMemo(() => fibonacci(num), [num]);

  return (
    <div>
      <h1
        onClick={() => setIsGreen(!isGreen)}
        style={{ color: isGreen ? "limegreen" : "crimson"}}
      >
        useMemo Example
      </h1>
      <h2>
        Fibonacci of {num} if {fib}
      </h2>
      <button onClick={() => setNum(num + 1)}>+</button>
    </div>
  )
}

useCallback

The useCallback hook is used when you have a component in which the child is rerendering again and again without need.

Pass an inline callback and an array of dependencies, useCallback will return a memorized version of the callback that only changes if one of the dependencies has changed. This is useful when passing callbacks to optimized child components that replay on reference equality to prevent unnecessary renders.

const memorizedCallback = useCallback(() => {
  doSomething(a, b)
}, [a, b])

Think useCallback like this way.

We have two different Fibonacci’s that are functionally the same function, but are actually two different instances of the same function.

function a() {}
function b() {}

console.log(a === b) // false -> in real world

useCallback is like to make a === b to be true.

function a() {}
function b() {}

console.log(a === b) // true

memo is like to check the props, if props stay the same, then it’s not going to re-render.

Tip: useCallback(fn, deps) equals useMemo(() => fn, deps).

import { useState, useEffect, useCallback, memo } from "react";

const ExpensiveComputationComponent = memo(({ compute, count}) => {
  return (
    <div>
      <h1>computed: {computed(count)}</h1>
      <h4>last re-render {new Date().toLocaleTimeString()}</h4>
    </div>
  )
});

const CallbackComponent = () => {
  const [time, setTime] = useState(new Date());
  const [count, setCount] = useState(1);

  useEffect(() => {
    const timer = setTimeout(() => setTime(new Date()), 1000);
    return () => clearTimeout(timer);
  }, [time]);

  const fibonacci = (n) => {
    if (n <= 1) return 1;
    return fibonacci(n - 1) + fibonacci(n - 2);
  };

  return (
    <div>
      <h1>useCallback Example {time.toLocaleTimeString()}</h1>
      <button onClick={() => setCount(count + 1)}>
        current count {count}
      </button>
      <ExpensiveComputationComponent
        compute={useCallback(fibonacci, [])}
        count={count}
      />
    </div>
  );
};

export default CallbackComponent;

React.StrictMode

在新版本的 React.strictMode 你會發現你有些執行的程式碼,觸發了兩次,造成的原因是以下三點。

  1. Your components will re-render an extra time to find bugs caused by impure rendering.
  2. Your components will re-run Effects an extra time to find bugs caused by missing Effect cleanup.
  3. Your components will be checked for usage of deprecated APIs.

一的行為流程大致上是 useEffect 會幫你 mounted -> unmounted -> re-mounted

至於解釋我個人認為從第二點的方式講解比較簡單易懂,由於點擊按鈕的行為 count 數值變動,間接觸發了 side effect,但由於你初始時,未在 unmounted 階段時將事件清除,也就等同於多註冊了一次 keypress 事件。

const App = () => {
  const [count, setCount] = useState(0)

  useEffect(() => {
    console.log('effect') // 因為第一點的關係,這邊會觸發兩次,簡單來講就是 useEffect 在這 component 註冊時會觸發兩次

    function handleKeypress() {
      console.log(count) // 這邊點擊時如果沒清除,會觸發兩次,清除後就會變成一次
    }

    document.addEventListener('keypress', handleKeypress) // 註冊事件

    // 將下面的程式碼添加上去,就可以預防第二點的問題
    // return () => document.removeEventListener('keypress', handleKeypress)

  }, [count]) // count 的 side effect

  return (
    <React.StrictMode>
      <button onClick={() => setCount(c => c + 1)}>click</button>
    </React.StrictMode>
  );
}

像是下面這個範例,由於 strictMode 會讓 useEffect 再註冊一次,所以相同的 setInterval 又再註冊了一遍,造成 count 數值變動兩次,體感上會很像 setCount(c => c + 2) 這動作。

const App = () => {
  const [count, setCount] = useState(0)

  useEffect(() => {
    console.log('effect') // 因為第一點的關係,這邊會觸發兩次,簡單來講就是 useEffect 在這 component 註冊時會觸發兩次

    const interval = setInterval(() => {
      setCount(c => c + 1)
    }, 1000)

    // return () => clearInterval(interval)
  }, [])

  return (
    <React.StrictMode>
      <div>{count}</div>
    </React.StrictMode>
  );
}

好的,上面是兩個簡單的範例,很多 youtuber 講 StrictMode 的好幾乎都是透過上方這種簡單的範例,但是其實實際在寫 React 時,你會發現有時你真心只想要註冊一次,ex: websocket,或著抓取資料…等,這時 react 的行為就會很弔詭,前者 websocket 會 disconnect,後者資料會抓兩次,這也是為啥又有些人說要把 strictMode 關掉的原因。

你可以使用 useRef 然後判斷 current 是不是有東西,來預防該情況。但畢竟是多寫的程式碼,所以造成了額外的心智負擔,這也是為啥開發 React 我會建議是有個 leader 在帶的基礎底下在使用其進行開發,會比較舒服,當局者迷。

題外話:你可以使用額外的套件來避免這個問題,像是 react-query,但是就我個人的觀點,這站在教學的角度其實不大好。

import { useEffect, useRef } from "react";

const App = () => {
  const onceRef = useRef(false)

  useEffect(() => {
    // 使用類似的做法去實作
    if (onceRef.current === false) {
      const fetchData = async () => {
        const res = await fetch('https://jsonplaceholder.typicode.com/todos/1')
        const data = await res.json()
        console.log(data)
      }
  
      fetchData()

      return () => onceRef.current = true
    }
  }, [])

  return <div>...</div>
}

useLayoutEffect

React Team: We recommend starting with useEffect first and only trying useLayoutEffect if that causes a problem.

Sometimes you need that function to immediately run right after. Can be think like to be a synchronously useEffect.

可以把順序想像成如下:

  • useEffect (Component > State Changes > Component Renders > Rendered Component is Printed on Screen > useEffect runs)
  • useLayoutEffect (Component > State Changes > Component Renders > useLayoutEffect runs > Rendered Component is Printed on Screen)
import { useState, useLayoutEffect, useRef } from "react";

const LayoutEffectComponent = () => {
  const [width, setWidth] = useState(0);
  const [height, setHeight] = useState(0);
  const el = useRef();

  useLayoutEffect(() => {
    setWidth(el.current.clientWidth);
    setHeight(el.current.clientHeight);
  }, [setWidth, setHeight]);

  return (
    <div>
      <h1>useLayoutEffect Example</h1>
      <h2>textarea width: {width}</h2>
      <h2>textarea height: {height}</h2>
      <textarea
        onClick={() => {
          // If using useEffect, effect happened later, and the click event happened sooner in the millisecond.
          // May cause getting wrong value and will cause above width value change then re-render.
          setWidth(0);
        }}>
        ref={el}
      </textarea>
    </div>
  );
};

export default LayoutEffectComponent;

useImperativeHandle

Create function on the child component in ElaborateInput by using useImperativeHandle and then use this function by calling it on the parent component in ImperativeHandleComponent.

Should use this hook with forwardRef.

import { useState, useRef, useImperativeHandle, forwardRef } from "react";

const ElaborateInput = forwardRef(
  ({ hasError, placeholder, value, update}) => {
    const inputRef = useRef();
    useImperativeHandle(ref, () => {
      return {
        focus() {
          inputRef.current.focus();
        }
      }
    });

    return (
      <input
        ref={inputRef}
        value={value}
        onChange={(e) => update(e.target.value)}
        placeholder={placeholder}
        style={{
          padding: "5px 15px",
          borderWidth: "3px",
          borderStyle: "solid",
          borderColor: hasError ? "crimson" : "#999999",
          borderRadius: "5px",
          margin: "0 10px",
          textAlign: "center"
        }}
      />
    )
  }
);

const ImperativeHandleComponent = () => {
  const [city, setCity] = useState("Seattle");
  const [state, setState] = useState("WA");
  const [error, setError] = useState("");
  const cityEl = useRef();
  const stateEl = useRef();

  function validate() {
    // https://stackoverflow.com/a/25677072
    if (
      !/^([a-zA-Z\u0080-\u024F]+(?:. |-| |'))*[a-zA-Z\u0080-\u024F]+$/.test(
        city
      )
    ) {
      setError("city");
      cityEl.current.focus();
      return;
    }

    if (!/^[A-Z]{2}$/.test(state)) {
      setError("state");
      stateEl.current.focus(); // this ref will get useImperativeHandle registered focus event.
      return;
    }

    setError("");
    alert("Valid form!");
  }

  return (
    <div>
      <h1>useImperativeHandle Example</h1>
      <ElaborateInput
        hasError={error === "city"}
        placeholder={"City"}
        value={city}
        update={setCity}
        ref={cityEl}
      />
      <ElaborateInput
        hasError={error === "state"}
        placeholder={"State"}
        value={state}
        update={setState}
        ref={stateEl}
      />
      <button onClick={validate}>Validate Form</button>
    </div>
  )
};

useDebugValue

import { useState, useEffect, useDebugValue } from "react";

const useIsRaining = () => {
  const [isRaining, setIsRaining] = useState(false);

  useEffect(() => {
    setIsRaining(Math.random() > 0.5);
  });

  useDebugValue(isRaining ? "Is raining" : "Is not raining");

  return isRaining;
};

const DebugValueComponent = () => {
  const isRaining = useIsRaining();

  return (
    <div>
      <h1>useDebugValue Example</h1>
      <p>Do you need a coat today? {isRaining ? "yes" : "maybe"}</p>
    </div>
  );
};

export default DebugValueComponent;

Code split by using lazy and Suspense

Cut your bundle size.

import { lazy, Suspense } from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";

const Details = lazy(() => import("./Details"));
const SearchParams = lazy(() => import("./SearchParams"));

const App = () => {
  return (
    <Suspense
      fallback={<h2 style={{ textAlign: "center" }}>Loading routes...</h2>}
    >
      <Router>
        <Switch>
          <Route path="/details/:id">
            <Detail />
          </Route>
          <Route path="/">
            <SearchParams>
          </Route>
        </Switch>
      </Router>
    </Suspense>
  );
};

Server side rendering

// ClientApp.js - only have in browser
import { hydrate } from "react-dom"
import { BrowserRouter } from "react-router-dom";
import App from "./App"

// other stuff that should only happen in the browser like analytics.

hydrate(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById("root");
)
// App.js
import { StrictMode } from "react-dom";
import { Switch, Route } from "react-router-dom";
import Details from "./Details";
import SearchParams from "./SearchParams";

const App = () => {
  return (
    <StrictMode>
      <Switch>
        <Route path="/details/:id">
          <Details />
        </Route> <Route path="/">
          <SearchParams />
        </Route>
      </Switch>
    </StrictMode>
  );
};

export default App;
// package.json
{
  "script": {
    "build:client": "parcel build --public-url ./dist/ src/index.html",
    "build:server": "parcel build -d dist-server --target node server/index.js",
    "build": "npm run build:client && npm run build:server",
    "start": "npm -s run build && node dist-server/index.js"
  },
  "target : {
    "frontend": {
      "source": "src/index.html",
      "publicUrl": "/frontend"
    },
    "backend": {
      "source": "server/index.js",
      "optimize": false,
      "context": "node",
      "engines": {
        "node": ">=16"
      }
    }
  }
}
import express from "express";
import { renderToString, renderToNodeStream } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import fs from "fs";
import App from "../src/App";

const PORT = process.env.PORT || 3000;

const html = fs.readFileSync("dist/index.html").toString();
const parts = html.split("not rendered");

const app = express();

app.use("/dist", express.static("dist"));

// using renderToString
// app.use((req, res) => {
//   const staticContext = {};
//   const reactMarkup = (
//     <StaticRouter url={req.url} context={staticContext}>
//       <App />
//     </StaticRouter>
//   );
//   res.status(staticContext.statusCode || 200);
//   res.send(`${parts[0]}${renderToString(reactMarkup)}${parts[1]}`);
//   res.end();
// });

// using renderToNodeStream
app.use((req, res) => {
  res.write(parts[0]);
  const staticContext = {};
  const reactMarkup = (
    <StaticRouter url={req.url} context={staticContext}>
      <App />
    </StaticRouter>
  );

  const stream = renderToNodeStream(reactMarkup);
  stream.pipe(res, { end: false });
  stream.on("end", () => {
    res.status(staticContext || 200);
    res.write(parts[1]);
    res.end();
  })
});

app.listen(PORT, () => console.log(`listening on http://localhost:${PORT}`));

React Typescript + Eslint

Below is my personal typescript eslint setup. (Initializing project by using vite react-ts with eslint)

// tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": false,
    "skipLibCheck": false,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["./src"]
}
// .eslintrc.json
{
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:import/recommended",
    "plugin:import/typescript",
    "plugin:jsx-a11y/recommended",
    "plugin:react-hooks/recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@typescript-eslint/recommended-requiring-type-checking",
    "prettier"
  ],
  "rules": {
    "react/self-closing-comp": ["error"],
    "react/jsx-filename-extension": [
      "warn",
      {
        "extensions": [".tsx"]
      }
    ],
    "react/react-in-jsx-scope": 0
  },
  "plugins": ["react", "import", "jsx-a11y", "@typescript-eslint"],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 2021,
    "project": "./tsconfig.json",
    "sourceType": "module",
    "ecmaFeatures": {
      "jsx": true
    }
  },
  "env": {
    "es6": true,
    "browser": true,
    "node": true
  },
  "settings": {
    "react": {
      "version": "detect"
    },
    "import/parsers": {
      "@typescript-eslint/parser": [".ts", ".tsx"]
    },
    "import/resolvers": {
      "typescript": {
        "alwaysTryTypes": true
      }
    }
  }
}

Redux

Below is the simple redux setup and example.

// store/index.js
import { createStore } from "react-redux";
import reducer from "../reducer";

const store = createStore(
  reducer,
  typeof window === "object" && typeof window.__REDUX_DEVTOOLS_EXTENSION__ !== undefined ?
    window.__REDUX_DEVTOOLS_EXTENSION__(): (f) => f
);

export default store;
// reducer/index.js
import { combineReducers } from "redux";
import location from "./location";
import animal from "./animal";

export default combineReducers({
  location,
  animal
});
// reducer/location.js
export default function location(state="Seattle, WA" , action) { switch(action.type) {
    case "CHANGE_LOCATION":
      return action.payload
    default
      return state;
  }
}
// action/changeAnimal.js
export default function changeAnimal(animal) {
  return { type: "CHANGE_ANIMAL", payload: animal }
}
import { Provider } from "react-redux";
import store from "./store";

const App = () => {
  return (
    <Provider store={store}>
      ...
    </Provider>
  )
}
import { useSelector, useDispatch } from "react-redux";
import changeLocation from "./action/changeLocation";

const SearchParams = () => {
  const location = useSelector((state) => state.location);
  const dispatch = useDispatch();

  return (
    <div>
      <form>
        <label htmlFor="location">
          Location
          <input
            id="location"
            value={location}
            placeholder="Location"
            onChange={(e) => dispatch(changeLocation(e.target.value))}
          />
        <label>
        ...
      </form>
    </div>
  )
}

Unit Test

Microsoft engineer Brian Holt said: I think 100% test coverage is a fable fairy tale that’s not worth chasing because a lot of times you write garbage tests. Just to make sure you cover that one last line, right? And I don’t believe in that garbage one last test, right? I will not write that one last test.

Below is the simple react jest test.

// __test__/Pet.test.js
import { expect, test } from "@/jest/globals";
import { StaticRouter } from "react-router-dom";
import { render } from "@testing-library/react";
import Pet from "../Pet";

test("display a default thumbnail", async () => {
  const pet = render(
    <StaticRouter>
      <Pet />
    </StaticRouter>
  );

  const petThumbnail = await pet.findByTestId("thumbnail");

  expect(petThumbnail.src).toContain("none.jpg");
});

test("displays a non-default, correct thumbnail", async () => {
  const pet = render(
    <StaticRouter>
      <Pet images={["1.jpg", "2.jpg", "3.jpg"]} />
    </StaticRouter>
  );

  const petThumbnail = await pet.findByTestId("thumbnail");

  expect(petThumbnail.src).toContain("1.jpg");
});

Testing custom hook. Hooks always exist in components, so you can’t test them outside of component. So if you want to test react custom hook, One way is to render null fake component. The other way is to install an library called @testing-library/react-hooks.

// render null component
import { expect, test } from "@jest/globals";
import { render } from "@testing-library/react";
import useBreedList from "../useBreedList";

function getBreedList(animal) {
  let list;

  function TestComponent() {
    list = useBreedList(animal);
    // component can be null
    return null;
  }

  render(<TestComponent />)

  return list;
}

test("gives an empty array with no animal", async () => {
  const [breedList, status] = useBreedList();

  expect(breedList).toHaveLength(0);
  expect(status).toBe("unloaded");
});
// using @testing-library/react-hooks
import { expect, test } from "@jest/globals";
import { renderHook } from "@testing-library/react-hooks";
import useBreedList from "../useBreedList";

test("gives an empty array with no animal", async () => {
  const { result } = renderHook(() => useBreedList());
  const [breedList, status] = result.current;

  expect(breedList).toHaveLength(0);
  expect(status).toBe("unloaded");
});

Mocking the API for optimizing the test performance by installing the library called jest-fetch-mock.

// package.json -> jest configuration part
{
  "jest": {
    "automock": false,
    "setupFiles": ["./src/setupJest.js"]
  }
}
// setupJest.js
import { enableFetchMocks } from "jest-fetch-mock";

enableFetchMocks();
// useBreedList.test.js
import { expect, test } from "@jest/globals";
import { renderHook } from "@testing-library/react-hooks";
import useBreedList from "../useBreedList";

test("gives an empty array with no animal", async () => {
  const { result } = renderHook(() => useBreedList(""));
  const [breedList, status] = result.current;

  expect(breedList).toHaveLength(0);
  expect(status).toBe("unloaded");
});

test("gives back breeds with an animal", async () => {
  const breeds = [
    "Havanese",
    "Bichon Frise",
    "Poodle",
    "Corgie"
  ];

  fetch.mockResponseOnce(JSON.stringify({
    animal: "dog",
    breeds
  }));

  const { result, waitForNextUpdate } = renderHook(() => useBreedList("dog"));

  await waitForNextUpdate();

  const [breedList status] = result.current;

  expect(status).toBe("loaded");
  expect(breedList).toEqual(breeds);
});

Snapshot the react component by using react-test-renderer.

import { expect, test } from "@jest/globals";
import { create } from "react-test-renderer";
import Results from "../Results";

test("snapshot with no pets", () => {
  const tree = create(<Results pets={[]} />).toJSON();

  expect(tree).toMatchSnapshot();
})

Istanbul, to see test coverage. Link

{
  "script": {
    "jest:coverage": "jest --coverage"
  }
}

Conclusion

這篇文章的 code 都在這個 Github 連結 的不同 branch 內。


Mayvis Chen
你好,我是 Mayvis Chen
住在台北,會點前端,會點後端,熱愛分享新知及愛喝奶茶的全端打雜工程師。