지금까지 배운 것들을 바탕으로 일정 관리 웹 애플리케이션을 만들어 보도록 하겠습니다.
일정 관리 웹 애플리케이션을 만들기 위한 준비작업을 해보겠습니다.
애플리케이션을 위한 프로젝트를 새로 생성해줍니다.
이번 프로젝트에서는 sass를 이용할 예정이며, classnames를 통해 좀 더 편한 스타일링을 할 예정입니다.
또한, react-icons를 통해서 다양한 아이콘도 사용해 볼 예정입니다.
{
"singleQuote": true,
"semi": true,
"useTabs": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 80
}
src와 public 디렉터리가 존재하는 최상위 공간에 다음과 같은 .prettic 파일을 생성하여 코드 스타일을 정리하겠습니다.
body {
margin: 0;
padding: 0;
background: #e9ecef;
}
index.css를 위와 같이 수정합니다. 배경색으로는 회색을 사용했습니다.
const App = () => {
return <div>Todo App을 만들자!</div>
};
export default App;
추후 작성을 위해 App.js를 다음과 같이 수정해주도록 하겠습니다.
이렇게 개발 준비는 모두 마쳤습니다. 이제 일정관리 앱 만들기를 본격적으로 시작하겠습니다.
앞으로 만들 컴포넌트에 대해 먼저 정리하겠습니다.
이렇게 총 4개의 컴포넌트를 만들 예정이고, src폴더에 components라는 디렉터리를 생성하여 저장하겠습니다. 이렇게 따로 components를 디렉터리로 분리하는 이유는 자주 사용되는 관습입니다.
src 디렉터리 안 components 디렉터리에 TodoTemplate.js와 TodoTemplate.scss 파일을 만들어 작성해보겠습니다.
[TodoTemplate.js ]파일에 다음과 같이 작성합니다.
import React from 'react';
import './TodoTemplate.scss';
const TodoTemplate = ({ children }) => {
return (
<div className="TodoTemplate">
<div className="app-title">일정 관리</div>
<div className="content">{children}</div>
</div>
);
};
export default TodoTemplate;
[App.js]에 이 컴포넌트를 불러와 렌더링 하겠습니다.
import TodoTemplate from "./components/TodoTemplate";
const App = () => {
return <TodoTemplate>Todo App을 만들자!</TodoTemplate>
};
export default App;
App.js에 컴포넌트를 import 하는 과정에서 Enter키를 누르면 자동으로 입력이 됩니다. 그러나 이런 경우는TodoTemplate.js 컴포넌트가 다른 탭으로 열려있을 경우만 해당되며, 닫혀있으면 작동하지 않습니다.
이를 해결해주기 위해 jsonconfig.json을 만들어 줍니다.
{
"compilerOptions": {
"target": "es2020"
}
}
최상위 디렉터리에 다음과 같이 jsonconfig.json파일을 만들고 ctrl+space 키를 입력하고 enter을 누르면 다음과 같은 코드가 자동 완성됩니다. 이를 통해 불러오려는 컴포넌트 파일이 열려있지 않아도 자동완성을 사용할 수 있게 되었습니다.
그렇다면 이제 [TodoTemplate.scss]를 작성해보겠습니다.
.TodoTemplate {
width: 512px;
margin-left: auto;
margin-right: auto;
margin-top: 6rem;
border-radius: 4px;
overflow: hidden;
.app-title {
background: #22b8cf;
color: white;
height: 4rem;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.content {
background: white;
}
}
위와 같이 작성하고 나면 화면에 다음과 같이 나타나게 됩니다.
TodoInsert컴포넌트를 만들어 보겠습니다. components디렉터리에 TodoInsert.js와 TodoInsert.scss 파일을 생성하겠습니다.
[TodoInsert.js]에 아래와 같이 작성합니다.
import React from 'react';
import { MdAdd } from 'react-icons/md';
import './TodoInsert.scss';
const TodoInsert = () => {
return (
<form className="TodoInsert">
<input placeholder="할 일을 입력하세요" />
<button type="submit">
<MdAdd />
</button>
</form>
);
};
export default TodoInsert;
TodoInsert.js에서는 react-icons를 사용했습니다. https://react-icons.netlify.com/#/icons/md 페이지에서 아이콘에 대한 정보를 확인할 수 있습니다. 여기서 사용하고 싶은 아이콘을 고르고 import 구문을 통해 불러와서 컴포넌트처럼 사용합니다.
import { 아이콘 이름 } from 'react-icons/md'
이제 [App.js]에서 이 컴포넌트를 불러와 렌더링 하겠습니다.
import TodoTemplate from "./components/TodoTemplate";
import TodoInsert from "./components/TodoInsert";
const App = () => {
return (
<TodoTemplate>
<TodoInsert/>
</TodoTemplate>
)
};
export default App;
이렇게 아이콘과 함께 추가된 모습을 확인할 수 있습니다.
그럼 이제 컴포넌트를 스타일링 해보겠습니다. [TodoInsert.scss]에 아래의 코드를 작성합니다.
.TodoInsert {
display: flex;
background: #495057;
input {
background: none;
outline: none;
border: none;
padding: 0.5rem;
font-size: 1.125rem;
line-height: 1.5;
color: white;
&::placeholder {
color: #dee2e6;
}
flex: 1;
}
button {
background: none;
outline: none;
border: none;
background: #868e96;
color: white;
padding-left: 1rem;
padding-right: 1rem;
font-size: 1.5rem;
display: flex;
align-items: center;
cursor: pointer;
transition: 0.1s background ease-in;
&:hover {
background: #adb5bd;
}
}
}
스타일링을 하고나면 이런 모습이 됩니다. +버튼 위에 마우스를 올리면 배경색도 바뀌게 됩니다.
이제 일정 관리 항목이 보이게 될 TodoListItem과 TodoList만들어 보겠습니다.
두 컴포넌트 다 js파일과 scss파일을 생성합니다.
생성한 [TodoListItem.js]를 아래와 같이 작성합니다.
import React from 'react';
import {
MdCheckBoxOutlineBlank,
MdCheckBox,
MdRemoveCircleOutline,
} from 'react-icons/md';
import './TodoListItem.scss';
const TodoListItem = () => {
return (
<div className="TodoListItem">
<div className="checkbox">
<MdCheckBoxOutlineBlank />
<div className="text">할 일</div>
</div>
<div className="remove">
<MdRemoveCircleOutline />
</div>
</div>
);
};
export default TodoListItem;
다양한 아이콘들을 불러와서 사용했습니다. 아직 MdCheckBox 아이콘 컴포넌트는 사용하지 않은 상태인데, 이 아이콘은 나중에 할 일이 완료되었을 때 체크된 상태를 보여주는 아아콘입니다.
다음으로 [TodoList.js] 파일을 아래와 같이 작성합니다.
import React from 'react';
import TodoListItem from './TodoListItem';
import './TodoList.scss';
const TodoList = () => {
return (
<div className="TodoList">
<TodoListItem />
<TodoListItem />
<TodoListItem />
</div>
);
};
export default TodoList;
스타일링도 진행해보겠습니다.
[TodoList.scss]에 다음과 같이 작성합니다.
.TodoList {
min-height: 320px;
max-height: 513px;
overflow-y: auto;
}
[TodoListItem.scss]에는 다음과 같이 작성합니다.
.TodoListItem {
padding: 1rem;
display: flex;
align-items: center; // 세로 중앙 정렬
&:nth-child(even) {
background: #f8f9fa;
}
.checkbox {
cursor: pointer;
flex: 1; // 차지할 수 있는 영역 모두 차지
display: flex;
align-items: center; // 세로 중앙 정렬
svg {
// 아이콘
font-size: 1.5rem;
}
.text {
margin-left: 0.5rem;
flex: 1; // 차지할 수 있는 영역 모두 차지
}
// 체크되었을 때 보여 줄 스타일
&.checked {
svg {
color: #22b8cf;
}
.text {
color: #adb5bd;
text-decoration: line-through;
}
}
}
.remove {
display: flex;
align-items: center;
font-size: 1.5rem;
color: #ff6b6b;
cursor: pointer;
&:hover {
color: #ff8787;
}
}
// 엘리먼트 사이사이에 테두리를 넣어 줌
& + & {
border-top: 1px solid #dee2e6;
}
}
컴포넌트를 다 작성했으면 [App.js]에서 렌더링 합니다.
import TodoTemplate from "./components/TodoTemplate";
import TodoInsert from "./components/TodoInsert";
import TodoList from "./components/TodoList";
const App = () => {
return (
<TodoTemplate>
<TodoInsert/>
<TodoList />
</TodoTemplate>
)
};
export default App;
이렇게 컴포넌트의 스타일링이 모두 끝났습니다.
이제 앱의 스타일링은 끝났지만, 아직 동작하지는 않습니다. 이제는 동작이 가능하도록 기능을 구현해 보겠습니다.
나중에 추가하게 될 일정 항목에 대한 상태들은 모두 App 컴포넌트에서 관리합니다. App에서 useState를 사용하여 todos라는 상태를 정의하고 todos를 TodoList의 props로 전달해보겠습니다.
[App.js]를 다음과 같이 작성합니다.
import React, { useState } from 'react';
import TodoTemplate from './components/TodoTemplate';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';
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에서 이 값을 받아 온 후 TodoListItem으로 변환하여 렌더링 하도록 설정해야 합니다.
[TodoList.js]를 아래와 같이 수정합니다.
import React from 'react';
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로 받아온 todos 배열을 배열 내장 함수 map을 통해 TodoListItem으로 이루어진 배열로 변환하여 렌더링해 주었습니다. map을 사용하여 컴포넌트로 변환할 때는 key props를 전달해주어야 합니다. 여기서는 각 항목의 고윳값인 id를 사용했습니다. 그리고 여러 종류의 값을 전달해야 하는 경우에는 객체 통째로 전달하는 편이 나중에 성능 최적화를 할 때 편리하기에 todo 데이터 통째로 props에 전달해줍니다.
TodoListItem 컴포넌트에서 받아온 todo값에 따라 제대로 된 UI를 보여 줄 수 있도록 컴포넌트를 수정해 봅시다.
[TodoListItem.js] 를 다음과 같이 작성해줍니다.
import React from 'react';
import {
MdCheckBoxOutlineBlank,
MdCheckBox,
MdRemoveCircleOutline,
} from 'react-icons/md';
import cn from 'classnames';
import './TodoListItem.scss';
const TodoListItem = ({ todo }) => {
const { 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 값에 따라 다른 내용을 보여줍니다.
이번에는 일정 항목을 추가하는 기능을 구현해 보겠습니다. 이 기능을 구현하려면 TodoInsert 컴포넌트에서 인풋 상태를 관리하고 App 컴포넌트에서는 todos 배열에 새로운 객체를 추가하는 함수를 만들어 주어야 합니다.
TodoInsert 컴포넌트에서 인풋에 입력하는 값을 관리할 수 있도록 useState를 사용하여 vlaue라는 상태를 정의하겠습니다.
추가로 인풋에 넣어 줄 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를 설정하지 않더라도 입력할 수 있습니다. 리액트 컴포넌트 쪽에서 해당 인풋에 무엇이 입력되어있는지 추적하지 않을 뿐입니다. 이럴경우 onChange안에서 console.log를 찍어보는 것 대신 리액트 개발자 도구를 통해 state가 잘 업데이트 되고 있는지 확인할 수 있습니다.
App 컴포넌트에서 todos 배열에 새 객체를 추가하는 onInsert 함수를 만들어 보겠습니다. 이 함수에서는 새로운 객체를 만들 때마다 id 값에 1씩 더해 주어야 하는데요. id 값은 useRef를 사용하여 관리하겠습니다.
useState가 아닌 useRef를 사용하는 이유는 id값은 렌더링을 요구하는 정보가 아니기 때문에 값이 바뀐다고 컴포넌트가 리렌더링될 필요가 없기 때문입니다.
또한, onInsert 함수는 컴포넌트의 성능을 아낄 수 있도록 useCallback으로 감싸 주겠습니다. props로 전달해야 할 함수를 만들 때는 useCallback을 사용하여 함수를 감싸주는게 좋습니다.
onInsert 함수를 만든 뒤에는 해당 함수를 TodoInsert 컴포넌트의 props로 설정해 줍시다.
[App.js]를 아래와 같이 수정해봅시다.
import React, { useState, useRef, useCallback } from 'react';
import TodoTemplate from './components/TodoTemplate';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';
const App = () => {
const [todos, setTodos] = useState([
{
id: 1,
text: '리액트의 기초 알아보기',
checked: true,
},
{
id: 2,
text: '컴포넌트 스타일링해 보기',
checked: true,
},
{
id: 3,
text: '일정 관리 앱 만들어 보기',
checked: false,
},
]);
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;
버튼을 클릭하면 발생할 이벤트를 설정해 보겠습니다.
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 값 초기화
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 className="TodoInsert">
<input
placeholder="할 일을 입력하세요"
value={value}
onChange={onChange}
/>
<button onClick={onClick}>
<MdAdd />
</button>
</form>
);
이렇게 클릭 이벤트만으로도 할 수 있는데 form과 siubmit 이벤트를 사용한 이유는 onSubmit의 경우 input에서 enter를 눌렀을 때도 발생하기 때문입니다. onclick을 사용했으면 따로 onKeyPress를 통해 enter를 감지하는 로직을 작성해야 합니다.
일정 항목 추가 기능이 구현되었습니다.
이번에는 지우기 기능을 구현해 보겠습니다. 리액트 컴포넌트에서 배열의 불변성을 지키면서 배열의 원소를 제거하기 위해 배열의 내장 함수인 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를 반환하는 경우에만 새로운 배열에 포함됩니다.
filter를 사용하여 onRemove 함수를 작성해보겠습니다. App 컴포넌트에 id를 파라미터 값으로 받아 와서 같은 id를 가진 항목을 todos 배열에서 지우는 함수 입니다. 이 함수를 만들고 나서 TodoList의 props로 설정해주세요.
[App.js]를 아래와 같이 수정합니다.
import React, { useState, useRef, useCallback } 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;
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;
이제 삭제 버튼을 누르면 TodoLisItem에서 onRemove 함수에 현재 자신이 가진 id를 넣어서 삭제 함수를 호출하도록 설정해봅시다.
[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 }) => {
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;
수정 기능도 방금 만든 삭제 기능과 꽤 비슷합니다. App에 onToggle함수를 만들고, 해당 함수를 TodoList 컴포넌트에 props로 넣어 줍니다. 그 다음에는 TodoList를 통해 TodoListItem까지 전달해 주면 됩니다.
[App.js]을 아래와 같이 수정해봅시다.
import React, { useState, useRef, useCallback } from 'react';
import TodoTemplate from './components/TodoTemplate';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';
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함수를 사용했습니다. 왜 그럴까요?
onToggle 함수를 보면 todo.id === id ? … : …이라는 삼항 연산자가 사용되었습니다.
todo.id와 현재 파라미터로 사용된 id 값이 같을 때는 우리가 정해 준 규칙대로 새로운 객체를 생성하지만, id 값이 다를 때는 변화 없이 처음의 상태로 반환합니다. 그렇기 때문에 map을 사용하여 만든 배열에서 변화가 필요한 원소만 업데이트되고 나머지는 그대로 남아 있게 되는 것입니다.
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.js]도 수정해보겠습니다. onRemove와 비슷하게 수정하면 됩니다.
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;
이제 모든 기능이 구현 완료 되었습니다.
Corner React3
Editor: 니나노
[리액트 스타터 3] 12장. immer를 사용하여 더 쉽게 불변성 유지하기 (0) | 2022.12.29 |
---|---|
[리액트 스타터 3] 11장. 컴포넌트 성능 최적화 (0) | 2022.12.29 |
[리액트 스타터 3] 9장. 컴포넌트 스타일링 (0) | 2022.12.01 |
[리액트 스타터 3] 8장. Hooks (0) | 2022.11.24 |
[리액트 스타터 3] 7장. 컴포넌트의 라이프 사이클 메서드 (0) | 2022.11.17 |