Todo List

React60 min

Prompt

Create a user-friendly web-based to-do list application that enables users to manage their tasks easily. The application should allow users to add new tasks, edit existing ones, delete them, and mark them as complete.

Requirements

  • Users should be able to add tasks by pressing the "Enter" key.
  • Users must have the capability to edit the description of a task at any time.
  • Users should have the option to delete tasks they no longer need.
  • Users must be able to mark a task as completed once it is finished.
  • The interface should resemble what is depicted in the gif below.

Example

Playground

Hint 1

Think about creating a form in React for adding new tasks. How can you set it up so that pressing the 'Enter' key submits the form? Remember to manage the input state to clear it after submission.

Hint 2

When adding a new task, think about how to update your tasks array. You'll likely add a new task object to the array. Consider using the spread operator or array concatenation to include the new task.

Hint 3

To edit a task, you need to update its name in the array. Use the map function to iterate over the tasks. For the task that matches the one being edited, return a new object with the updated name. For all other tasks, return them as is.

Hint 4

For deleting a task, the filter method is useful. This method creates a new array excluding the task that matches the deletion criteria. Use filter to return all tasks except the one that needs to be removed.

Hint 5

To mark a task as completed, use map to iterate over your tasks. When you find the task that was completed, return a new object with its 'completed' property toggled. Otherwise, return the task as is.

Hint 6

For each task, consider how you can toggle between a view mode and an edit mode. You might need state management within the task component to keep track of whether it's currently being edited.

Solution

Explanation

This is one of the most commonly asked interview challenges. It has been asked at companies like Apollo, Codility and Zepto. It looks simple on the surface, but the interviewer is evaluating a lot of things at once: component architecture, state management, form handling, array manipulation and how clean your code is. Let's walk through it step by step.

Now before we start coding, it is very important to thoroughly read the prompt and understand the requirements. We need to support four operations: add a task, edit a task, delete a task and mark a task as completed. We also need to handle the "Enter" key for adding tasks.

Planning Components

Before writing any code, let's think about what components we need and the data shape of each task. This is something you should always do in an interview. Take a minute to plan, it shows the interviewer that you think before you code.

We need: App, AddTaskForm, TasksList, Task and Checkbox. Each task will have this shape:

{
id: 'unique-id',
name: 'Task description',
completed: false,
}

Each task needs a unique id for the key prop, a name for the description and a completed boolean to track if it is done.

Component Architecture

Writing Everything in App.js: Some candidates write all the logic and UI in a single App.js file because they feel creating separate components takes too much time. This makes the code harder to read, harder to debug and shows the interviewer that you do not think in terms of reusability. Always break your UI into separate components early. You might never get time to refactor later.

Step 1: Setting Up App.js with Tasks State

Let's start by setting up App.js with a tasks state and some initial data so we have something to work with.

App.js

import React from 'react';
import './styles.css';
const INITIAL_TASKS = [
{
id: crypto.randomUUID(),
name: 'Hit the gym',
completed: true,
},
{
id: crypto.randomUUID(),
name: 'Walk the dog',
completed: false,
},
{
id: crypto.randomUUID(),
name: 'Go to sleep',
completed: false,
},
];
export default function App() {
const [tasks, setTasks] = React.useState(INITIAL_TASKS);
console.log(tasks);
return (
<div className="wrapper">
<p>Todo List</p>
</div>
);
}

At this point, we are just making sure our state is set up correctly. We use console.log(tasks) to verify in the browser console that our initial tasks are there. We render a simple <p> tag as a placeholder. Always verify your state is working before building UI on top of it.

Step 2: Creating and Rendering AddTaskForm

Now let's create the AddTaskForm component and immediately render it in App.js so we can see the input on screen.

components/AddTaskForm.js

import React, { useState } from 'react';
function AddTaskForm({ onAddTask }) {
const [name, setName] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!name.trim()) return;
onAddTask(name);
setName('');
};
return (
<form className="add-todo-form" onSubmit={handleSubmit}>
<input
placeholder="Add a task"
className="input input-large"
type="text"
autoComplete="off"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</form>
);
}
export default AddTaskForm;

We are using a <form> element with onSubmit instead of listening for the "Enter" key on the input using onKeyDown. This is very important. When you wrap an input in a form, pressing "Enter" automatically triggers the onSubmit event. This is cleaner, requires less code and is better for accessibility.

