2
\$\begingroup\$

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

\$\endgroup\$
6
  • 1
    \$\begingroup\$ Quick comment: you switch between class-based components, functional components, and components defined as arrow functions all over the place. Some take props, some take arguments. I would pick a style and stick with it. Maybe two if you want to show function F(props) is an exported component and F = (props) => {} is used inside a file or something. And they should always accept props unlike FilterButton \$\endgroup\$
    – Coupcoup
    Dec 4, 2020 at 18:32
  • \$\begingroup\$ should i switch back to functional components? \$\endgroup\$ Dec 4, 2020 at 19:53
  • 1
    \$\begingroup\$ I think the trend is toward functional components with React but you don't have to. Consistency is more important than which particular you choose which is true for a lot of stylistic choices \$\endgroup\$
    – Coupcoup
    Dec 4, 2020 at 19:54
  • \$\begingroup\$ okay got it, also what do you mean by components defined with arrow functions? which one? and why not destructure right in the argument? of course when i switch back to functional components \$\endgroup\$ Dec 4, 2020 at 19:55
  • \$\begingroup\$ Arrow functions are any defined f = () => {...} vs the standard f() {...}. 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 accepting props 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\$
    – Coupcoup
    Dec 4, 2020 at 20:02

1 Answer 1

0
\$\begingroup\$

The code you've got looks pretty good :), it's very readable and easy to understand what's going on. I'll points out a handful of suggestions, some of which you might you might decide you don't like as much and prefer to ignore.

App.js

To address your concern of passing in props then turning it into state, what you are doing is perfectly fine. However, I might renamed the "tasks" being passed in too "initialTasks", to make it more clear that that's just the starting value.

Try to make your function naming a little more consistent. you have both "addTask()" and "deleteTodo()" - pick either "task" or "todo" and stick with it.

All of those functions revolve around updating the same bit of state. This makes a good candidate for using useReducer() instead of useState(). Here's an example of what that might look like:

function reducer(tasks, action) {
    const updateTask = (id, update) => (
        tasks.map((task) => task.id === id ? update(task) : task)
    );

    switch (action.type) {
        case "TOGGLE_TASK_COMPLETED":
            return updateTask(action.id, (task) => ({...task, completed: !task.completed}));
        
        case "DELETE_TASK":
            return tasks.filter((task) => task.id !== action.id);
        
        case "EDIT_TASK":
            return updateTask(action.id, (task) => ({...task, name: action.newName}));
        
        case "ADD_TASK":
            const newTask = { id: `todo-${nanoid()}`, name: action.name, completed: false };
            return [...tasks, newTask];
        
        default:
            throw new Error();
    }
}

function App({ initialTasks }) {
    const [tasks, dispatch] = useReducer(reducer, initialTasks);
    const modifyTodos = {
        add: (name) => dispatch({ type: "ADD_TASK", name }),
        toggleCompleted: (id) => dispatch({ type: "TOGGLE_TASK_COMPLETED", id }),
        edit: (id, newName) => dispatch({ type: "EDIT_TASK", id, newName }),
        delete: (id) => dispatch({ type: "DELETE_TASK", id }),
    };

    return (
        <div className="todoapp stack-large">
            <h1>TodoMatic</h1>
            <Form addTask={modifyTodos.add} />
            <TaskTable tasks={tasks} modifyTodos={modifyTodos} />
        </div>
    );
}

In the above code example, I also bundled together the task modifier functions into one object to make it easier to pass around. It would be equally valid to just pass the dispatch function down and not make these lightweight wrapper functions, or to keep them all separate as you had it before, if you find it easier to understand when props are more explicit.

Form.js

It's been a common practice among the popular Javascript frameworks (angular, react, or vue) to have one component per file. This has been a useful practice that really helps with code organization. It's akin to having one class per file in java. But with the recent addition of react hooks, which empower functional components, there's some additional power to harness that's unique to React. There's some great potential that we miss out on if we keep trying to structure projects with a single medium-sized component per file. If we instead changed this rule to "one well-defined exported component per file", we are then free to make all sorts of smaller helper components that can aid us in defining the component we wish to export - akin to having multiple helper functions in a single file.

