[React] 일정 관리 웹 애플리케이션 만들기 2

KangRok Yoon·2023년 7월 23일
0
post-thumbnail

이제 일정 관리 애플리케이션이 실제로 동작할 수 있도록 기능을 구현해 보자 !!! 🏋🏻

3. 기능 구현하기

3.1 App에서 todos 상태 사용하기

나중에 추가할 일정 항목에 대한 상태들은 모두 App 컴포넌트에서 관리. App에서 useState를 사용하여 todos라는 상태를 정의하고, todos를 TodoList의 props로 전달해 보자!

여기서 Hooks 사용법을 모르겠다면 리액트 Hooks이것을 보고오자!

// App.js
import React, { useState } from 'react';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';
import TodoTemplate from './components/TodoTemplate';

const App =() => {
  const [todos, setTodos] = useState([
    {
      id: 1,
      text: '리액트의 기초 알아보기',
      checked: true,
    },
    {
      id: 2,
      text: '컴포넌트 스타일링해 보기',
      checked: true,
    },
    {
      id: 3,
      text: '일정 관리 앱 만들어 보기',
      checked: false,
    },
  ]);

  return (
    <TodoTemplate>
      <TodoInsert />
      <TodoList todos={todos} />
    </TodoTemplate>
  );
};

export default App;

todos 배열 안에 들어 있는 객체에는 각 항목의 고유 id, 내용, 완료 여부를 알려 주는 값이 포함 되어 있다. 이 배열은 TodoList에 props로 전달된다. TodoList에서 이 값을 받아 온 후 TodoItem으로 변환하여 렌더링하도록 설정해야함.

// TodoList.js
import TodoListItem from './TodoListItem';
import './TodoList.scss';

const TodoList = ({ todos }) => {
    return (
        <div className="TodoList">
            {todos.map(todo => (
                <TodoListItem todo={todo} key={todo.id} />
            ))}
        </div>
    );
};

export default TodoList;

props로 받아 온 배열을 배열 내장 함수 map을 통해 TodoListItem으로 이루어진 배열로 변환하여 렌더링해 주었다. map을 사용하여 컴포넌트로 변환할 때는 key props를 전달해 주어야 한다고 알고 있다.

  • 여기서 사용되는 key 값은 각 항목마다 가지고 있는 고윳값인 id를 넣어 주자!
  • todo 데이터는 통째로 props로 전달해 주자!
  • 여러 종류의 값을 전달해야 하는 경우에는 객체를 통째로 전달하는 편이 나중에 성능 최적화를 할 때 편리하다!

이제 TodoListItem 컴포넌트에서 받아 온 todo 값에 따라 제대로 된 UI를 보여줄 수 있도록 해보자! 조건부 스타일링을 위해 classnames를 사용했다.

// TodoListItem.js
import {
  MdCheckBoxOutlineBlank,
  MdCheckBox,
  MdRemoveCircleOutline,
} from 'react-icons/md';
import cn from 'classnames';
import './TodoListItem.scss';

const TodoListItem = ({ todo }) => {
  const { id, text, checked } = todo;

  return (
    <div className="TodoListItem">
      <div className={cn('checkbox', { checked })}>
        {checked ? <MdCheckBox /> : <MdCheckBoxOutlineBlank />}
        <div className="text">{text}</div>
      </div>
      <div className="remove">
        <MdRemoveCircleOutline />
      </div>
    </div>
  );
};

export default TodoListItem;

이제 TodoList 컴포넌트는 App에서 전달해 준 todos 값에 따라 다른 내용을 제대로 보여준다!

3.2 항목 추가 기능 구현하기

  • 일정 항목을 추가하는 기능을 구현
    이 기능을 구현하려면, TodoInsert 컴포넌에서 input 상태를 관리하고 App 컴포넌트에는 todos 배열에 새로운 객체를 추가하는 함수를 만들어야 함.

3.2.1 TodoInsert value 상태 관리하기

TodoInsert 컴포넌트에서 input에서 입력하는 값을 관리할 수 있도록 useState를 사용하여 value라는 상태를 정의! 추가로 input에 넣어 줄 onChange 함수도 작성해 주어야 한다. 이 과정에서 컴포넌트가 리렌더링될 때마다 함수를 새로 만드는 것이 아니라, 한 번 함수를 만들고 재사용할 수 있도록 useCallback Hook을 사용하자!