We call e.preventDefault() inside handleSubmit to prevent the form from reloading the page, which is the default browser behavior when a form is submitted. We trim the input before adding the task using name.trim() to prevent users from adding empty tasks. After adding, we reset the input by calling setName('').

Now let's update App.js to render it and add the handleAddTask handler:

App.js

import React from 'react';
import AddTaskForm from './components/AddTaskForm';
import './styles.css';
const INITIAL_TASKS = [
{
id: crypto.randomUUID(),
name: 'Hit the gym',
completed: true,
},
{
id: crypto.randomUUID(),
name: 'Walk the dog',
completed: false,
},
{
id: crypto.randomUUID(),
name: 'Go to sleep',
completed: false,
},
];
export default function App() {
const [tasks, setTasks] = React.useState(INITIAL_TASKS);
const handleAddTask = (name) => {
setTasks((prev) => [
{ id: crypto.randomUUID(), name, completed: false },
...prev,
]);
};
console.log(tasks);
return (
<div className="wrapper">
<AddTaskForm onAddTask={handleAddTask} />
</div>
);
}

Now open the browser, you should see the input field. Type something, press "Enter" and check the console. You should see the new task added to the array. If it works, we can move on. If not, debug now before building more.

Notice that the name state lives inside AddTaskForm, not in the parent. This is called state colocation. The input value is only needed by this component, so there is no reason to lift it up.

Form Handling Mistakes

Forgetting to Use a Form: A lot of candidates do not use a form. They put an input on the page and then add an onKeyDown event handler to listen for the "Enter" key manually. This works but it is more code, more error-prone and worse for accessibility. Using a <form> with onSubmit is always the better approach.

Not Using event.preventDefault(): When using a form, pressing "Enter" naturally tries to submit and reload the page. Candidates forget to add e.preventDefault() and then wonder why their page is refreshing. This is a very common mistake.

Not Trimming Input: Many candidates forget to trim the input, which means users can add empty tasks by just pressing "Enter" with spaces. Always trim before adding.

Step 3: Rendering the Tasks List

Now that adding works, let's display the tasks on screen. We will create the TasksList component and a simple Task component. We will start with just the view mode for now, no edit mode yet.

components/Task.js (simple version first)

import React from 'react';
function Task({
id,
name,
completed,
onDelete,
onComplete,
}) {
return (
<li className="task">
<span>{name}</span>
<div className="button-wrapper">
<button
type="button"
className="button"
onClick={() => onComplete(id)}
>
{completed ? 'â†Šī¸' : '✅'}
</button>
<button
type="button"
className="button"
onClick={() => onDelete(id)}
>
đŸ—‘ī¸
</button>
</div>
</li>
);
}
export default Task;

components/TasksList.js

import React from 'react';
import Task from './Task';
function TasksList({
tasks,
onEdit,
onDelete,
onComplete,
}) {
if (tasks.length === 0) return null;
return (
<ul className="task-list">
{tasks.map((task) => (
<Task
key={task.id}
id={task.id}
name={task.name}
completed={task.completed}
onEdit={onEdit}
onDelete={onDelete}
onComplete={onComplete}
/>
))}
</ul>
);
}
export default TasksList;

We use a <ul> with <li> elements for the list because it is semantically correct. The key prop uses task.id which is a unique identifier. We return null early if there are no tasks to avoid rendering an empty list.

Rendering Lists

Using Array Index as Key: A lot of candidates write tasks.map((task, index) => <Task key={index} />) instead of using task.id. This might seem like it works, but when tasks are deleted or reordered, React uses the key to decide which components to re-render. If you use the index, React confuses which components correspond to which items, causing visual bugs where the wrong task gets deleted or the wrong checkbox gets toggled. Always use a unique identifier like task.id as the key.

Not Handling the Empty List State: When all tasks are deleted, candidates often leave an empty <ul> on the page or worse, their code breaks because they try to render something from an empty array. Always handle the empty state gracefully. In our solution, TasksList returns null when there are no tasks: if (tasks.length === 0) return null;

Now let's update App.js to render TasksList and add the delete and complete handlers:

App.js

