React - todo list 範例


Posted by saffran on 2021-02-25

在 React JSX 的語法裡面,沒有「迴圈」的概念,因此如果要 render 陣列(一系列的資料)的話,一定要用 .map() 這種 functional 的方式去做

因為沒有迴圈,所以 .forEach() 也不會有東西,所以會用 .map()

.map() 的好處是:可以把陣列裡面的每一個東西,都 map 成一個 component,如下程式碼

這時的 todos 陣列就會是 [123, 456]
.map() 會產生出一個陣列

function App() {
  const [todos, setTodos] = React.useState([
    123, 456
  ]);
  const handleButtonClick = () => {

  }
  return (
    <div className="App">
      <button onClick={handleButtonClick}>Add todo</button>

      {
        todos.map(todo => <TodoItem content={todo} />)
      }

    </div>
  );
}

上面用 .map() 的寫法,就等同於是下面這樣寫:
用大括號包住一個陣列,陣列裡面傳入我要 render 的很多 components

function App() {
  const [todos, setTodos] = React.useState([
    123, 456
  ]);
  const handleButtonClick = () => {

  }
  return (
    <div className="App">
      <button onClick={handleButtonClick}>Add todo</button>

      {
        [<TodoItem content={123} />, <TodoItem content={456} />]
      }

    </div>
  );
}

在 console 會看到一個 warning 寫說:「Warning: Each child in a list should have a unique "key" prop.」

用 JSX 來 render 一個陣列時,要幫每個 component 加上一個 key 的 prop,讓 React 可以辨別陣列裡面的每一個 item

key 是不能重複的
這裡,會先用 index 當作 key(但其實是不建議用 index 當作 key)
建議是用 todo.id 當作 key (請看 範例 - 新增 todo

setTodos 的錯誤寫法

function App() {
  const [todos, setTodos] = React.useState([
    123, 456
  ]);
  const handleButtonClick = () => {
    todos.push(777); // 這時的 todos 會是 [123, 456, 777]
    setTodos(todos); // setTodos 裡面的 todos 也是 [123, 456, 777]
  }
  return (
    <div className="App">
      <button onClick={handleButtonClick}>Add todo</button>

      {
        todos.map((todo, index) => <TodoItem key={index} content={todo} />)
      }

    </div>
  );
}

上面這樣寫,會發現
點擊「Add todo 按鈕」不會有任何反應,原因為:
todos.push(777) 之後,這時的 todos 會是 [123, 456, 777](這是舊的 state)
setTodos(todos) 裡面的 todos 也是 [123, 456, 777] (這是新的 state)
當 React 判定「舊的 state」跟「新的 state」是一樣時,就不會做任何事情

所以,我不能「直接改變原本的 state」,而是要「在 setTodos 產生一個新的 state」

原本的 todos 是「不會變的」

setTodos 的正確寫法

在 React 的 state 是「immutable 不可改變的」
因此,如果是要「新增」的話,要這樣寫:

setTodos([...todos, 777]);

setTodos() 裡面,建立一個新的陣列,用解構的語法把 todos 原本的值複製過來,後面再加上我要新增的東西

import styled from 'styled-components';
import TodoItem from './TodoItem';
import { useState } from 'react'; // 用解構的語法

const YellowTodoItem = styled(TodoItem)`
  background-color: yellow;
`

function App() {
  const [todos, setTodos] = useState([
    123, 456
  ]);
  const handleButtonClick = () => {
    setTodos([...todos, 777]);
  }
  return (
    <div className="App">
      <button onClick={handleButtonClick}>Add todo</button>

      {
        todos.map((todo, index) => <TodoItem key={index} content={todo} />)
      }

    </div>
  );
}

export default App;

新增 todo

React 的 component 有分兩種:

第一種:controlled component

controlled component 就是:value 有放在 state 裡面

範例:
inputvalue(也就是 input 會顯示在畫面上的值) 放到一個 state 裡面

每當 state 改變,就會 re-render

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

  const [todos, setTodos] = useState([
    123, 456
  ]);
  const handleButtonClick = () => {
    setTodos([...todos, 777]);
  }

  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, index) => <TodoItem key={index} content={todo} />)
      }

    </div>
  );
}

第二種:uncontrolled component

uncontrolled component 就是:value 沒有放在 state 裡面

要拿出 input 的 value 有幾種方法

方法一:

input 裡面不加上 value={value} onChange={handleInputChange} 這兩個
當我要拿出 input 的 value 時我再用 document.querySelector('.input-todo').value 拿出來

function App() {
  const [todos, setTodos] = useState([
    123, 456
  ]);
  const handleButtonClick = () => {
    const value = document.querySelector('.input-todo').value;
    setTodos([...todos, 777]);
  }

  return (
    <div className="App">
      <input className="input-todo" type="text" placeholder="add todo" />
      <button onClick={handleButtonClick}>Add todo</button>

      {
        todos.map((todo, index) => <TodoItem key={index} content={todo} />)
      }

    </div>
  );
}

方法二:

先在上方引入 useRef 這個 function
input 傳入一個參數 ref 來存取到這個 DOM 元素

import styled from 'styled-components';
import TodoItem from './TodoItem';
import { useState, useRef } from 'react'; // 用解構的語法

const YellowTodoItem = styled(TodoItem)`
  background-color: yellow;
`

function App() {
  const [todos, setTodos] = useState([
    123, 456
  ]);
  const inputRef = useRef();

  const handleButtonClick = () => {
    console.log(inputRef);
    setTodos([...todos, 777]);
  }

  return (
    <div className="App">
      <input ref={inputRef} className="input-todo" type="text" placeholder="add todo" />
      <button onClick={handleButtonClick}>Add todo</button>

      {
        todos.map((todo, index) => <TodoItem key={index} content={todo} />)
      }

    </div>
  );
}

export default App;

console.log(inputRef) 會印出一個物件,物件裡面有 current 這個值(這是 React 提供的)

因此,inputRef.current 就會是 input 這個 DOM 元素
我就可以用 inputRef.current.value 來拿到 input 的 value

範例 - 新增 todo

接下來的範例,會使用「controlled component」這個方法

setTodos()...todos 的方式來新增 todo

setTodos() 小括號裡面,要產生一個新的陣列

import TodoItem from './TodoItem';
import { useState } from 'react'; // 用解構的語法

let id = 2; // 會一直遞增的 id

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

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

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

  const handleButtonClick = () => {
    setTodos([
        {
          id: id,
          content: value
        }, ...todos]);
    setValue('');
    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} content={todo.content} />)
      }

    </div>
  );
}

export default App;

不好的做法:把 id 存成 state

不好的原因是:
id 的改變並不會造成「畫面的改變」,並不需要 re-render 畫面
但是如果把 id 存成 state,每當 id 改變時(state 改變),畫面就會 re-render,造成不必要的效能浪費

import TodoItem from './TodoItem';
import { useState } from 'react'; // 用解構的語法

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

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

  const [id, setId] = useState(2);

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

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

  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} content={todo.content} />)
      }

    </div>
  );
}

export default App;

因此,不該把 id 存成 state

推薦的做法:用 useRef

useRef 為了要讓「值可以保存住」,useRef 可以當成 state 來用,也可以直接操作。但是在 component re-render 時,useRef 的值不會變

  • useRef(2) 來設定 id 的初始值是 2
  • useRef(2) 會回傳下面的物件,所以要用 id.current 才能拿到 useRef(2) 小括號裡面的值,也就是 2
  {
    current: 2
  }
  • 要更新 id 的值,就用 id.current++
  • todos.map(todo => <TodoItem key={todo.id} todo={todo} />) 為了確定是否有成功把 id 放到 todo 上面,可以在 <TodoItem> 直接把整個 todo 傳入

App.js:

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

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

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

  const id = useRef(2);

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

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

  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>
  );
}

export default App;

TodoItem.js:

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

這樣就可以看到:
data-todo-id 有成功放到 todo 上面了

加上「刪除 todo」的功能

在 App.js 裡面,會 render 出 TodoItem,所以

  • App.js 是 parent(父層)
  • TodoItem 是 child(子層)

當我按下了在 TodoItem.js 的 <RedButton>刪除</RedButton>,我要怎麼去改變 App.js 裡面的 state 呢?

作法如下:

React 重要觀念

把「要做事情的 function」寫在 parent,再把這個 function 傳給 child

當 child 呼叫這個 function,我就可以在 parent 處理這個 function 該做的事情和資訊

這樣就可以達成「在 child 改變 “parent 的 state”」

在 App.js(父層)

在 App.js 宣告一個 function 叫做 handleDeleteTodo,會接收一個 id

handleDeleteTodo 這個 function 當作 prop 傳給 <TodoItem>

App.js:

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

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

  const id = useRef(2);

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

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

  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>
  );
}

在 TodoItem.js(子層)

這時,在 TodoItem.js 裡面的 TodoItem 就可以接收到 handleDeleteTodo 這個 function

接著,就可以在 TodoItem component 的 <RedButton> 加上 onClick 事件:當點擊刪除按鈕時,執行 handleDeleteTodo function,並傳入 todo.id
TodoItem.js:

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

setTodos().filter() 來刪除指定的 todo

執行 handleDeleteTodo function 就會去呼叫在 App.js 的 handleDeleteTodo function

我就可以在 App.js 裡面,用 handleDeleteTodo function 接收到的 id 來把該 todo 刪掉

因此,在 handleDeleteTodo function 裡面,用 setTodo() 來更新 todos 陣列:
不能用 .splice() 來刪除 todo,因為 .splice() 會改到原本的 todos 陣列

要用 .filter() 來刪除 todo,因為 .filter() 會產生一個新的陣列,不會去改到原本的 todos 陣列

App.js:

...
  const handleDeleteTodo = id => {
    setTodos(todos.filter(todo => todo.id !== 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>
  );
}

render 出 todo 的「已完成/未完成」

因為在 JSX 語法中,沒有 if...else,所以要寫判斷式的話,有兩個方式:

方式一:用「三元運算子」 (如果條件就只有 true/false,那就用三元運算子即可)

TodoItem.js:
{todo.isFinished ? '未完成' : '已完成'} 三元運算子來決定 todo 是要顯示已完成 or 未完成

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

然後,同樣在 TodoItem.js 裡面,就可以在 TodoContent 的 styled component 用 props 加上 text-decoration: line-through 這段
&& 意思就是:如果 props.isFinished 是 true 的話,就會回傳後面那行 text-decoration: line-through;

const TodoContent = styled.div`
  color: ${props => props.theme.colors.primary_500};
  font-size: 16px;
  ${props => props.size === 'XL' && `
    font-size: 20px;
  `}

  ${props => props.isFinished && `
    text-decoration: line-through;
  `}
`

方式二:用短路運算子 (如果條件有很多個,不只有 true/false,那就要用短路運算子)

  • {todo.isFinished && '未完成'} 意思是:如果 todo.isFinished 是 true,就會回傳 '未完成'
  • {!todo.isFinished && '已完成'} 意思是:如果 todo.isFinished 是 false,就會回傳 '已完成'
export default function TodoItem({ className, size, todo, handleDeleteTodo, handleToggleIsFinished }){
  const handleToggleClick = () => {
    handleToggleIsFinished(todo.id)
  }

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

  return (
    <TodoItemWrapper className={className} data-todo-id={todo.id}>
      <TodoContent $isFinished={todo.isFinished} size={size}>{todo.content}</TodoContent>
      <TodoButtonWrapper>
        <Button onClick={handleToggleClick}>
          {todo.isFinished && '未完成'}
          {!todo.isFinished && '已完成'}
        </Button>
        <RedButton onClick={handleDeleteClick}>刪除</RedButton>
      </TodoButtonWrapper>
    </TodoItemWrapper>
  )
}

transient prop 的觀念

可參考文件 Transient props

在 component 傳入的 props,除了會傳給 styled component 之外,也會直接傳到 DOM 上面

例如我在 <TodoContent> 加上一個 props 叫做 id="abc"

<TodoContent id="abc" isFinished={todo.isFinished} size={size}>{todo.content}</TodoContent>

render 出來的 DOM 就會有 id="abc" 這個屬性:

但如果我不想要把我傳入的 props 傳給 DOM,我只想要傳給 styled component 做 style 的處理就好,那就在 props 前面加一個 $ 即可

加上 $ 就會是一個 transient prop

id="abc" 前面加一個 $

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

render 出來的 DOM 就不會有 id="abc" 這個屬性了:

在 styled component 用時,就要寫成 props.$id,而不是 props.id

const TodoContent = styled.div`
  color: ${props => props.theme.colors.primary_500};
  font-size: 16px;
  ${props => props.size === 'XL' && `
    font-size: 20px;
  `}

  ${props => props.$isFinished && `
    text-decoration: line-through;
  `}

  ${props => props.$id && `
    color: red
  `}
`

改變 todo 的「已完成/未完成」

宣告一個 handleToggleIsFinished function 來處理「已完成/未完成」

setTodos().map() 修改指定的 todo 的屬性

.map() 會產生出一個新的陣列,我就可以在這個新的陣列裡面修改那個 idtodo(修改 isFinished 的狀態)

然後,要把 handleToggleIsFinished 當作 prop 傳到 <TodoItem>

App.js:

...
  // 切換已完成/未完成
  const handleToggleIsFinished = id => {
    setTodos(todos.map(todo => {
      if (todo.id !== id) return todo;
      return {
        ...todo,
        isFinished: !todo.isFinished
      }
    }))
  }

  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} handleToggleIsFinished={handleToggleIsFinished} />)
      }

    </div>
  );
}

這樣,在 TodoItem.js 的 <TodoItem> 就可以接受到 handleToggleIsFinished
<Button> 就可以加上 onClick 的事件了
這裡把 onClick 裡面的 function 抽出來寫(handleToggleClick),這樣的可讀性會比直接寫在 onClick 裡面好(直接寫在裡面叫做 inline function)

export default function TodoItem({ className, size, todo, handleDeleteTodo, handleToggleIsFinished }){
  const handleToggleClick = () => {
    handleToggleIsFinished(todo.id)
  }

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

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

todo list 重點整理

setTodos() 小括號裡面,要產生一個新的陣列

  • setTodos()...todos 的解構語法來新增 todo
  • setTodos().filter() 來刪除指定的 todo
  • setTodos().map() 修改指定的 todo 的屬性

從 todo list 學到的重點觀念有:

  • Component
  • Props
  • Style
  • Event handler
  • JSX
  • State

#React







Related Posts

在AWS EC2上執行Express專案

在AWS EC2上執行Express專案

【Day04】元件

【Day04】元件

ASP.NET Core Web API 入門教學 - 事前必備知識

ASP.NET Core Web API 入門教學 - 事前必備知識


Comments