I'm getting started with React, so I took a React tutorial for creating a simple to-do list app.
After finishing it, I read "Thinking in React", and tried to improve what I did during tutorial based on that article.
This is my original code from tutorial: package.json:
{
"name": "todo-initial",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.2.2",
"nanoid": "^3.1.18",
"prop-types": "^15.7.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-scripts": "4.0.1",
"web-vitals": "^0.2.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"babel-eslint": "^10.1.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.21.5",
"eslint-plugin-react-hooks": "^4.0.0"
},
"keywords": [],
"description": ""
}
usePrevious.js:
import { useEffect, useRef } from "react";
export default function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
index.js:
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
const DATA = [
{ id: "todo-0", name: "Eat", completed: true },
{ id: "todo-1", name: "Sleep", completed: false },
{ id: "todo-2", name: "Repeat", completed: false }
];
ReactDOM.render(
<React.StrictMode>
<App tasks={DATA} />
</React.StrictMode>,
document.querySelector("#root")
);
index.css:
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
"Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
/* RESETS */
*,
*::before,
*::after {
box-sizing: border-box;
}
*:focus {
outline: 3px dashed #228bec;
}
html {
font: 62.5% / 1.15 sans-serif;
}
h1,
h2 {
margin-bottom: 0;
}
ul {
list-style: none;
padding: 0;
}
button {
border: none;
margin: 0;
padding: 0;
width: auto;
overflow: visible;
background: transparent;
color: inherit;
font: inherit;
line-height: normal;
-webkit-font-smoothing: inherit;
-moz-osx-font-smoothing: inherit;
-webkit-appearance: none;
}
button::-moz-focus-inner {
border: 0;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
input[type="text"] {
border-radius: 0;
}
body {
width: 100%;
max-width: 68rem;
margin: 0 auto;
font: 1.6rem/1.25 Arial, sans-serif;
background-color: #f5f5f5;
color: #4d4d4d;
}
@media screen and (min-width: 620px) {
body {
font-size: 1.9rem;
line-height: 1.31579;
}
}
/*END RESETS*/
/* GLOBAL STYLES */
.form-group > input[type="text"] {
display: inline-block;
margin-top: 0.4rem;
}
.btn {
padding: 0.8rem 1rem 0.7rem;
border: 0.2rem solid #4d4d4d;
cursor: pointer;
text-transform: capitalize;
}
.btn.toggle-btn {
border-width: 1px;
border-color: #d3d3d3;
}
.btn.toggle-btn[aria-pressed="true"] {
text-decoration: underline;
border-color: #4d4d4d;
}
.btn__danger {
color: #fff;
background-color: #ca3c3c;
border-color: #bd2130;
}
.btn__filter {
border-color: lightgrey;
}
.btn__primary {
color: #fff;
background-color: #000;
}
.btn-group {
display: flex;
justify-content: space-between;
}
.btn-group > * {
flex: 1 1 49%;
}
.btn-group > * + * {
margin-left: 0.8rem;
}
.label-wrapper {
margin: 0;
flex: 0 0 100%;
text-align: center;
}
.visually-hidden {
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px);
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap;
}
[class*="stack"] > * {
margin-top: 0;
margin-bottom: 0;
}
.stack-small > * + * {
margin-top: 1.25rem;
}
.stack-large > * + * {
margin-top: 2.5rem;
}
@media screen and (min-width: 550px) {
.stack-small > * + * {
margin-top: 1.4rem;
}
.stack-large > * + * {
margin-top: 2.8rem;
}
}
.stack-exception {
margin-top: 1.2rem;
}
/* END GLOBAL STYLES */
.todoapp {
background: #fff;
margin: 2rem 0 4rem 0;
padding: 1rem;
position: relative;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
0 2.5rem 5rem 0 rgba(0, 0, 0, 0.1);
}
@media screen and (min-width: 550px) {
.todoapp {
padding: 4rem;
}
}
.todoapp > * {
max-width: 50rem;
margin-left: auto;
margin-right: auto;
}
.todoapp > form {
max-width: 100%;
}
.todoapp > h1 {
display: block;
max-width: 100%;
text-align: center;
margin: 0;
margin-bottom: 1rem;
}
.label__lg {
line-height: 1.01567;
font-weight: 300;
padding: 0.8rem;
margin-bottom: 1rem;
text-align: center;
}
.input__lg {
padding: 2rem;
border: 2px solid #000;
}
.input__lg:focus {
border-color: #4d4d4d;
box-shadow: inset 0 0 0 2px;
}
[class*="__lg"] {
display: inline-block;
width: 100%;
font-size: 1.9rem;
}
[class*="__lg"]:not(:last-child) {
margin-bottom: 1rem;
}
@media screen and (min-width: 620px) {
[class*="__lg"] {
font-size: 2.4rem;
}
}
.filters {
width: 100%;
margin: unset auto;
}
/* Todo item styles */
.todo {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.todo > * {
flex: 0 0 100%;
}
.todo-text {
width: 100%;
min-height: 4.4rem;
padding: 0.4rem 0.8rem;
border: 2px solid #565656;
}
.todo-text:focus {
box-shadow: inset 0 0 0 2px;
}
/* CHECKBOX STYLES */
.c-cb {
box-sizing: border-box;
font-family: Arial, sans-serif;
-webkit-font-smoothing: antialiased;
font-weight: 400;
font-size: 1.6rem;
line-height: 1.25;
display: block;
position: relative;
min-height: 44px;
padding-left: 40px;
clear: left;
}
.c-cb > label::before,
.c-cb > input[type="checkbox"] {
box-sizing: border-box;
top: -2px;
left: -2px;
width: 44px;
height: 44px;
}
.c-cb > input[type="checkbox"] {
-webkit-font-smoothing: antialiased;
cursor: pointer;
position: absolute;
z-index: 1;
margin: 0;
opacity: 0;
}
.c-cb > label {
font-size: inherit;
font-family: inherit;
line-height: inherit;
display: inline-block;
margin-bottom: 0;
padding: 8px 15px 5px;
cursor: pointer;
touch-action: manipulation;
}
.c-cb > label::before {
content: "";
position: absolute;
border: 2px solid currentColor;
background: transparent;
}
.c-cb > input[type="checkbox"]:focus + label::before {
border-width: 4px;
outline: 3px dashed #228bec;
}
.c-cb > label::after {
box-sizing: content-box;
content: "";
position: absolute;
top: 11px;
left: 9px;
width: 18px;
height: 7px;
transform: rotate(-45deg);
border: solid;
border-width: 0 0 5px 5px;
border-top-color: transparent;
opacity: 0;
background: transparent;
}
.c-cb > input[type="checkbox"]:checked + label::after {
opacity: 1;
}
App.js:
import PropTypes from "prop-types";
import React, { useEffect, useRef, useState } from "react";
import { nanoid } from "nanoid";
import usePrevious from "./usePrevious";
import Todo from "./components/Todo";
import Form from "./components/Form";
import FilterButton from "./components/FilterButton";
const FILTER_MAP = {
All: () => true,
Active: (task) => !task.completed,
Completed: (task) => task.completed
};
const FILTER_NAMES = Object.keys(FILTER_MAP);
function App({ tasks }) {
const [currentTasks, setTasks] = useState(tasks);
const [filter, setFilter] = useState("All");
const headingRef = useRef(null);
const taskLength = usePrevious(currentTasks.length);
const toggleTaskCompleted = (id) => {
const updatedTasks = [...currentTasks];
const toggledTask = updatedTasks.find((task) => task.id === id);
toggledTask.completed = !toggledTask.completed;
setTasks(updatedTasks);
};
const deleteTodo = (id) => {
setTasks(currentTasks.filter((task) => task.id !== id));
};
const editTodo = (id, newName) => {
const updatedTasks = [...currentTasks];
const changedTask = updatedTasks.find((task) => task.id === id);
changedTask.name = newName;
setTasks(updatedTasks);
};
const updateFilter = (filterName) => {
setFilter(filterName);
};
const taskList = currentTasks
.filter(FILTER_MAP[filter])
.map((task) => (
<Todo
id={task.id}
name={task.name}
completed={task.completed}
key={task.id}
toggleCompleted={toggleTaskCompleted}
deleteTodo={deleteTodo}
editTodo={editTodo}
/>
));
const filterList = FILTER_NAMES.map((name) => (
<FilterButton
key={name}
text={name}
setFilter={updateFilter}
isPressed={name === filter}
/>
));
const addTask = (name) => {
const newTask = { id: `todo-${nanoid()}`, name, completed: false };
setTasks((storedTasks) => [...storedTasks, newTask]);
};
const tasksNoun = taskList.length === 1 ? "task" : "tasks";
const headingText = `${taskList.length} ${tasksNoun} remaining`;
useEffect(() => {
if (currentTasks.length - taskLength === -1) {
headingRef.current.focus();
}
}, [currentTasks.length, taskLength]);
return (
<div className="todoapp stack-large">
<h1>TodoMatic</h1>
<Form addTask={addTask} />
<div className="filters btn-group stack-exception">
{filterList}
</div>
<h2 id="list-heading" tabIndex="-1" ref={headingRef}>
{headingText}
</h2>
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles */}
<ul
role="list"
className="todo-list stack-large stack-exception"
aria-labelledby="list-heading"
>
{taskList}
</ul>
</div>
);
}
App.propTypes = {
tasks: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
completed: PropTypes.bool
})
).isRequired
};
export default App;
components/FilterButton.js
import PropTypes from "prop-types";
import React from "react";
export default function FilterButton({ text, setFilter, isPressed = false }) {
return (
<button
type="button"
className="btn toggle-btn"
aria-pressed={isPressed}
onClick={() => setFilter(text)}
>
<span className="visually-hidden">Show </span>
<span>{text}</span>
<span className="visually-hidden"> tasks</span>
</button>
);
}
FilterButton.propTypes = {
text: PropTypes.string.isRequired,
setFilter: PropTypes.func.isRequired,
isPressed: PropTypes.bool
};
components/Form.js:
/* eslint-disable jsx-a11y/label-has-associated-control */
import PropTypes from "prop-types";
import React, { useState } from "react";
export default function Form({ addTask }) {
const [name, setName] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (name === "") return;
addTask(name);
setName("");
};
const handleChange = (e) => {
setName(e.target.value);
};
return (
<form onSubmit={handleSubmit}>
<h2 className="label-wrapper">
<label htmlFor="new-todo-input" className="label__lg">
What needs to be done?
</label>
</h2>
<input
type="text"
id="new-todo-input"
className="input input__lg"
name="text"
autoComplete="off"
value={name}
onChange={handleChange}
/>
<button type="submit" className="btn btn__primary btn__lg">
Add
</button>
</form>
);
}
Form.propTypes = {
addTask: PropTypes.func
};
components/Todo.js:
import React, { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import usePrevious from "../usePrevious";
export default function Todo({
name,
id,
toggleCompleted,
deleteTodo,
editTodo,
completed = false
}) {
const [isEditing, setEditing] = useState(false);
const [newName, setNewName] = useState("");
const editFieldRef = useRef(null);
const editButtonRef = useRef(null);
const wasEditing = usePrevious(isEditing);
const handleChange = (e) => {
setNewName(e.target.value);
};
const handleSubmit = (e) => {
e.preventDefault();
if (!newName.trim()) {
return;
}
editTodo(id, newName);
setNewName("");
setEditing(false);
};
const editTemplate = (
<form className="stack-small" onSubmit={handleSubmit}>
<div className="form-group">
<label className="todo-label" htmlFor={id}>
New name for {name}
</label>
<input
id={id}
className="todo-text"
type="text"
name={newName}
onChange={handleChange}
ref={editFieldRef}
/>
</div>
<div className="btn-group">
<button
type="button"
className="btn todo-cancel"
onClick={() => setEditing(false)}
>
Cancel
<span className="visually-hidden">renaming {name}</span>
</button>
<button type="submit" className="btn btn__primary todo-edit">
Save
<span className="visually-hidden">new name for {name}</span>
</button>
</div>
</form>
);
const viewTemplate = (
<li className="todo stack-small">
<div className="c-cb">
<input
id={id}
type="checkbox"
defaultChecked={completed}
onChange={() => toggleCompleted(id)}
/>
<label className="todo-label" htmlFor={id}>
{name}
</label>
</div>
<div className="btn-group">
<button
type="button"
className="btn"
onClick={() => setEditing(true)}
ref={editButtonRef}
>
Edit <span className="visually-hidden">{name}</span>
</button>
<button
type="button"
className="btn btn__danger"
onClick={() => deleteTodo(id)}
>
Delete <span className="visually-hidden">{name}</span>
</button>
</div>
</li>
);
useEffect(() => {
if (!wasEditing && isEditing) {
editFieldRef.current.focus();
} else if (wasEditing && !isEditing) {
editButtonRef.current.focus();
}
}, [wasEditing, isEditing]);
return (
<div className="todo">{isEditing ? editTemplate : viewTemplate}</div>
);
}
Todo.propTypes = {
name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
toggleCompleted: PropTypes.func,
deleteTodo: PropTypes.func,
editTodo: PropTypes.func,
completed: PropTypes.bool
};
And this is the code refactored by me (only updated/added files): App.js
import PropTypes from "prop-types";
import React, { useState } from "react";
import { nanoid } from "nanoid";
import Form from "./components/Form";
import TaskTable from "./components/TaskTable";
function App({ tasks }) {
const [currentTasks, setTasks] = useState(tasks);
const toggleTaskCompleted = (id) => {
setTasks((prevTasks) => {
const toggledTask = prevTasks.find((task) => task.id === id);
toggledTask.completed = !toggledTask.completed;
return [...prevTasks];
});
};
const deleteTodo = (id) => {
setTasks(currentTasks.filter((task) => task.id !== id));
};
const editTodo = (id, newName) => {
setTasks((prevTasks) => {
const toggledTask = prevTasks.find((task) => task.id === id);
toggledTask.name = newName;
return [...prevTasks];
});
};
const addTask = (name) => {
const newTask = { id: `todo-${nanoid()}`, name, completed: false };
setTasks((storedTasks) => [...storedTasks, newTask]);
};
return (
<div className="todoapp stack-large">
<h1>TodoMatic</h1>
<Form addTask={addTask} />
<TaskTable
tasks={currentTasks}
toggleCompleted={toggleTaskCompleted}
editTodo={editTodo}
deleteTodo={deleteTodo}
/>
</div>
);
}
App.propTypes = {
tasks: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
completed: PropTypes.bool
})
).isRequired
};
export default App;
components/Form.js:
/* eslint-disable jsx-a11y/label-has-associated-control */
import PropTypes from "prop-types";
import React from "react";
class Form extends React.Component {
constructor(props) {
super(props);
this.state = {
name: ""
};
}
handleSubmit = (e) => {
e.preventDefault();
const { name } = this.state;
const { addTask } = this.props;
if (name === "") return;
addTask(name);
this.setState({ name: "" });
};
handleChange = (e) => {
this.setState({ name: e.target.value });
};
render() {
const { name } = this.state;
return (
<form onSubmit={this.handleSubmit}>
<h2 className="label-wrapper">
<label htmlFor="new-todo-input" className="label__lg">
What needs to be done?
</label>
</h2>
<input
type="text"
id="new-todo-input"
className="input input__lg"
name="text"
autoComplete="off"
value={name}
onChange={this.handleChange}
/>
<button type="submit" className="btn btn__primary btn__lg">
Add
</button>
</form>
);
}
}
Form.propTypes = {
addTask: PropTypes.func
};
export default Form;
components/FilterBar.js:
import React from "react";
import PropTypes from "prop-types";
import FilterButton from "./FilterButton";
export const FILTER_MAP = {
All: () => true,
Active: (task) => !task.completed,
Completed: (task) => task.completed
};
const FILTER_NAMES = Object.keys(FILTER_MAP);
class FilterBar extends React.Component {
constructor(props) {
super(props);
this.state = {
activeFilter: "All"
};
}
updateFilter = (filterName) => {
this.setState({ activeFilter: filterName });
};
render() {
const { activeFilter } = this.state;
const { updateFilter } = this.props;
const filterList = FILTER_NAMES.map((name) => (
<FilterButton
key={name}
text={name}
setFilter={() => {
this.updateFilter(name);
updateFilter(name);
}}
isPressed={name === activeFilter}
/>
));
return (
<div className="filters btn-group stack-exception">
{filterList}
</div>
);
}
}
FilterBar.propTypes = {
updateFilter: PropTypes.func.isRequired
};
export default FilterBar;
components/TaskList.js:
import PropTypes from "prop-types";
import React, { useRef } from "react";
import Todo from "./Todo";
export default function TaskList({
tasks,
toggleCompleted,
editTodo,
deleteTodo
}) {
const headingRef = useRef(null);
const taskList = tasks.map((task) => (
<Todo
id={task.id}
name={task.name}
completed={task.completed}
key={task.id}
toggleCompleted={toggleCompleted}
editTodo={editTodo}
deleteTodo={(...args) => {
deleteTodo(...args);
headingRef.current.focus();
}}
/>
));
const tasksNoun = taskList.length === 1 ? "task" : "tasks";
const headingText = `${taskList.length} ${tasksNoun} remaining`;
return (
<div>
<h2 id="list-heading" tabIndex="-1" ref={headingRef}>
{headingText}
</h2>
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles */}
<ul
role="list"
className="todo-list stack-large stack-exception"
aria-labelledby="list-heading"
>
{taskList}
</ul>
</div>
);
}
TaskList.propTypes = {
tasks: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
completed: PropTypes.bool
})
).isRequired,
toggleCompleted: PropTypes.func.isRequired,
editTodo: PropTypes.func.isRequired,
deleteTodo: PropTypes.func.isRequired
};
components/TaskTable.js:
import PropTypes from "prop-types";
import React from "react";
import TaskList from "./TaskList";
import FilterBar, { FILTER_MAP } from "./FilterBar";
class TaskTable extends React.Component {
constructor(props) {
super(props);
this.state = {
filter: "All"
};
}
updateFilter = (newFilter) => {
this.setState({ filter: newFilter });
};
render() {
const { filter } = this.state;
const { tasks, toggleCompleted, editTodo, deleteTodo } = this.props;
const filteredTasks = tasks.filter(FILTER_MAP[filter]);
return (
<div>
<FilterBar filter={filter} updateFilter={this.updateFilter} />
<TaskList
tasks={filteredTasks}
filter={filter}
toggleCompleted={toggleCompleted}
editTodo={editTodo}
deleteTodo={deleteTodo}
/>
</div>
);
}
}
TaskTable.propTypes = {
tasks: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
completed: PropTypes.bool
})
).isRequired,
toggleCompleted: PropTypes.func.isRequired,
editTodo: PropTypes.func.isRequired,
deleteTodo: PropTypes.func.isRequired
};
export default TaskTable;
I would like to get feedback on how I improved the code, and whether I can improve it further (i.e. to make it more React-way, etc.).
Also, I have a question, how should I correctly manage the tasks
passed to App
as props
, which then becomes state
in App
?
Thanks in advance
function F(props)
is an exported component andF = (props) => {}
is used inside a file or something. And they should always acceptprops
unlikeFilterButton
\$\endgroup\$f = () => {...}
vs the standardf() {...}
. On second look none of your components specifically use arrow functions but the overall use between which style you use in your code is inconsistent. Again, it's not like any one way is wrong (except for components not acceptingprops
which is required to be a valid component per reactjs.org/docs/components-and-props.html), just it's harder to follow when they change all the time \$\endgroup\$