// TodoInsert.js
import React, { useState, useCallback } from 'react';
import { MdAdd } from 'react-icons/md';
import './TodoInsert.scss';

const TodoInsert = () => {
    const [value, setValue]= useState('');

    const onChange = useCallback(e => {
        setValue(e.target.value);
    }, []);
  
    return (
        <form className="TodoInsert">
            <input
             placeholder="할 일을 입력하세요"
             value={value}
             onChange={onChange}    
            />
            <button type="submit">
                <MdAdd />
            </button>
        </form>
    )
}

export default TodoInsert;

사실 input은 value 값과 onChange를 설정하지 않더라도 입력할 수 있다. 그저 리액트 컴포넌트 쪽에서 해당 input에 무엇이 입력되어 있는지 추적하지 않을 뿐이다.

3.2.2 todos 배열에 새 객체 추가하기

이번에는 App 컴포넌트에서 todos 배열에 새 객체를 추가하는 onInsert 함수를 만들어 보자! 이 함수에서는 새로운 객체를 만들 때마다 id값에 1씩 더해 주어야 한다. id값은 useRef를 사용하여 관리하자.

여가서 useState가 아닌 useRef를 사용하여 컴포넌트에서 사용할 변수는 만드는 이유는 무엇일까???
id 값은 렌더링되는 정보가 아니기 때문이다. 예를 들어 이 값은 화면에 보이지 않고, 이 값이 바뀐다고 해서 컴포넌트가 리렌더링 될 필요도 없다. 단순히 새로운 항목을 만들 때 참조되는 값일 뿐

또한, onInsert 함수는 컴포넌트의 성능을 아낄 수 있도록 useCallback으로 감싸 주자. props로 전달해야 할 함수를 만들 때는 useCallback을 사용하여 감싸는 것을 습관화하자!

// App.js
import React, { useState, useRef, useCallback } from 'react';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';
import TodoTemplate from './components/TodoTemplate';

const App =() => {
  const [todos, setTodos] = useState([
    {
      id: 1,
      text: '리액트의 기초 알아보기',
      checked: true,
    },
    {
      id: 2,
      text: '컴포넌트 스타일링해 보기',
      checked: true,
    },
    {
      id: 3,
      text: '일정 관리 앱 만들어 보기',
      checked: false,
    },
  ]);
  
  // 고윳값으로 사용될 id
  // ref를 사용하여 변수 담기

  const nextId = useRef(4);

  const onInsert = useCallback(
    text =>{
      const todo = {
        id: nextId.current,
        text,
        checked: false,
      };
      setTodos(todos.concat(todo));
      nextId.current += 1;
    },
    [todos],
  );

  return (
    <TodoTemplate>
      <TodoInsert onInsert={onInsert} />
      <TodoList todos={todos} />
    </TodoTemplate>
  );
};

export default App;

3.2.3 TodoInsert에서 onSubmit 이벤트 설정하기

지금부터는 버튼을 클릭하면 발생할 이벤트를 설정해 보자. 방금 App에서 TodoInsert에 넣어 준 onInsert 함수에 현재 useState를 통해 관리하고 있는 value 값을 파라미터로 넣어서 호출한다.

// TodoInsert.js
import React, { useState, useCallback } from 'react';
import { MdAdd } from 'react-icons/md';
import './TodoInsert.scss';

const TodoInsert = ({ onInsert }) => {
    const [value, setValue]= useState('');

    const onChange = useCallback(e => {
        setValue(e.target.value);
    }, []);

    const onSubmit = useCallback(
        e => {
            onInsert(value);
            setValue(''); // value 값 초기화 
          
          	// submit 이벤트는 브라우저에서 새로고침을 발생시킨다.
          	// 이를 방지하기 위해 이 함수를 호출합니다.
            e.preventDefault();
        },
        [onInsert, value],
    );

    return (
        <form className="TodoInsert" onSubmit={onSubmit} >
            <input
             placeholder="할 일을 입력하세요"
             value={value}
             onChange={onChange}    
            />
            <button type="submit">
                <MdAdd />
            </button>
        </form>
    )
}

export default TodoInsert;

onSubmit이라는 함수를 만들고, 이를 form의 onSubmit으로 설정했다. 이 함수가 호출되면 props로 받아 온 onInsert 함수에 현재 value 값을 파라미터로 넣어서 호출하고, 현재 value 값을 초기화한다.