import React from 'react';
import AddTaskForm from './components/AddTaskForm';
import TasksList from './components/TasksList';
import './styles.css';
const INITIAL_TASKS = [
{
id: crypto.randomUUID(),
name: 'Hit the gym',
completed: true,
},
{
id: crypto.randomUUID(),
name: 'Walk the dog',
completed: false,
},
{
id: crypto.randomUUID(),
name: 'Go to sleep',
completed: false,
},
];
export default function App() {
const [tasks, setTasks] = React.useState(INITIAL_TASKS);
const handleAddTask = (name) => {
setTasks((prev) => [
{ id: crypto.randomUUID(), name, completed: false },
...prev,
]);
};
const handleDeleteTask = (id) => {
setTasks((prev) =>
prev.filter((task) => task.id !== id)
);
};
const handleCompleteTask = (id) => {
setTasks((prev) =>
prev.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task
)
);
};
return (
<div className="wrapper">
<AddTaskForm onAddTask={handleAddTask} />
<TasksList
tasks={tasks}
onDelete={handleDeleteTask}
onComplete={handleCompleteTask}
/>
</div>
);
}

Now check the browser again. You should see the initial tasks rendered. Try clicking the delete and complete buttons. Try adding a new task using the input. Everything should be working at this point. If something is broken, debug it now before adding more features.

Interview Tip

Use console.log after every major step. When you add a task, console.log(tasks) to see if the state updated correctly. When you delete a task, check the console again. It is much easier to find bugs when you test step by step than when you write all the code at once and then try to figure out why nothing works.

Let's talk about the array methods used in our handlers because they are critical:

  • Add: We create a new array with the new task at the beginning using [newTask, ...prev]. New tasks appear at the top of the list.
  • Delete: We use .filter() to create a new array that excludes the task with the matching id.
  • Complete: We use .map() to create a new array. For the task that matches the id, we return a new object with the toggled completed boolean using { ...task, completed: !task.completed }. For all other tasks, we return them unchanged.

State Immutability

Mutating State Directly: Some candidates do tasks.push(newTask) to add a task or task.completed = true to toggle completion. This directly mutates the state array or object, which React will not detect. The component will not re-render and the UI will be out of sync with the data. Always create new arrays using .map(), .filter() or the spread operator, and new objects using { ...task, completed: true }.

Step 4: Adding the Checkbox Component

Right now our tasks just show a span and buttons. Let's create a reusable Checkbox component and use it in our Task to properly handle the completed state with a checkbox.

components/Checkbox.js

import React from 'react';
export default function Checkbox({
id,
label,
...delegated
}) {
const generatedId = React.useId();
const appliedId = id || generatedId;
return (
<div className="checkbox-wrapper">
<input
{...delegated}
id={appliedId}
type="checkbox"
className="checkbox"
/>
<label htmlFor={appliedId}>{label}</label>
</div>
);
}

We use React.useId() to generate a unique id and connect the label to the input using htmlFor. This is important for accessibility because clicking the label will toggle the checkbox. The ...delegated spread passes through props like checked and onChange to the underlying input.

Now update the Task component to use Checkbox instead of the plain span:

// In Task.js, replace <span>{name}</span> with:
<Checkbox
label={name}
checked={completed}
onChange={() => onComplete(id)}
/>

Check the browser. You should see checkboxes next to each task. Clicking the checkbox or the label should toggle the completed state.

Checkbox Handling

Candidates often struggle with checkboxes. The most common mistake is using onClick on the checkbox instead of onChange. With checkboxes, you should always use the onChange event handler and the checked attribute to control the checkbox state. Also, always connect the label to the input using htmlFor and id for accessibility.

Step 5: Adding Edit Mode to Task

Now let's add the ability to edit a task. Each task needs to toggle between a view mode and an edit mode. We will add an isEditing state inside the Task component.

components/Task.js (final version)

