0%

[week 21] React Hooks API:useState & 再戰 Todo List

本篇為 [FE302] React 基礎 - hooks 版本 這門課程的學習筆記。如有錯誤歡迎指正!

學習目標:

 P1 我知道 React 的目的以及原理
 P1 我知道我們為什麼需要 React
 P1 我知道使用 React 跟之前使用 jQuery 的區別
 P1 我理解 state 跟 props 的不同

React Hooks API

根據官網介紹

Hook 是 React 16.8 中增加的新功能。它讓你不必寫 class 就能使用 state 以及其他 React 的功能。

先前提到,React 的核心概念之一是 component,而根據寫法又可分為兩種:

  • class compent:透過 class 寫成,可以控制 state 和生命週期
  • function compent:透過 function 寫成,主要用來呈現 UI

hook 其實就是 function

根據官方文件

hook 是 function,讓我們可以從 function component「hook into」React state 與生命週期功能。

也就是說,React 因為加入了 hooks,再結合 props、state、context、refs 以及 lifecycle 等概念,提供了更直接的 API 使用,讓我們能在 functional component 管理狀態和使用生命周期等功能,藉此簡化程式碼與提高重用性。

引入 hook 的語法如下,原本要寫成 React.useState,透過 ES6 解構語法即可直接使用 useState:

import { useState, useEffect, useRef } from 'react'; 

hooks 只能寫在 component 第一層

因為 React 背後的機制,hooks 只能寫在 component 第一層。

也就是說,不能把 hooks 寫在 if - else 等條件判斷句裡面,像是符合 XX 條件才使用,否則會出現錯誤訊息:

React Hook “useRef” is called conditionally. React Hooks must be called in the exact same order in every component render react-hooks/rules-of-hooks

只能自行在 hooks 裡面進行判斷是否使用,例如下方寫法:

useEffect(() => {
  // 若不符合就直接 return
  if (!todos) return;
  // 若符合要做的事情
})

useState:用來設定 react 中的 state

語法如下:

const [currentValue, setCurrentValue] = useState(initialValue);
  • currentValue:存放 state 的值
  • setCurrentValue:用來設定 state 值
  • initialValue:state 的初始值

還記得我們前面舉的 Counter component 範例嗎?簡單來說,就是在 useState([初始值]) 傳入初始值,然後回傳一個陣列:

function App(){
const [todos, setTodos] = useState([1])
}

其中 todos 具有 immutable 特性,也就是無法再賦值,必須用 setTodos 來產生新的 state,才能在每次 render 畫面時改變 todos 的值。

透過這個概念,我們也能實作出簡單的 Todo List:

// 解構語法
import { useState } from 'react';

function App() {
  const [todos, setTodos] = useState([
    1
  ]);

  const handleButtonClick = () => {
    // 傳入參數: 新的 todo + 解構 todos
    setTodos(["new todo", ...todos]);
  }

  return (
    <div className="App">
      /* 在 JSX 中,單標籤必須 /> 結尾 */
      <input type="text" placeholder="Add todo..." />
      <button onClick={handleButtonClick}>Add Todo</button>
      {
        todos.map((todo, index) => <TodoItem key={index} content={todo} /> ) 
      }
    </div>
  );
}

controlled vs uncontrolled

詳細可參考官方文件

在 React 中,表單元素的處理可分為 uncontrolled 和 controlled,兩者之間的差別,在於 component 的資料是否受到 React 的控制:

  • uncontrolled component:資料不受 React 的控制
    • 例如 input、textarea 等表單元素,通常會維持本身的 state,並根據使用者的輸入來更新該元素的 state
    • 若想取得 uncontrolled component 的值,可透過直接操作 DOM 或使用 useRef 來選取特定元素
  • controlled component:資料受到 React 的控制
    • 如果將資料的控制權交給 React 來處理,畫面就會根據 state 是否改變來重新渲染

參考文章:

  • [Day 27 - 即時天氣] React 中的表單處理(Controlled vs Uncontrolled)以及 useRef 的使用

再戰 Todo List

新增 Todo

