본문 바로가기
패스트캠퍼스/자습

React로 Todo List 구현하기 ( version 1 , 하나의 컴포넌트에 구현!)

by sunnykim91 2019. 10. 31.

최근 스터디에서 이제 프로젝트를 진행하고자 리액트 투두리스트를 만들기로 하였다.

 

위와 같은 형태의 TodoList 이다.

 

상단에서부터 입력할 수 있는 input이 있고

아래에는 Navigation bar 이 네비게이션은 전체를 볼 수 있는 것,  내가 해야할일만, 완료된것만 나눠서 볼 수 있다.

그 아래에는 가데이터로 들어가 있는 객체배열을 보여준 모습이고, 

왼쪽에 mark all as complete 를 체크하게 되면 전체 체크, 다시 누르면 전체 해제 이다.

오른쪽에 Clear completed 옆에 괄호 숫자는 현재 완료된 해야할일을 보여주며, 이 버튼을 누르면 완료된 해야할일을 지워준다. 

그 오른쪽에 2 items left는 내가 해야할일이다.

 

---> 여기까지가 위 사진에 대한 설명이다.

 

기본적으로 fastcampus에서 todolist를 배울때 템플릿과 css가 있어서 그것을 사용했다. (따로 html이나 css코드를 건드리지는 않았음.)

 

import React, { Component, createRef } from "react";

class TodoClass extends Component {
  state = {
    todos: [
      { id: 1, content: "HTML", completed: false },
      { id: 2, content: "CSS", completed: true },
      { id: 3, content: "Javascript", completed: false }
    ],
    navState: 'All'
  };

  inputRef = createRef();
  checkRef = createRef();

  onChangeInput = e => {
    e.preventDefault();
    this.setState({ inputRef: e.target.value });
  };

  addTodo = e => {
    const content = this.inputRef.current.value.trim();
    if (e.keyCode !== 13) return;
    this.setState(prevState => {
      return {
        todos: [
          ...prevState.todos,
          {
            id: this.generateId(),
            content,
            completed: false
          }
        ]
      };
    });
    this.inputRef.current.value = "";
  };

  generateId = () => {
    const { todos } = this.state;

    return !todos.length ? 1 : Math.max(...todos.map(todo => todo.id)) + 1;
  };

  checkedChange = id => {
    this.setState(prevState => {
      return {
        todos: prevState.todos.map(todo =>
          todo.id === id ? { ...todo, completed: !todo.completed } : todo
        )
      };
    });
  };

  removeTodo = id => {
    const { todos } = this.state;
    this.setState({
      todos: todos.filter(todo => todo.id !== id)
    });
  };

  allComplete = () => {
    this.setState({
      todos: this.state.todos.map(todo => {
        return { ...todo, completed: this.checkRef.current.checked };
      })
    });
  };

  clearComplete = () => {
    this.setState({
      todos: this.state.todos.filter(todo => todo.completed === false)
    });
  };

  completedNumber = () => {
    return this.state.todos.filter(todo => todo.completed === true).length;
  };

  unCompletedNumber = () => {
    return this.state.todos.filter(todo => todo.completed === false).length;
  };

  changeNav = (navId) => {
    // console.log(navId);
    if(navId === 1) {
      this.setState({navState: 'All'});
    } else if(navId === 2) {
      this.setState({navState: 'Active' });
    } else {
      this.setState({navState: 'Completed'});
    }
  };

  //   shouldComponentUpdate(nextProps, nextState) {
  //     console.log(this.state.todos);
  //     console.log(nextState.todos);
  //     if (this.state.todos !== nextState.todos) {
  //       return true;
  //     } else {
  //       return false;
  //     }
  //   }