import React, { useState } from 'react';
import Checkbox from './Checkbox';
function Task({
id,
name,
completed,
onEdit,
onDelete,
onComplete,
}) {
const [isEditing, setIsEditing] = useState(false);
const [newName, setNewName] = useState('');
const handleNewNameSubmit = (e) => {
e.preventDefault();
if (!newName.trim()) return;
onEdit(id, newName);
setNewName('');
setIsEditing(false);
};
const editingTemplate = (
<form
className="new-todo-form"
onSubmit={handleNewNameSubmit}
>
<input
className="input input-small"
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={`New name for ${name}`}
/>
<button
className="button"
type="button"
onClick={() => setIsEditing(false)}
>
âœ–ī¸
</button>
</form>
);
const viewTemplate = (
<>
<Checkbox
label={name}
checked={completed}
onChange={() => onComplete(id)}
/>
<div className="button-wrapper">
<button
type="button"
className="button"
onClick={() => setIsEditing(true)}
>
âœī¸
</button>
<button
type="button"
className="button"
onClick={() => onDelete(id)}
>
đŸ—‘ī¸
</button>
</div>
</>
);
return (
<li className="task">
{isEditing ? editingTemplate : viewTemplate}
</li>
);
}
export default Task;

The isEditing state lives inside Task because only this component needs to know if it is being edited. The parent does not care whether a task is in view or edit mode, it only cares about the data changes.

When isEditing is false, we show the viewTemplate with the checkbox, edit button and delete button. When isEditing is true, we show the editingTemplate with an input and a cancel button. We use a <form> for the edit mode too, so pressing "Enter" submits the new name.

Button Types Inside Forms

Buttons Inside Forms Accidentally Submitting: When you place a button inside a <form>, the button defaults to type="submit". This means clicking a cancel button inside the edit form will trigger the form's onSubmit event, which is not what you want. You must explicitly set type="button" on any button inside a form that should not trigger submission. In our solution, notice how the cancel button in the edit mode has type="button".

Now add the handleEditTask handler in App.js:

const handleEditTask = (id, newName) => {
setTasks((prev) =>
prev.map((task) =>
task.id === id ? { ...task, name: newName } : task
)
);
};

And pass it to TasksList:

<TasksList
tasks={tasks}
onEdit={handleEditTask}
onDelete={handleDeleteTask}
onComplete={handleCompleteTask}
/>

Check the browser. Click the edit button on a task, type a new name, press "Enter". The task name should update. Click the cancel button, it should go back to view mode without changing anything. If all four operations work (add, edit, delete, complete), our core functionality is done.

Final App.js

Here is the complete App.js with all handlers:

App.js

import React from 'react';
import TasksList from './components/TasksList';
import AddTaskForm from './components/AddTaskForm';
import './styles.css';
const INITIAL_TASKS = [
{
id: crypto.randomUUID(),
name: 'Hit the gym',
completed: true,
},
{
id: crypto.randomUUID(),
name: 'Walk the dog',
completed: false,
},
{
id: crypto.randomUUID(),
name: 'Go to sleep',
completed: false,
},
];
export default function App() {
const [tasks, setTasks] = React.useState(INITIAL_TASKS);
const handleAddTask = (name) => {
setTasks((prev) => [
{ id: crypto.randomUUID(), name, completed: false },
...prev,
]);
};
const handleEditTask = (id, newName) => {
setTasks((prev) =>
prev.map((task) =>
task.id === id ? { ...task, name: newName } : task
)
);
};
const handleDeleteTask = (id) => {
setTasks((prev) =>
prev.filter((task) => task.id !== id)
);
};
const handleCompleteTask = (id) => {
setTasks((prev) =>
prev.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task
)
);
};
return (
<div className="wrapper">
<AddTaskForm onAddTask={handleAddTask} />
<TasksList
tasks={tasks}
onEdit={handleEditTask}
onDelete={handleDeleteTask}
onComplete={handleCompleteTask}
/>
</div>
);
}

Upgrading to Context and useReducer

This is something you can bring up with the interviewer at the very beginning of the interview. You can say something like: "This can be solved in two ways. One is using useState and passing handlers as props, and the other is using Context with useReducer which avoids prop drilling. Given the time constraint, I would like to go with useState because it is faster to implement, but I am happy to discuss how we can convert it to Context and useReducer afterwards." This shows the interviewer that you are aware of both approaches and you are making a conscious decision based on the situation.

The problem with the useState approach is that we have to pass handler functions through multiple layers of components (App -> TasksList -> Task). This is called prop drilling. With Context, any component in the tree can access the tasks and dispatch actions without the handlers being passed as props. With useReducer, all our state update logic is centralized in one function (the reducer) instead of being spread across multiple handler functions.

The playground solution uses this approach. Here is the reducer:

reducers/tasksReducer.js