추가로 onSubmit 이벤트는 브라우저를 새로고침시킨다. 이때 e.preventDefault() 함수를 호출하면 새로고침을 방지할 수 있다. 물론 다음과 같이 onSubmit 대신에 버튼의 onClick 이벤트로 충분히 처리할 수 있다.

const onClick = useCallback(
  () => {
    onInsert(value);
    setValue(''); // value 값 초기화
  },
  [onInsert, value],
);

return (
  <form claaName="TodoInsert">
  	<input
             placeholder="할 일을 입력하세요"
             value={value}
             onChange={onChange}    
            />
            <button onClick={onClick}>
                <MdAdd />
            </button>
        </form>
    )
}

export default TodoInsert;

이렇게 클릭 이벤트만으로도 할 수 있는데 굳이 form과 onSubmit 이벤트를 사용하는 이유는 무엇일까??

  • onSubmit 이벤트의 경우 input에서 Enter를 눌렀을 때도 발생하기 때문이다. 반면 버튼에서 onClick만 사용했다면, input에서 onKeyPress 이벤트를 통해 Enter를 감지하는 로직을 따로 작성해야함.

  • 새로운 일정이 잘 추가된다!!!

3.3 지우기 기능 구현하기

이번에는 지우기 기능을 구현해 보자! 리액트 컴포넌트에서 배열의 불변성을 지키면서 배열 원소를 제거해야 할 경우, 배열 내장 함수인 filter를 사용하면 매우 간편하다.

3.3.1 배열 내장 함수 filter

filter 함수는 기존의 배열은 그대로 둔 상태에서 특정 조건을 만족하는 원소들만 따로 추출하여 새로운 배열을 만들어 준다.

// filter 사용 예제
const array = [1,2,3,4,5,6,7,8,9,10];
const biggerThanFive = array.filter(number => number > 5);
//결과 : [6,7,8,9,10]

filter 함수에는 조건을 확인해 주는 함수를 파라미터로 넣어 주어야 한다. 파라미터로 넣는 함수는 true 혹은 false 값을 반환해야 하며, 여기서 true를 반환하는 경우만 새로운 배열에 포함된다.

3.3.2 todos 배열에서 id로 항목 지우기

filter 함수를 사용하여 onRemove 함수를 작성해 보자! App 컴포넌트에 id를 파라미터로 받아 와서 같은 id를 가진 항목을 todos 배열에서 지우는 함수이다. 이 함수를 만들고 나서 TodoList의 props로 설정해 주자!

import { useCallback, useRef, useState } from 'react';
import TodoTemplate from './components/TodoTemplate';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';
 
const App = () => {
  (...)

  const onRemove = useCallback(
    id => {
      setTodos(todos.filter(todo => todo.id !== id));
    },
    [todos],
  );

  return (
    <TodoTemplate>
      <TodoInsert onInsert={onInsert}/>
      <TodoList todos={todos} onRemove={onRemove}/>
    </TodoTemplate>
  );
};
 
export default App;

3.3.3 TodoListItem에서 삭제 함수 호출하기

TodoListItem에서 방금 만든 onRemove 함수를 사용하려면 우선 TodoList 컴포넌트를 거쳐야 한다. 다음과 같이 props로 받아 온 onRemove 함수를 TodoListItem에 그래도 전달한다.

// TodoList.js
import  React from 'react';
import TodoListItem from './TodoListItem';
import './TodoList.scss';

const TodoList = ({ todos, onRemove }) => {
    return (
        <div className="TodoList">
            {todos.map(todo => (
                <TodoListItem todo={todo} key={todo.id} onRemove={onRemove} />
            ))}
        </div>
    );
};

export default TodoList;

이제 삭제 버튼을 누르면 TodoListItem에서 onRemove 함수에 현재 자신이 가진 id를 넣어서 삭제 함수를 호출하도록 설정해 보자!

// TodoListItem.js
import {
  MdCheckBoxOutlineBlank,
  MdCheckBox,
  MdRemoveCircleOutline,
} from 'react-icons/md';
import cn from 'classnames';
import './TodoListItem.scss';

const TodoListItem = ({ todo, onRemove }) => {
  const { id, text, checked } = todo;

  return (
    <div className="TodoListItem">
      <div className={cn('checkbox', { checked })}>
        {checked ? <MdCheckBox /> : <MdCheckBoxOutlineBlank />}
        <div className="text">{text}</div>
      </div>
      <div className="remove" onClick={() => onRemove(id)}>
        <MdRemoveCircleOutline />
      </div>
    </div>
  );
};