  render() {
    const { todos, navState } = this.state;
    const navItems = [{ id: 1, navVal: 'All' }, { id: 2, navVal: 'Active' }, { id: 3, navVal: 'Completed'}];

    console.log(todos);
    return (
      <>
        <div className="container">
          <h1 className="title">Todos</h1>
          <div className="ver">1.0</div>

          <input
            className="input-todo"
            placeholder="What needs to be done?"
            ref={this.inputRef}
            onChange={this.onChangeInput}
            onKeyUp={this.addTodo}
            autoFocus
          />
          <ul className="nav">
            {navItems.map((navItem) => {
              return <li key={navItem.id} className={navItem.navVal === this.state.navState ? 'active' : null} onClick={() => this.changeNav(navItem.id)}>{navItem.navVal}</li>
            }
            )}
          </ul>

          <ul className="todos">
            {todos.filter( (todo) => {
              if(navState === 'Active') {return !todo.completed}
              if(navState === 'Completed') {return todo.completed}
              return true;
            })
            .map(todo => {
                return (
                  <li key={todo.id} id={todo.id} className="todo-item">
                    <input
                      className="custom-checkbox"
                      type="checkbox"
                      id={`ck-${todo.id}`}
                      checked={todo.completed}
                      onChange={() => this.checkedChange(todo.id)}
                    />
                    <label htmlFor={`ck-${todo.id}`}>{todo.content}</label>
                    <i
                      className="remove-todo far fa-times-circle"
                      onClick={() => {
                        this.removeTodo(todo.id);
                      }}
                    ></i>
                  </li>
                );
              })}
          </ul>
          <div className="footer">
            <div className="complete-all">
              <input
                className="custom-checkbox"
                type="checkbox"
                id="ck-complete-all"
                ref={this.checkRef}
                onChange={this.allComplete}
              />
              <label htmlFor="ck-complete-all">Mark all as complete</label>
            </div>
            <div className="clear-completed">
              <button className="btn" onClick={this.clearComplete}>
                Clear completed (
                <span className="completed-todos">
                  {this.completedNumber()}
                </span>
                )
              </button>
              <strong className="active-todos">
                {this.unCompletedNumber()}
              </strong>{" "}
              items left
            </div>
          </div>
        </div>
      </>
    );
  }
}

export default TodoClass;

 

먼저 차근차근 구현해야한다. 항상 구현해야하는 순서는 상관없으나 나는 이렇게 구현하였다.

 

1) 제일 기본적인 가데이터들을 화면에 보여주어야한다.

state = {
    todos: [
      { id: 1, content: "HTML", completed: false },
      { id: 2, content: "CSS", completed: true },
      { id: 3, content: "Javascript", completed: false }
    ]
  };

위와 같이 상태값을 배열안에 객체들을 나열하여 저장해준다. 

todos.map(todo => {
  return (
  	<li key={todo.id} id={todo.id} className="todo-item">
  	<input
  		className="custom-checkbox"
  		type="checkbox"
  		id={`ck-${todo.id}`}
  		checked={todo.completed}
  		onChange={() => this.checkedChange(todo.id)}
  	/>
  	<label htmlFor={`ck-${todo.id}`}>{todo.content}</label>
  	<i
    	className="remove-todo far fa-times-circle"
    	onClick={() => {
    		this.removeTodo(todo.id);
    	}}
    ></i>
  </li>
  );
})}

그리고 배열 고차함수인 map을 통해서 <ul>태그 안에 <li>를 하나씩 집어넣어준다. 

react에서는 map을 사용할때 <li>에는 key값을 항상 넣어주어야한다.

각각의 태그에 필요한 이벤트를 연결해주고, 데이터들을 가져온다. 

 

이렇게 완성되면 데이터들이 잘 나오게 된다.

 

2) CRUD 완성하기

2-1) add

일단 데이터를 넣는 작업부터하면

onChangeInput = e => {
    e.preventDefault();
    this.setState({ inputRef: e.target.value });
  };

input이 입력이 있을때마다 state 값을 입력한 value로 변경하는 값을 거치고, 아래 함수가 동작한다.

 

addTodo = e => {
    const content = this.inputRef.current.value.trim();
    if (e.keyCode !== 13) return;
    this.setState(prevState => {
      return {
        todos: [
          ...prevState.todos,
          {
            id: this.generateId(),
            content,
            completed: false
          }
        ]
      };
    });
    this.inputRef.current.value = "";
  };

  generateId = () => {
    const { todos } = this.state;

    return !todos.length ? 1 : Math.max(...todos.map(todo => todo.id)) + 1;
  };

위 2가지 함수를 사용한다. 

여기서 inputRef는 ref라는 react에서 제공하는 DOM에 접근을 할 수 있게하는 것을 사용한 것이다.

content에 입력한 값을 저장하고, 

엔터를 누르지 않으면 (enter의 keycode는 13번이다) 바로 함수를 빠져나가버리게 만들었다.

setState를 사용하는데 이전상태값을 기억하기 위해 콜백함수형태로 사용했다.

return 문은 새로운 객체를 하나 만들어서 이전 todos인 prevTodos와 합치는 것이다. 

이전 todos와 합치기 위해서는 id값과 content값과 completed값을 가져야하는데

content는 위에서 입력한 값을 저장했고, completed값은 true, false값을 갖는데 입력당시에는 완료할 일을 입력할게 아니라 해야할일 이므로 false값을 기본으로 갖는다.

그럼 이제 id가 문제이다. id는 고유값을 가져야하므로 id생성하는 함수를 따로 만들었다.

generateId 함수에서 보면

첫째줄은 자바스크립트 구조화(distructring)문법이다. 

this.state.todos 이런식으로 써야할 것을 저렇게 써주면  그 다음부터 todos로만 사용할 수 있게 만들어주는 편리한 문법이다. 

