is there a way to replace an element (that is inside a .map()) with a custom component, when state changes. instead of replacing all elements?

Viewed 32

I'm working on a Todo App, I'd like to replace a particular div element (when a button is clicked) with a form component.

There’s this particular component that renders all my todos. on each todo item it renders, there’s a description and two buttons. one of the buttons is for deleting the particular todo item, the other is for updating it. I don’t have an issue deleting a particular todo. where i have an issue is updating a particular one. When I click the update button, it’s supposed to replace that particular todo item with a custom form component. then I can click update on that form and it’ll update, in theory. Tthe actual problem now is that when I click the update button on a particular todo item, it replaces all the todo items w the custom form component.

const Todos: React.FC<Props> = props => {
const ctx = useContext(TodoContext);

const updateForm = <TodoUpdateForm />;

const editTodo = (value: Todo) => {
ctx?.setClicked(true);

const updatedTodo = ctx?.todos.filter(todo => todo.id === value.id);
console.log(updatedTodo);
};

const completeTodo = (todo: string) => ctx?.completeTodo(todo);
const removeTodo = (todo: string) => ctx?.removeTodo(todo);

return (
<div>
  <AnimatePresence>
    {ctx?.todos.map((todo, index) => {
      return (
        <motion.div
          key={index}
          whileInView={{ x: [-100, 0], opacity: [0, 1] }}
          exit={{ x: [0, 20], opacity: [1, 0] }}
          transition={{
            duration: 1,
            ease: 'easeInOut',
            delayChildren: 0.5,
          }}
          className={`${
            todo.isComplete ? 'todo-row complete' : 'todo-row'
          } app__flex`}
        >
          {ctx.isClicked ? (
            updateForm
          ) : (
            <div key={todo.id} className="todos-item">
              <div
                key={todo.id}
                onClick={() => completeTodo(todo.id)}
                className="app__todo-list-item"
              >
                {todo.text}
              </div>

              <motion.div
                whileInView={{ opacity: [0, 1] }}
                transition={{
                  duration: 2,
                  ease: 'easeInOut',
                  delayChildren: 0.5,
                }}
                className="icons app__flex"
              >
                <RiCloseCircleLine
                  className="delete-icon"
                  onClick={() => removeTodo(todo.id)}
                />
                <TiEdit
                  className="edit-icon"
                  onClick={() => editTodo(todo)}
                />
              </motion.div>
            </div>
          )}
        </motion.div>
      );
    })}
  </AnimatePresence>
</div>
);
};

export default Todos;

the Context Provider

export const TodoContext = React.createContext<TodoContextObj | null>({
todos: [],
edit: { id: '', value: '' },
isClicked: false,
addTodo: (todo: Todo) => {},
removeTodo: (id: string) => {},
completeTodo: (id: string) => {},
updateTodo: (id: string, todo: Todo) => {},
deleteTodos: (key: string) => {},
setClicked: (state: boolean) => {},
});

type Props = { children: React.ReactNode };

const initialValue: InitialState = { id: null, value: '' };