export default TodoListItem;

브라우저를 열어 일정 항목의 우측에 나타나는 빨간색 아이콘 버튼을 눌러 보면 항목이 삭제가 된다!

3.4 수정 기능

수정 기능도 방금 만든 삭제 기능과 꽤 비슷하다! onToggle이라는 함수를 App에 만들고, 해당 함수를 TodoList 컴포넌트에게 props로 넣어 주자! 그다음에는 TodoList를 통해 TodoListItem까지 전달해 주면 된다.

3.4.1 onToggle 구현하기

// App.js
import React, { useState, useRef, useCallback } from 'react';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';
import TodoTemplate from './components/TodoTemplate';

const App =() => {
(...)

  const onToggle = useCallback(
    id => {
      setTodos(
        todos.map(todo =>
          todo.id === id ? { ...todo, checked: !todo.checked } : todo,
        ),
      );
    },
    [todos],
  );

  return (
    <TodoTemplate>
      <TodoInsert onInsert={onInsert} />
      <TodoList todos={todos} onRemove={onRemove} onToggle={onToggle} />
    </TodoTemplate>
  );
};

export default App;

위 코드에서는 배열 내장 함수 map을 사용하여 특정 id를 가지고 있는 객체의 checked 값을 반전 시켜 주었다. 불변성을 유지하면서 특정 배열 원소를 업데이트해야 할 때 이렇게 map을 사용하면 짧을 코드로 쉽게 작성할 수 있다.

여기서 갑자기 왜 map이 사용된 것인지 이해하기 힘들 수도 있다. map 함수는 배열을 전체적으로 새로운 형태로 변환하여 새로운 배열을 생성해야 할 때 사용한다고 배웠다. 지금은 딱 하나의 원소만 수정하는데 왜 map을 사용할까요?????

onToggle 함수를 보면 todo.id === id ? ... : ... 이라는 삼항 연산자가 사용되었다. todo.id와 현재 파라미터로 사용된 id 값이 같을 때는 우리가 정해 준 규칙대로 새로운 객체를 생성하지만, id 값이 다를 때는 변화를 주지 않고 처음 받아 왔던 상태 그대로 반환한다. 그렇기 때문에 map을 사용하여 만든 배열에서 변화가 필요한 원소만 업데이트되고 나머지는 그대로 남아 있게 되는 것이다.

3.4.2 TodoListItem에서 토글 함수 호출하기

이제 App에서 만든 onToggle 함수를 TodoListItem에서도 호출할 수 있도록 TodoList를 거쳐 TodoListItem에게 전달하자.

// TodoList,js
import React from 'react';
import TodoListItem from './TodoListItem';
import './TodoList.scss';

const TodoList = ({ todos, onRemove, onToggle }) => {
    return (
        <div className="TodoList">
            {todos.map(todo => (
                <TodoListItem todo={todo} key={todo.id} onRemove={onRemove} onToggle={onToggle} />
            ))}
        </div>
    );
};

export default TodoList;

이어서 TodoListItem도 수정해 보자. 이전에 onRemove를 사용했던 것과 비슷하게 구현하면 된다.

// TodoListItem.js
import React from 'react';
import {
  MdCheckBoxOutlineBlank,
  MdCheckBox,
  MdRemoveCircleOutline,
} from 'react-icons/md';
import cn from 'classnames';
import './TodoListItem.scss';

const TodoListItem = ({ todo, onRemove, onToggle }) => {
  const { id, text, checked } = todo;

  return (
    <div className="TodoListItem">
      <div className={cn('checkbox', { checked })} onClick={() => onToggle(id)}>
        {checked ? <MdCheckBox /> : <MdCheckBoxOutlineBlank />}
        <div className="text">{text}</div>
      </div>
      <div className="remove" onClick={() => onRemove(id)}>
        <MdRemoveCircleOutline />
      </div>
    </div>
  );
};

export default TodoListItem;

이제 마지막 기능까지 모두 구현이 되었습니다!!! 체크 박스를 눌러보고 삭제도 해보고 일정도 추가해 보세요!!!

참고자료

리액트를 다루는 기술 - 김민준

profile
It's better to be a pirate than join the navy ☠️

2개의 댓글

comment-user-thumbnail
2023년 7월 23일

좋은 글이네요. 공유해주셔서 감사합니다.

1개의 답글