return 문에서는 일단 todos가 비어있을 수 있으므로 길이를 체크해서 없으면 id값은 1인 것이고 있다면, 있는 todo 들 중에서도 id값들만 map함수를 통해서 가져와 거기서 최대값을 찾아 1을 더한다. 

예를들어 todolist에 id가 2, 4, 5 이런식으로 있다면 5를 가져와서 새로운 todo에 id값으로는 6이 되는 것이다.

 

 

2-2) delete

이제 삭제를 만들어보자.

 

 

removeTodo = id => {
    const { todos } = this.state;
    this.setState({
      todos: todos.filter(todo => todo.id !== id)
    });
  };

삭제를 하기 위해서는 당연히 어떤것을 삭제할지 알아야 하므로 id값으로 삭제를 한다.

인자값으로 id를 받아서 

filter함수를 통해 todos배열에 있는 id값들과 비교하면서 같은 아이는 제외하고 나머지 아이들로 setState를 해주는 방식이다. 

<i
	className="remove-todo far fa-times-circle"
	onClick={() => {
		this.removeTodo(todo.id);
	}}
></i>

이코드에서 보면 onClick이벤트 핸들러 안에 화살표 함수를 사용하여 감싼 다음에 매개변수를 넘겨줄 수 있습니다.

매개변수인 id를 넘겨주기 위해 위처럼 화살표함수를 사용합니다.

 

2-3) complete의 상태값을 바꿔보자. check input을 이용하여

checkedChange = id => {
    this.setState(prevState => {
      return {
        todos: prevState.todos.map(todo =>
          todo.id === id ? { ...todo, completed: !todo.completed } : todo
        )
      };
    });
  };

add와 마찬가지로 이전상태값을 가져와서 활용한다.

또한, remove와 마찬가지로 해당 id값을 알아야 해당 completed값을 바꿀 수 있음로 인자로 id를 받는다.

그래서 id를 먼저 비교하고, 같은 id를 가지고 있는 아이의 completed값을 반대로 바꿔주는 것이다. 

 

 

3) 부가적인 기능 만들기

아래 왼쪽에 다 체크하는 기능을 만들어보자.

allComplete = () => {
    this.setState({
      todos: this.state.todos.map(todo => {
        return { ...todo, completed: this.checkRef.current.checked };
      })
    });
  };

일단 이 체크박스가 체크되어 있는지 안되어있는지 알아야하기 때문에 ref를 통해서 그 값을 알아낸 다음에

그 값으로 todos의 각각의 todo들의 completed값을 그 ref를 통해 알아낸 값으로 변경시켜주는 작업이다.

 

오른쪽 부가기능의 코드는

clearComplete = () => {
    this.setState({
      todos: this.state.todos.filter(todo => todo.completed === false)
    });
  };

  completedNumber = () => {
    return this.state.todos.filter(todo => todo.completed === true).length;
  };

  unCompletedNumber = () => {
    return this.state.todos.filter(todo => todo.completed === false).length;
  };

filter함수를 통해서 해결 가능하다.

 

 

4) navgation 구현하기

 

네비게이션은 일단 전체를 나타내는 all 과 해야할일 active 완료한일 complete로 3개로 나눈다.

먼저 해당 네비게이션을 구현하기 위해서 todos와 같이 객체 배열을 만들었다.

const navItems = [{ id: 1, navVal: 'All' }, { id: 2, navVal: 'Active' }, { id: 3, navVal: 'Completed'}];
<ul className="nav">
	{navItems.map((navItem) => {
		return <li key={navItem.id} className={navItem.navVal === this.state.navState ? 'active' : null} onClick={() => this.changeNav(navItem.id)}>{navItem.navVal}</li>
	}
	)}
</ul>

그리고 위에서 보면 이것을 통해서 className 은 (state에는 이미 navstate가 all로 되어있다.) active가 주어진다면 파란색으로 표시되고, 아니라면 아무것도 없다.

만약 이것들을 클릭하면, onClick이벤트로 네비게이션안의 데이터들을 바꿔야하므로 함수를 호출한다.

  changeNav = (navId) => {
    // console.log(navId);
    if(navId === 1) {
      this.setState({navState: 'All'});
    } else if(navId === 2) {
      this.setState({navState: 'Active' });
    } else {
      this.setState({navState: 'Completed'});
    }
  };
  

받은 id를 가지고 1이면 All, 2이면 Active 외에는 Completed로 바꾼다.

 

이제 네비게이션의 상태를 알고 그 상태를 기준으로 todos를 그릴때 filter함수를 적용하고, map함수를 돌린다.