透過下方程式碼,可存取 input 的值,並藉由 setValue 來更新 todos 狀態:

import TodoItem from './TodoItem'
// 解構語法
import { useState, useRef } from 'react';

function App() {
  const [todos, setTodos] = useState([
    1
  ]);

  const [value, setValue] = useState('');

  const handleButtonClick = () => {
    // 傳入參數: 新的 todo + 解構 todos
    setTodos([value, ...todos]);
    // 新增完 todo 後清空 value
    setValue('');
  }

  const handleInputChange = (e) => {
    // 拿到 input 的 value
    setValue(e.target.value);
  }

  return (
    <div className="App">
      <input type="text" placeholder="Add todo..." value={value} onChange={handleInputChange} />
      <button onClick={handleButtonClick}>Add Todo</button>
      {
        todos.map((todo, index) => <TodoItem key={index} content={todo} /> ) 
      }
    </div>
  );
}

useRef:用來抓取 DOM 節點的 hook

基本用法:

const refContainer = useRef(initialValue);

根據官方文件介紹:

useRef 回傳一個可變的 ref object,其 .current 屬性被初始為傳入的參數(initialValue)。回傳的 object 在 component 的生命週期將保持不變。

簡單來說,useRef 是可持有 mutable(可變的)值、具有 .current 屬性的「盒子」,並具有以下特性:

  • 當 .current 屬性有變動時不會觸發重新 render
  • 在每次 render 時都會給同一個的 ref object

我們可透過宣告 id = useRef(2),就能夠在每次 render 時,更改每個 todo 中 id.current 的值:

function App() {
  const [todos, setTodos] = useState([
    {id: 1, content: 'todo1'}
  ]);

  const [value, setValue] = useState('');
  // useRef: 能我們抓取到 DOM 節點的 hooks
  // 會回傳一個物件,要以 id.current 讀取
  const id = useRef(2);

  const handleButtonClick = () => {
    setTodos([
      {
        id: id.current,
        content: value
      }, ...todos]
    );
    setValue('');
    id.current++;
  }

  const handleInputChange = (e) => {
    setValue(e.target.value);
  }

  return (
    <div className="App">
      <input type="text" placeholder="Add todo..." value={value} onChange={handleInputChange} />
      <button onClick={handleButtonClick}>Add Todo</button>
      {
        todos.map(todo => <TodoItem key={todo.id} todo={todo} /> ) 
      }
    </div>
  );

傳入 todo 這個參數,並放到 TodoItem:

export default function TodoItem({ content, todo }) {
  return (
    <TodoItemWrapper data-todo-id={todo.id}>
      <TodoContent>{todo.content}</TodoContent>
      <TodoButtonWrapper>
        <Button>未完成</Button>
        <RedButton>刪除</RedButton>
      </TodoButtonWrapper>
    </TodoItemWrapper>
  );
}

===上方寫法等同於===

function TodoItem() {
  // ...
}
export default TodoItem;

刪除 todo

1. 把要做的 function 寫在 Parent,並傳入參數給 Children

把 handleDeleteTodo 這個 function 當作 props 傳給 TodoItem:

const handleDeleteTodo = id => {

}

return (
  <div className="App">
    <input type="text" placeholder="Add todo..." value={value} onChange={handleInputChange} />
    <button onClick={handleButtonClick}>Add Todo</button>
    {
      todos.map(todo => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} /> ) 
    }
  </div>
);

2. 再由 Children 呼叫 function

TodoItem 就可以接收這個 funtcion,並透過刪除按鈕的監聽事件,來呼叫 handleDeleteTodo 以及回傳該 todo.id

export default function TodoItem({ content, todo, handleDeleteTodo }) {
  return (
    <TodoItemWrapper data-todo-id={todo.id}>
      <TodoContent>{todo.content}</TodoContent>
      <TodoButtonWrapper>
        <Button>未完成</Button>
        <RedButton onClick={() => {
          handleDeleteTodo(todo.id)
        }}>刪除</RedButton>
      </TodoButtonWrapper>
    </TodoItemWrapper>
  );
}