export default function tasksReducer(tasks, action) {
switch (action.type) {
case 'add':
return [
{
id: action.id,
name: action.name,
completed: false,
},
...tasks,
];
case 'edit':
return tasks.map((task) => {
if (task.id === action.id) {
return { ...task, name: action.newName };
}
return task;
});
case 'complete':
return tasks.map((task) => {
if (task.id === action.id) {
return {
...task,
completed: !task.completed,
};
}
return task;
});
case 'delete':
return tasks.filter((task) => task.id !== action.id);
default:
throw Error('Unknown action: ' + action.type);
}
}

The logic is exactly the same as our handler functions, but now it is all in one place. This makes it easier to understand, test and maintain.

useState vs Context + useReducer

If you are able to solve it using useState and passing the handlers as props, you should be good provided your solution is clean and your naming of variables and handlers is understandable. Context and useReducer is the more advanced approach and shows that you think about scalability. In a real application with many nested components, prop drilling becomes a maintenance burden and Context solves that. Use whichever approach you are most comfortable with during the interview.

Performance Optimization with React.memo

Once everything is working, wrapping your components in React.memo is a nice touch. React.memo prevents a component from re-rendering if its props have not changed. For a todo list with many items, this can make a noticeable difference.

export default React.memo(Task);
export default React.memo(TasksList);
export default React.memo(AddTaskForm);

My Interview Experience

I want to share insights from a recent interview. I was tasked with creating a TODO list within an hour. I delivered a solution enabling adding, editing, deleting and marking tasks as complete, leaving no edge cases untouched.

Interestingly, I received vague feedback implying overlooked edge cases. This was not a reflection of the solution's accuracy, but rather a justification to offer me a Senior Software Engineer 1 position instead of the Senior Software Engineer 2 role I applied for.

This experience highlights that while your technical skills and preparation are important, the final outcome often depends on the interviewer's perception and the company's internal needs. But persistent effort does bear fruit. Every interview and feedback is a valuable learning opportunity.

Communicate While You Code

Throughout the interview, talk about the important decisions you are making. For example:

  • I am using a form here because pressing "Enter" will automatically trigger onSubmit, which is cleaner than listening for key events manually
  • I am keeping the isEditing state inside the Task component because only this component needs it
  • I am using map and filter instead of mutating the array directly because React requires immutable state updates

The interviewer loves candidates who can reason about their code out loud.

Interviewer Criteria

HTML/CSS

  • Does my layout accurately match the provided image or design specification?

  • Did I use semantic HTML tags like ul, li, button, form, input, label, etc., appropriately?

JavaScript

  • Do I understand and correctly use e.preventDefault() to manage form submission?

  • Have I effectively handled edge cases, such as trimming input to prevent adding empty to-do items?

  • Do I reset the input field after a to-do item is added?

  • Do I effectively use JavaScript array methods like filter and map?

  • Do I demonstrate knowledge and appropriate use of ES6 features, including let, const, arrow functions, and destructuring?

  • Were conditionals in my code handled in a clean and efficient manner?

React

  • Am I comfortable using React hooks?

  • Have I used the key prop appropriately on all iterated elements, ensuring efficient and correct re-rendering?

  • Am I comfortable and proficient in using function components?

  • Did I colocate state appropriately, such as maintaining input state within the AddTaskForm component?

  • Did I consider memoizing the Task component using React.memo for performance optimization?

  • Were view and edit modes implemented cleanly, with smooth toggling between the two via state management?

Component Architecture

  • Did I create separate components for Task, TasksList, AddTaskForm, and Checkbox?

  • Are the names of my classes, functions, handlers, and components clear and understandable?

  • Is my codebase organized into separate folders for components, context, and reducers, facilitating easier navigation and maintenance?

Time Checkpoints

  • 10:00 AM

    Interview starts đŸ‘Ĩ

  • 10:03 AM

    Prompt given by the interviewer

  • 10:05 AM

    Candidate reads the prompt, asks clarifying questions, and starts coding

  • 10:10 AM

    AddTaskForm component created with form and input

  • 10:20 AM

    Task component created with view and edit modes

  • 10:30 AM

    Add, edit, delete, and complete functionality working

  • 10:40 AM

    Styling completed

  • 10:48 AM

    Edge cases handled (empty input, trimming)

  • 10:53 AM

    React.memo added for performance

  • 10:55 AM

    Discussion with interviewer

  • 11:00 AM

    Interview ends âœ