{todos.filter( (todo) => {
	if(navState === 'Active') {return !todo.completed}
	if(navState === 'Completed') {return todo.completed}
	return true;
}).map(  ...............

만약 Active란 탭이 눌렀다면, navState가 Active가 됬을 것이고, 여기서 return을 하게되면 todo들 중에서도 completed가 되지 않은 것들만 todos로 만들어지고, 여기서 map함수를 돌리게되면 해당 todo들만 나오는 형태이다.

 

** 사실 배열의 고차함수를 겹쳐서 사용하는 방법이 익숙치 않다면, 충분한 연습이 필요해보인다. .. 나도 포함.. ㅎㅎ

 

 

 

번외) 함수형 컴포넌트로 만들기!

자세한 코드설명은 생략하겠습니다. 

import React, { useState, useRef } from "react";

const TodoHooks = () => {
  const [todos, setTodos] = useState(
    [
      { id: 1, content: "HTML", completed: false },
      { id: 2, content: "CSS", completed: true },
      { id: 3, content: "Javascript", completed: false }
    ]
  );
  const [value, setValue] = useState('');
  const [navState, setNavState] = useState('All');
  const navItems = [{ id: 1, navVal: 'All' }, { id: 2, navVal: 'Active' }, { id: 3, navVal: 'Completed' }];
  
  const checkRef = useRef('');

  const onChangeInput = e => {
    e.preventDefault();
    setValue(e.target.value);
  };

  const addTodo = e => {
    const content = value.trim();
    if (e.keyCode !== 13) return;
    setTodos( (prevTodos) => 
      [...prevTodos, {id: generateId(), content, completed: false}]
    );
    setValue('');
  };

  const generateId = () => {
    return !todos.length ? 1 : Math.max(...todos.map(todo => todo.id)) + 1;
  };

  const checkedChange = id => {
    setTodos( (prevTodos) => 
      prevTodos.map(todo => todo.id === id ? {...todo, completed: !todo.completed} : todo)
    )
  };

  const removeTodo = id => {
      setTodos(todos.filter(todo => todo.id !== id));
  };

  const allComplete = () => {
    setTodos(
      todos.map(todo => {
        return { ...todo, completed: checkRef.current.checked };
      })
    )
  };

  const clearComplete = () => {
    setTodos(todos.filter(todo => todo.completed === false));
  };

  const completedNumber = () => {
    return todos.filter(todo => todo.completed === true).length;
  };

  const unCompletedNumber = () => {
    return todos.filter(todo => todo.completed === false).length;
  };

  //   shouldComponentUpdate(nextProps, nextState) {
  //     console.log(this.state.todos);
  //     console.log(nextState.todos);
  //     if (this.state.todos !== nextState.todos) {
  //       return true;
  //     } else {
  //       return false;
  //     }
  //   }

  const changeNav = (navId) => {
    if(navId === 1) {
      setNavState('All');
    } else if(navId === 2) {
      setNavState('Active');
    } else {
      setNavState('Completed');
    }
  }; 

  console.log(todos);

  return (
      <>
        <div className="container">
          <h1 className="title">Todos</h1>
          <div className="ver">1.0</div>

          <input
            className="input-todo"
            placeholder="What needs to be done?"
            onChange={onChangeInput}
            onKeyUp={addTodo}
            autoFocus
          />
          <ul className="nav">
            {navItems.map((navItem) => {
              return <li key={navItem.id} className={navState === navItem.navVal ? 'active':null} onClick={() => changeNav(navItem.id)}>{navItem.navVal}</li>
            })} 
          </ul>

          <ul className="todos">
            {todos.filter((todo) => {
              if(navState === 'Active') {return !todo.completed};
              if(navState === 'Completed') {return todo.completed};
              return true;
            }).map(todo => {
              return (
                <li key={todo.id} id={todo.id} className="todo-item">
                  <input
                    className="custom-checkbox"
                    type="checkbox"
                    id={`ck-${todo.id}`}
                    checked={todo.completed}
                    onChange={() => checkedChange(todo.id)}
                  />
                  <label htmlFor={`ck-${todo.id}`}>{todo.content}</label>
                  <i
                    className="remove-todo far fa-times-circle"
                    onClick={() => {
                      removeTodo(todo.id);
                    }}
                  ></i>
                </li>
              );
            })}
          </ul>
          <div className="footer">
            <div className="complete-all">
              <input
                className="custom-checkbox"
                type="checkbox"
                id="ck-complete-all"
                ref={checkRef}
                onChange={allComplete}
              />
              <label htmlFor="ck-complete-all">Mark all as complete</label>
            </div>
            <div className="clear-completed">
              <button className="btn" onClick={clearComplete}>
                Clear completed (
                <span className="completed-todos">
                  {completedNumber()}
                </span>
                )
              </button>
              <strong className="active-todos">
                {unCompletedNumber()}
              </strong>{" "}
              items left
            </div>
          </div>
        </div>
      </>
  )
}

export default TodoHooks;
반응형