You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React任务组件多选框批量操作异常:仅最后一项位置变更的修复

问题描述

开发了一个Task组件,其中handleCheckboxChange函数实现以下功能:

  • 勾选任务后,3秒内该任务会移至Done列表
  • 取消勾选时,任务会移至Todo列表顶部

单独操作单个任务时功能正常,但同时勾选或取消勾选多个任务时,仅有最后一个任务的位置会发生变化。

问题根源

问题出在handleCheckboxChange函数的延迟更新逻辑中:

  1. 闭包捕获旧值:setTimeout内部代码捕获了事件触发时的todos状态、数组索引等变量,3秒后执行时,所有延迟操作都会基于同一个旧状态修改,后面的操作会直接覆盖前面的结果。
  2. 依赖不稳定的数组索引:用数组索引定位任务,当其他任务移动后,原索引对应的任务可能已经变化,导致操作目标错误。
  3. 非函数式状态更新:直接使用当前todos状态进行更新,没有利用React的函数式更新获取最新状态。
修复方案

步骤1:为任务添加唯一ID

为每个任务添加唯一标识,避免依赖不稳定的数组索引。修改初始数据和新增任务逻辑:

Body组件修改:

import React, { useEffect, useState } from 'react'
import TodoContainer from './TodoContainer'

export default function Body() {
const [todos, setTodos] = useState([]);

const handleDeleteTask = (taskId, section) => {
    setTodos(prevTodos => {
        const updatedTodos = { ...prevTodos };
        updatedTodos[section] = prevTodos[section].filter(task => task.id !== taskId);
        return updatedTodos;
    });
};

const handleAddTask = (newTask, section) => {
    setTodos(prevTodos => {
        const newTaskWithId = { ...newTask, id: Date.now() };
        const updatedTodos = { ...prevTodos };
        updatedTodos[section] = [newTaskWithId, ...updatedTodos[section]];
        return updatedTodos;
    });
};

useEffect(() => {
    const hasInitialDataLS = localStorage.getItem("hasInitialDataLS");

    if (!hasInitialDataLS) {
        const preSavedData = {
            todo: [
                { id: 1, title: 'Start with meditation, exercise & breakfast for a productive day', check: 0 },
                { id: 2, title: 'Read to learn something new every day', check: 0 },
                { id: 3, title: 'Learn something fresh & relevant', check: 0 }
            ],
            doing: [
                { id: 4, title: 'Engage & question in meetings', check: 0 },
                { id: 5, title: 'Use time-blocking for effective days', check: 0 }
            ],
            done: [
                { id: 6, title: 'Use time-blocking for effective days', check: 1 },
                { id: 7, title: 'Congratulate yourself for incorporating healthier habits into your lifestyle, like regular exercise or mindful eating', check: 1 }
            ]
        };
        localStorage.setItem("todos", JSON.stringify(preSavedData));
        localStorage.setItem("hasInitialDataLS", true);
        setTodos(preSavedData);
    }
    if (hasInitialDataLS) {
        const storeTodos = localStorage.getItem("todos");
        if (storeTodos) {
            setTodos(JSON.parse(storeTodos));
        }
    }
}, []);

useEffect(() => {
    localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]);

return (
    <div className="body">
        <TodoContainer
            className="todo"
            title="Todo"
            requirementTasks={todos.todo}
            onDeleteTask={(taskId) => handleDeleteTask(taskId, 'todo')}
            setTodos={setTodos}
            todos={todos}
            onAddTask={(newTask) => handleAddTask(newTask, 'todo')}
        />
        <TodoContainer
            className="doing"
            title="Doing 💪"
            requirementTasks={todos.doing}
            onDeleteTask={(taskId) => handleDeleteTask(taskId, 'doing')}
            setTodos={setTodos}
            todos={todos}
            onAddTask={(newTask) => handleAddTask(newTask, 'doing')}
        />
        <TodoContainer
            className="done"
            title="Done 🎉"
            requirementTasks={todos.done}
            onDeleteTask={(taskId) => handleDeleteTask(taskId, 'done')}
            setTodos={setTodos}
            todos={todos} />
    </div>
)
}

步骤2:修改TodoContainer组件

使用任务id作为key,并传递id给删除函数:

import React, { useState, useEffect, useRef } from 'react'
import Task from './Task'
import Button from './Button';
import { useDrop } from 'react-dnd';
import { ItemTypes } from './constants';