Here's a version of Form.js that I've broken up into lots of little components

/* eslint-disable jsx-a11y/label-has-associated-control */
import PropTypes from "prop-types";
import React, { useState } from "react";

function Form({ addTask }) {
    const [name, setName] = useState("");

    const handleSubmit = e => {
        e.preventDefault();
        if (name === "") return;
        addTask(name);
        setName("");
    };

    return (
        <Container onFormSubmit={handleSubmit}>
            <Heading>What needs to be done?</Heading>
            <TaskBox name={name} setName={setName} />
            <SubmitButton />
        </Container>
    );
}

const Container = ({ children, onFormSubmit }) => (
    <form onSubmit={onFormSubmit}>{children}</form>
);

const Heading = ({ children }) => (
    <h2 className="label-wrapper">
        <label htmlFor="new-todo-input" className="label__lg">
            {children}
        </label>
    </h2>
);

function TaskBox({ name, setName }) {
    const handleChange = (e) => setName(e.target.value);
    return (
        <input
            type="text"
            id="new-todo-input"
            className="input input__lg"
            name="text"
            autoComplete="off"
            value={name}
            onChange={handleChange}
        />
    );
}

const SubmitButton = () => (
    <button type="submit" className="btn btn__primary btn__lg">
        Add
    </button>
)

Form.propTypes = {
    addTask: PropTypes.func
};

export default Form;

Extracting out these helper components makes the main exported component a much higher-level component - it turns this component into an overview of what this module does, then invites the code reader to look into one of the helper components if they want more specific details of how a particular piece functions.

Also notice that I'm only attaching propTypes to the exported Form component - this is the component that needs to be well-defined - this is the external API. Attaching prop-types to each helper component would just get tedious and get in the way.

Todo.js

Here's another example of a file that would do well to be split up a little. Especially with your editTemplate and viewTemplate variables - those are already almost stand-alone component, the only thing they're missing is to be wrapped in a function and receive their props explicitly.

index.css

It looks like you've got a good naming scheme going on in there to prevent class-name clashes, so good job there. Having all of your styles in one file won't scale very well though, so I would recommend creating a stylesheet for each component, and have each component import their own style sheet.

Final Thoughts

While I didn't go into detail about all of your modules, I hope the ones I did talk about can give you some ideas of where you might take your code next. Overall, this is some great code, it's easy to follow and understand. it seems you're really understanding how to code the react way.

\$\endgroup\$
6
  • \$\begingroup\$ thanks for the comments, i find all of them helpful. i changed most of them. i didn't use reducer because it was kind sophisticated for me, i'll consider it when i dig deeper into react, and will get back to this code then. w.r.t. splitting into smaller components - is this a emerging trend? w.r.t. editTemplate and viewTemplate, i tried to split them into components, but how do i deal with state that's common for both? (e.g. setEditing) \$\endgroup\$ Dec 28, 2020 at 16:54
  • 1
    \$\begingroup\$ The well-defined exported component that uses the editTemplate and viewTemplate components can create the state and pass down the state value and setState function the its children that needs it. \$\endgroup\$ Dec 28, 2020 at 17:07
  • 1
    \$\begingroup\$ Smaller functions have always been preferred over larger functions. With the power to define all React components as functions, it's almost a natural thing to sub-divide the logic into smaller functions. Even when using class-based components, I would sometimes make helper-render functions. The particular idea I mentioned above of having one well-defined exported component per file is just something I sometimes use. In larger projects I do things a little different. I haven't seen any emerging trend regarding project structure, so figure out what works for you \$\endgroup\$ Dec 28, 2020 at 17:14
  • \$\begingroup\$ okay, got it. so i separated two components for each edit and view templates. in my Todo component i have editFieldRef and editButtonRefrefs which are passed to each of view and edit components, and initialized there. since i use the refs in useEffect() in Todo component, i get Cannot read property 'focus' of null for the refs. what's wrong here? \$\endgroup\$ Dec 29, 2020 at 17:10
  • 1
    \$\begingroup\$ oops, sorry, i hadn't passed the refs as prop. it works now. \$\endgroup\$ Dec 31, 2020 at 17:38

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.