Project_ReduxTodo.jsx
React.js/lessons/19_redux_toolkit/projects/Project_ReduxTodo.jsx
/**
* Project: Redux Todo App — Redux Toolkit 練習
*
* 學習重點:
* - configureStore 建立 Store
* - createSlice 定義 reducer 和 actions
* - useSelector 讀取 state
* - useDispatch 發送 action
* - Immer 的直接修改語法
*
* 安裝依賴:npm install @reduxjs/toolkit react-redux
* 使用方式:將此檔案內容複製到你的 App.jsx 中
*/
import { useState } from 'react';
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';
// ============================================================
// Todos Slice
// ============================================================
const todosSlice = createSlice({
name: 'todos',
initialState: {
items: [
{ id: 1, text: '學習 Redux Toolkit', done: true, priority: 'high' },
{ id: 2, text: '建立 Todo App', done: false, priority: 'high' },
{ id: 3, text: '理解 createSlice', done: false, priority: 'medium' },
{ id: 4, text: '練習 useSelector', done: false, priority: 'low' },
],
filter: 'all',
},
reducers: {
addTodo: (state, action) => {
state.items.push({
id: Date.now(),
text: action.payload.text,
done: false,
priority: action.payload.priority || 'medium',
});
},
toggleTodo: (state, action) => {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) todo.done = !todo.done;
},
deleteTodo: (state, action) => {
state.items = state.items.filter((t) => t.id !== action.payload);
},
editTodo: (state, action) => {
const { id, text } = action.payload;
const todo = state.items.find((t) => t.id === id);
if (todo) todo.text = text;
},
setFilter: (state, action) => {
state.filter = action.payload;
},
clearCompleted: (state) => {
state.items = state.items.filter((t) => !t.done);
},
toggleAll: (state) => {
const allDone = state.items.every((t) => t.done);
state.items.forEach((t) => { t.done = !allDone; });
},
},
});
const { addTodo, toggleTodo, deleteTodo, editTodo, setFilter, clearCompleted, toggleAll } = todosSlice.actions;
// ============================================================
// Store
// ============================================================
const store = configureStore({
reducer: { todos: todosSlice.reducer },
});
// ============================================================
// Components
// ============================================================
function TodoInput() {
const [text, setText] = useState('');
const [priority, setPriority] = useState('medium');
const dispatch = useDispatch();
const handleSubmit = (e) => {
e.preventDefault();
if (!text.trim()) return;
dispatch(addTodo({ text: text.trim(), priority }));
setText('');
};
return (
<form onSubmit={handleSubmit} style={{
display: 'flex', gap: '8px', marginBottom: '20px',
padding: '12px', backgroundColor: '#f8fafc', borderRadius: '12px',
}}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="新增待辦事項..."
style={{
flex: 1, padding: '10px 14px', border: '2px solid #e2e8f0',
borderRadius: '8px', fontSize: '0.95rem', outline: 'none',
}}
/>
<select value={priority} onChange={(e) => setPriority(e.target.value)}
style={{ padding: '8px', borderRadius: '8px', border: '2px solid #e2e8f0' }}>
<option value="high">高</option>
<option value="medium">中</option>
<option value="low">低</option>
</select>
<button type="submit" disabled={!text.trim()} style={{
padding: '10px 24px', borderRadius: '8px', border: 'none',
backgroundColor: text.trim() ? '#4f46e5' : '#cbd5e1',
color: 'white', cursor: text.trim() ? 'pointer' : 'not-allowed',
fontWeight: 'bold',
}}>
新增
</button>
</form>
);
}
function TodoFilters() {
const dispatch = useDispatch();
const filter = useSelector((state) => state.todos.filter);
const items = useSelector((state) => state.todos.items);
const activeCount = items.filter((t) => !t.done).length;
const completedCount = items.filter((t) => t.done).length;
return (
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: '16px', flexWrap: 'wrap', gap: '8px',
}}>
<div style={{ display: 'flex', gap: '4px' }}>
{[
{ key: 'all', label: `全部 (${items.length})` },
{ key: 'active', label: `未完成 (${activeCount})` },
{ key: 'completed', label: `已完成 (${completedCount})` },
].map((f) => (
<button
key={f.key}
onClick={() => dispatch(setFilter(f.key))}
style={{
padding: '6px 14px', borderRadius: '8px', border: 'none',
backgroundColor: filter === f.key ? '#4f46e5' : 'transparent',
color: filter === f.key ? 'white' : '#64748b',
cursor: 'pointer', fontSize: '0.8rem',
fontWeight: filter === f.key ? '600' : '400',
}}
>
{f.label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{items.length > 0 && (
<button onClick={() => dispatch(toggleAll())} style={{
padding: '6px 12px', borderRadius: '8px', border: '1px solid #e2e8f0',
backgroundColor: 'white', color: '#64748b', cursor: 'pointer', fontSize: '0.8rem',
}}>
{items.every((t) => t.done) ? '取消全選' : '全選'}
</button>
)}
{completedCount > 0 && (
<button onClick={() => dispatch(clearCompleted())} style={{
padding: '6px 12px', borderRadius: '8px', border: '1px solid #fca5a5',
backgroundColor: 'white', color: '#ef4444', cursor: 'pointer', fontSize: '0.8rem',
}}>
清除已完成
</button>
)}
</div>
</div>
);
}
function TodoItem({ todo }) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
const dispatch = useDispatch();
const priorityStyles = {
high: { bg: '#fef2f2', color: '#ef4444', label: '高' },
medium: { bg: '#fffbeb', color: '#f59e0b', label: '中' },
low: { bg: '#f0fdf4', color: '#22c55e', label: '低' },
};
const ps = priorityStyles[todo.priority];
const handleSave = () => {
if (editText.trim()) {
dispatch(editTodo({ id: todo.id, text: editText.trim() }));
}
setIsEditing(false);
};
return (
<div style={{
display: 'flex', alignItems: 'center', gap: '12px', padding: '12px 16px',
backgroundColor: 'white', borderRadius: '10px', marginBottom: '8px',
border: '1px solid #f1f5f9', opacity: todo.done ? 0.6 : 1,
}}>
<input
type="checkbox"
checked={todo.done}
onChange={() => dispatch(toggleTodo(todo.id))}
style={{ width: '18px', height: '18px', accentColor: '#4f46e5', cursor: 'pointer' }}
/>
<span style={{
padding: '2px 8px', borderRadius: '6px', backgroundColor: ps.bg,
color: ps.color, fontSize: '0.7rem', fontWeight: 'bold', flexShrink: 0,
}}>{ps.label}</span>
{isEditing ? (
<input
value={editText}
onChange={(e) => setEditText(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
autoFocus
style={{ flex: 1, padding: '4px 8px', border: '2px solid #818cf8', borderRadius: '6px', outline: 'none' }}
/>
) : (
<span
onDoubleClick={() => setIsEditing(true)}
style={{
flex: 1, textDecoration: todo.done ? 'line-through' : 'none',
color: todo.done ? '#94a3b8' : '#1e293b', cursor: 'text',
}}
title="雙擊編輯"
>
{todo.text}
</span>
)}
<button
onClick={() => dispatch(deleteTodo(todo.id))}
style={{ background: 'none', border: 'none', color: '#e2e8f0', cursor: 'pointer', fontSize: '1.2rem' }}
onMouseEnter={(e) => (e.target.style.color = '#ef4444')}
onMouseLeave={(e) => (e.target.style.color = '#e2e8f0')}
>
×
</button>
</div>
);
}
function TodoList() {
const items = useSelector((state) => state.todos.items);
const filter = useSelector((state) => state.todos.filter);
const filtered = items.filter((t) => {
if (filter === 'active') return !t.done;
if (filter === 'completed') return t.done;
return true;
});
if (filtered.length === 0) {
return (
<div style={{ textAlign: 'center', padding: '40px', color: '#cbd5e1' }}>
<p style={{ fontSize: '2rem', margin: '0 0 8px' }}>📋</p>
<p style={{ margin: 0 }}>沒有待辦事項</p>
</div>
);
}
return (
<div>
{filtered.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</div>
);
}
function StoreVisualizer() {
const state = useSelector((s) => s.todos);
return (
<div style={{
marginTop: '24px', padding: '16px', borderRadius: '12px',
backgroundColor: '#0f172a', color: '#94a3b8', fontFamily: 'monospace',
fontSize: '0.75rem', lineHeight: 1.6, overflow: 'auto',
}}>
<p style={{ color: '#818cf8', margin: '0 0 4px' }}>// Redux Store State</p>
<pre style={{ margin: 0 }}>{JSON.stringify(state, null, 2)}</pre>
</div>
);
}
function TodoApp() {
return (
<div style={{
maxWidth: '600px', margin: '32px auto', padding: '0 16px',
fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
}}>
<h1 style={{ color: '#0f172a', marginBottom: '4px' }}>Redux Todo App</h1>
<p style={{ color: '#94a3b8', marginTop: 0, marginBottom: '24px' }}>使用 Redux Toolkit 管理狀態</p>
<TodoInput />
<TodoFilters />
<TodoList />
<StoreVisualizer />
</div>
);
}
// ============================================================
// App(用 Provider 包裹)
// ============================================================
function App() {
return (
<Provider store={store}>
<TodoApp />
</Provider>
);
}
export default App;
Bài viết liên quan
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
Đọc bài viết →App.test.js
App.test.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.test.js).
Đọc bài viết →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
Đọc bài viết →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
Đọc bài viết →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
Đọc bài viết →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
Đọc bài viết →