export default function TodoContainer({
className,
title,
requirementTasks,
onDeleteTask,
setTodos,
todos,
onAddTask
}) {
const [isDraggingOver, setIsDraggingOver] = useState(false);
const ref = useRef();

const [{ isOver }, drop] = useDrop({
    accept: ItemTypes.TASK,
    drop: (item) => {
        const { taskId, section: originalSection } = item;
        const newSection = className;
        if (originalSection !== newSection) {
            setTodos((prevTodos) => {
                const taskToMove = prevTodos[originalSection].find(task => task.id === taskId);
                if (!taskToMove) return prevTodos;
                
                const updatedOriginalTasks = prevTodos[originalSection].filter(task => task.id !== taskId);
                const updatedNewTasks = [
                    { ...taskToMove, check: className === 'done' ? 1 : 0 },
                    ...prevTodos[newSection]
                ];

                return ({
                    ...prevTodos,
                    [originalSection]: updatedOriginalTasks,
                    [newSection]: updatedNewTasks,
                });
            });
        }
    },
    collect: (monitor) => ({
        isOver: !!monitor.isOver(),
    }),
});

useEffect(() => {
    drop(ref);

    const handleDragOver = (event) => {
        event.preventDefault();
        setIsDraggingOver(true);
    };

    const handleDragLeave = () => {
        setIsDraggingOver(false);
    };

    ref.current.addEventListener("dragover", handleDragOver);
    ref.current.addEventListener("dragleave", handleDragLeave);

    return () => {
        ref.current.removeEventListener("dragover", handleDragOver);
        ref.current.removeEventListener("dragleave", handleDragLeave);
    };
}, [drop]);

const tasks = requirementTasks || [];

return (
    <div className={`${className} todo-container  ${isDraggingOver ? "dragging-over" : ""}`} ref={ref}>
        <div className="todo-header">
            <h3>{title}</h3>
            <small>{tasks.length} Tasks</small>
        </div>

        {tasks.map((task) => (
            <div className="tasks" key={task.id}>
                <Task
                    context={task.title}
                    check={task.check}
                    handleDeleteTask={() => onDeleteTask(task.id)}
                    taskId={task.id}
                    setTodos={setTodos}
                    todos={todos}
                    requirementTasks={requirementTasks}
                    onAddTask={onAddTask}
                    section={className} />
            </div>
        ))}
        {className === 'done' ? '' : <Button
            type='add'
            onClickFun={() => { onAddTask({ title: '', check: 0 }); }} />}
    </div>
)
}

步骤3:修复Task组件的handleCheckboxChange

使用函数式状态更新,基于最新状态操作,并用id定位任务:

import React, { useState, useRef, useEffect } from 'react'
import { useDrag } from 'react-dnd';
import { ItemTypes } from './constants';
import Button from './Button';

export default function Task({ context, check, handleDeleteTask, taskId, setTodos, todos, 
requirementTasks, section }) {
const [, drag] = useDrag({
    type: ItemTypes.TASK,
    item: { taskId, section },
});
const [isEditing, setIsEditing] = useState(false)
const [text, setText] = useState(context)
const textareaRef = useRef(null)
const [isChecked, setIsChecked] = useState(check);

const handleSpanClick = () => {
    setIsEditing(true)
}

const handleTextChange = (event) => {
    setText(event.target.value)
    setTodos(prevTodos => {
        const updatedTasks = prevTodos[section].map(task => 
            task.id === taskId ? { ...task, title: event.target.value } : task
        );
        return {
            ...prevTodos,
            [section]: updatedTasks,
        };
    });
}

const handleBlur = () => {
    setIsEditing(false)
}

const handleCheckboxChange = () => {
    const newCheckedState = !isChecked;
    setIsChecked(newCheckedState);

    const targetSection = newCheckedState ? "done" : "todo";
    const currentSection = section;

    setTimeout(() => {
        setTodos(prevTodos => {
            const taskToMove = prevTodos[currentSection].find(task => task.id === taskId);
            if (!taskToMove) return prevTodos;

            const updatedCurrentSection = prevTodos[currentSection].filter(task => task.id !== taskId);
            const updatedTargetSection = [
                { ...taskToMove, check: newCheckedState ? 1 : 0 },
                ...prevTodos[targetSection]
            ];

            return {
                ...prevTodos,
                [currentSection]: updatedCurrentSection,
                [targetSection]: updatedTargetSection
            };
        });
    }, 3000);
};


useEffect(() => {
    if (isEditing && textareaRef.current) {
        const textarea = textareaRef.current
        const textLength = textarea.value.length
        textarea.setSelectionRange(textLength, textLength)
        textarea.focus()
    }
}, [isEditing])

return (
    <div className={`task ${isEditing ? '' : 'dis-flex'}`} ref={drag}>
        <label className="checkbox-container">
            <input
                type="checkbox"
                checked={isChecked}
                onChange={handleCheckboxChange}
            />
            <span className={`checkmark ${isChecked ? 'checked' : ''}`}></span>
        </label>
        {isEditing ? (
            <textarea
                ref={textareaRef}
                value={text}
                onChange={handleTextChange}
                onBlur={handleBlur}
                className="task-textarea"
            />
        ) : (
            <span onClick={handleSpanClick} className={`${isChecked ? 'text-decoration' : ''}`}>{text ? text : (<input placeholder="new Task" className='new-task'></input>)}</span>
        )}
        <Button
            type='delete'
            onClickFun={handleDeleteTask}
        />
    </div>
)
}
修复说明
  1. 唯一ID标识:用任务id代替数组索引,确保任务定位不受数组顺序变化影响,从根源避免索引失效问题。
  2. 函数式状态更新:所有状态更新都使用setTodos(prevTodos => {...}),确保每次操作都基于最新的状态,彻底解决闭包捕获旧值导致的覆盖问题。
  3. 独立延迟操作:每个任务的延迟更新都独立处理自身任务,不再依赖触发时的旧状态,多个任务操作互不干扰。

内容的提问来源于stack exchange,提问作者sadegh

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.13 14:17:01