const ContextProvider: React.FC<Props> = props => {
const [todos, setTodos] = useState<Array<Todo>>([]);
const [edit, setEdit] = useState(initialValue);
const [isClicked, setIsClicked] = useState(false);

useEffect(() => {
  const data = localStorage.getItem('todos');

  if (data) setTodos(JSON.parse(data));

  return () => {};
}, []);

const setClicked = (state: boolean) => setIsClicked(state);

const addTodo = (todo: Todo) => {
  // Guard Clause
  if (!todo.text || /^\s*$/.test(todo.text)) return;

  const newTodos = [...todos, todo];

  setTodos(newTodos);
  setIsClicked(false);

localStorage.setItem('todos', JSON.stringify(newTodos));
};

const updateTodo = (id: string, value: Todo) => {
// Guard Clause
if (!value.text || /^\s*$/.test(value.text)) return;

if (edit.id) setEdit({ id: value.id, value: value.text });

localStorage.removeItem('todos');

setTodos(prev => prev.map(item => (item.id === id ? value : item)));
setIsClicked(false);

localStorage.setItem('todos', JSON.stringify(todos));
};

const removeTodo = (id: string) => {
const updatedTodo = [...todos].filter(todo => todo.id !== id);

localStorage.removeItem('todos');
setTodos(updatedTodo);
localStorage.setItem('todos', JSON.stringify(updatedTodo));
};

const completeTodo = (id: string) => {
let updateTodos = todos.map(todo => {
  if (todo.id === id) todo.isComplete = !todo.isComplete;
  return todo;
});

setTodos(updateTodos);
};

const deleteTodos = (key: string) => {
localStorage.removeItem(key);
setTodos([]);
};

const contextValue: TodoContextObj = {
todos,
isClicked,
addTodo,
removeTodo,
completeTodo,
updateTodo,
deleteTodos,
setClicked,
};

return (
<TodoContext.Provider value={contextValue}>
  {props.children}
</TodoContext.Provider>
);
};

export default ContextProvider;
3 Answers

You get this pattern a lot. The simplest solution is just to make a component that represents a single Todo. You can then give that component the editing state.

You are setting a simple bool flag to say if you are in edit mode. This is not enough info for the component to know which todo is in edit mode. You need to change setIsClicked (which i recommend you rename to setEditTodoId or something) to accept todo.id then set this string all the way down. You can type it so null is also allowed.

Then, in each loop, you will check if the current todo.id matches the stored id. If it does, you'd render the form.

So, to solve this issue, I created a class that contained the shape of a single Todo item, and I used that shape as the type of my Todos in my Context.

class Todo {
  id: string;
  text: string;
  isActive: boolean;
  isComplete: boolean;

  constructor(todoText: string) {
    this.id = `${Math.floor(Math.random() * 10000)}`;
    this.text = todoText;
    this.isActive = false;
    this.isComplete = false;
  }
}

export default Todo;

Typing my Context Object

interface TodoContextObj {
  todos: Todo[];
  editTodo: (id: string) => void;
  addTodo: (todo: string) => void;
  removeTodo: (id: string) => void;
  completeTodo: (id: string) => void;
  updateTodo: (id: string, value: string) => void;
  deleteTodos: (key: string) => void;
}

I also added a new editTodo method. When it's being called, it toggles the isActive prop on that particular Todo being clicked. That way, all my Todos don't get replaced with the custom form component when I click on just one Todo.

const editTodo = (id: string) => {
    let updatedTodo = todos.map(todo => {
      if (todo.id === id) todo.isActive = !todo.isActive;
      return todo;
    });

    setTodos(updatedTodo);
  };

Then I modified my updateTodo method. It gets triggered when the update button on the custom form component is clicked.

const updateTodo = (id: string, value: string) => {
    // Guard Clause
    if (!value || /^\s*$/.test(value)) return;

    let updatedTodo = todos.map(todo => {
      if (todo.id === id) {
        todo.text = value;
        todo.isActive = !todo.isActive;
      }
      return todo;
    });

    setTodos(updatedTodo);

    modifyLocalStorage('todos', todos);
  };

Finally, the bit of code responsible for conditionally rendering either the Todo or the Form.

{
  todo.isActive ? (<TodoUpdateForm id={todo.id} />) : 
(<div key={index} className="todos-item">
   <div
     key={todo.id}
     onClick={() => completeTodo(todo.id)}
     className="app__todo-list-item"
     >
     {todo.text}
   </div>

   <motion.div
     whileInView={{ opacity: [0, 1] }}
     transition={{
       duration: 2,
       ease: 'easeInOut',
       delayChildren: 0.5,
     }}
     className="app__todo-icons app__flex"
   >
     <RiCloseCircleLine
       className="delete-icon"
       onClick={() => removeTodo(todo.id)}
     />
     <TiEdit
       className="edit-icon"
       onClick={() => ctx.editTodo(todo.id)}
     />
   </motion.div>
 </div>
 )}
Related