3. 接著在 Parent 處理 function

const handleDeleteTodo = id => {
  // 若用 splice() 會改到原本的 todo,因此要用 filter(),留下該 id 以外的 todo
  setTodos(todos.filter(todo => todo.id !== id))
}

編輯 todo

在實作編輯 todo 之前,要先來檢視資料結構,也就是把 todo 的狀態加上 isDone,用來判斷是否已完成:

function App() {
  const [todos, setTodos] = useState([
    { id: 1, content: 'done', isDone: true },
    { id: 2, content: 'not done', isDone: false }
  ]);

  const [value, setValue] = useState('');
  const id = useRef(3);

接著在 TodoItem 的按鈕加上三元運算子,用來判斷 todo 狀態,再藉由 $isDone 這個參數,判斷式是否執行後面的 JSX 語法:

const TodoContent = styled.div`
  font-size: 26px;
  color: ${props => props.theme.colors.primary_300};

  // 若 isDone 這個參數為 true(已完成) 則接續後面的 JSX 語法
  ${props => props.$isDone && `
    text-decoration: line-through;
  `}
`

export default function TodoItem({ content, todo, handleDeleteTodo }) {
  return (
    <TodoItemWrapper data-todo-id={todo.id}>
      // 傳入 isDone 這個參數
      <TodoContent $isDone={todo.isDone}>{todo.content}</TodoContent>
      <TodoButtonWrapper>
        <Button>
          // 透過三元運算子判斷 todo 狀態
          {todo.isDone ? '已完成' : '未完成'}
        </Button>
        <RedButton onClick={() => {
          handleDeleteTodo(todo.id)
        }}>刪除</RedButton>
      </TodoButtonWrapper>
    </TodoItemWrapper>
  );
}

除了三元運算子,也可改寫成邏輯運算子 && 的寫法,適用於多種可能的情況:

<Button onClick={handleToggleClick}>
  {todo.isDone && '已完成'}
  {!todo.isDone && '未完成'}
</Button>

接著實作 handleToggleIsDone 修改 todo 功能:

const handleToggleIsDone = id => {
  setTodos(todos.map(todo => {
    // 如果不是要修改的 todo id 就直接回傳
    if (todo.id !== id) return todo;
    // 要修改的 todo id
    return {
      // todo 原本的東西
      ...todo,
      // 要修改的屬性
      isDone: !todo.isDone
    }
  }));
}

傳入參數到 TodoItem:

{
  todos.map(todo => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} handleToggleIsDone={handleToggleIsDone}/> ) 
}

由 TodoItem 接收參數,可以把 click 事件抽出來寫,相較於原本的 inline function,能夠提高程式碼的可讀性:

export default function TodoItem({ todo, handleDeleteTodo, handleToggleIsDone }) {
  const handleToggleClick = () => {
    handleToggleIsDone(todo.id);
  }

  const handleDeleteClick = () => {
    handleDeleteTodo(todo.id);
  }

  return (
    <TodoItemWrapper data-todo-id={todo.id}>
      <TodoContent $isDone={todo.isDone}>{todo.content}</TodoContent>
      <TodoButtonWrapper>
        <Button onClick={handleToggleClick}>
          {todo.isDone ? '已完成' : '未完成'}
        </Button>
        <RedButton onClick={handleDeleteClick}>刪除</RedButton>
      </TodoButtonWrapper>
    </TodoItemWrapper>
  );
}

Transient props:$<props>

在上方程式碼中,加在 TodoContent 的 $isDone 這個 props,會被視為 style component props,不會被繼續傳到下一個 DOM 元素,也就不會顯示在 TodoContent 標籤上。

如果沒有加上 $ 符號,這個 props 就會被直接加在 TodoContent 這個 DOM 結構上。

再以下方程式碼為例:

<TodoContent id="abc" $isDone={todo.isDone}>{todo.content}</TodoContent>

可以發現經過 render 之後,在 DOM 元素只會出現 id="abc" 這個屬性,而不會有 $isDone,這是因為 Transient props 不會被往下傳:

Todo List 總結

透過實作簡單的 Todo List,其實我們就差不多學會了有關 React 的基礎:

  1. Component 組件

開發 React 很重要的一點,就是去思考在頁面有哪些重複性高或相似的 Element,再透過 JSX 將這些 Element 建立成一個 Components,讓每個 Components 擁有重複性及可擴充性。

  1. Props 參數

Props 主要提供值給 Component,用來設定屬性或資料,因此就算是同一個組件,也會根據提供的 Props 而有所不同。

  1. Style 樣式

可透過幾種方式撰寫 React 中的 CSS,目前主流方法是透過 styled-components 這個套件來撰寫 CSS 語法。

  1. Event handler 事件機制

和過去在網頁添加事件監聽不同,必須先以 querySelector() 選取 DOM 元素:

function sayHello() {
  alert('Hello!');
}
document.querySelector('.sayHello').addEventListener('click', sayHello);

而 React 把 DOM 和 JavaScript 程式碼寫在一起,因此可直接在 DOM 元素加上 onClick、onSubmit、onKeyDown 等事件監聽:

function TodoItem({ todo, handleDeleteTodo }) {
  const handleDeleteClick = () => {
    handleDeleteTodo(todo.id);
  }
  return (
    <RedButton onClick={handleDeleteClick}>刪除</RedButton>
  );
}

      /* 也可簡化成箭頭函式 */

function TodoItem({ todo, handleDeleteTodo }) {
  return (
    <RedButton onClick={() => {
      handleDeleteTodo(todo.id)
    }}>刪除</RedButton>
  );
}
  1. JSX 語法

透過 JSX 語法,即可將 HTML 語法轉成 JavaScript 的形式,讓我們用來建立 React elements。

使用 JSX 時需注意下列幾點:

  • class 是保留字,必須改寫成 className
  • 可在大括號內寫程式碼,例如:{ JS code },也因此 inline-style 需改為駝峰式命名
  • 沒有迴圈的概念,也沒有 if-else 判斷式
    • 解決方法:透過三元運算子,或是邏輯運算子 && 進行判斷

此外,當我們要 render 一系列 list 的時候,會使用 map() 把資料變成一個陣列,然後 render 需要提供 key:

{
   todos.map(todo => <TodoItem key={todo.id} todo={todo} /> ) 
}
  1. State 狀態

是 React 中最重要的觀念,可透過 useState 設定 state 初始值,再以 setState 去改變 state。state 會對應到一個 UI,一旦 state 有變動,就會自動呼叫 render()。

state 最基本的語法如下:

const [currentValue, setCurrentValue] = useState(initialValue);

在 React 當中,若要進行新增、編輯、刪除功能,雖然有許多方法能夠達成,但基本上有固定的作法:

  • 新增功能:解構語法
const handleButtonClick = () => {
  setTodos([
    {
      // 要新增的 todo
      id: id.current,
      content: value
      // 解構語法
    }, ...todos]
  );
  setValue('');
  id.current++;
}
  • 編輯功能:map()
const handleToggleIsDone = id => {
  setTodos(todos.map(todo => {
    // 如果不是要修改的 todo id 就直接回傳
    if (todo.id !== id) return todo;
    // 要修改的 todo id
    return {
      // todo 原本的東西
      ...todo,
      // 要修改的屬性
      isDone: !todo.isDone
    }
  }));
}
  • 刪除功能:filter()
const handleDeleteTodo = id => {
  // 留下該 id 以外的 todo
  setTodos(todos.filter(todo => todo.id !== id))
}


結論

這和以往的思考模式其實很不一樣,像是在切好的 UI 畫面上新增各種功能;而 React 則是先思考 state 狀態,再去想會如何改變畫面。

記住一個重點,就是 Component 之間可透過 props 把 state 傳遞下去。並且,只要 state 所有變動,就會觸發 render() 來更新 UI 畫面。

參考資料:

  • 常見的幾個 React hooks 教學-useState、useEffect、useRef
  • React 性能優化大挑戰:一次理解 Immutable data 跟 shouldComponentUpdate