📗목차
npx create-react-app todo-app
위의 명령어를 입력하여 새 프로젝트를 생성한다.
Prettier를 설정하여 코드를 작성할 때 코드 스타일을 깔끔하게 정리하겠다.프로젝트의 최상위 디렉터리에 .prettierrc 파일을 다음과 같이 생성하자.
prettierrc
{ "singleQuote": true, "semi": true, "useTabs": false, "tabWidth": 2, "trailingComma": "all", "printWidth": 80 }
index.css의 코드를 아래와 같이 수정하자.
index.css
body { margin: 0; padding: 0; background: #e9ecef; }
배경은 회색으로 설정.
이제 기존 App 컴포넌트의 내용을 모두 삭제하고 다음과 같이 변경한다.
import React from 'react';
const App = () => {
return <div>Todo App을 만들자!</div>;
};
export default App;
여기까지 작성했을 때의 브라우저에는 이렇게 나타난다.
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 './App.css'; import TodoTemplate from './components/TodoTemplate'; const App = () => { return <TodoTemplate>Todo App을 만들자!</TodoTemplate>; }; export default App;
이 컴포넌트를 작성하는 과정에서 다음과 같이 상단에 import를 넣지 않고 바로 컴포넌트를 사용하려고 하면, 아래 사진처럼 VS Code 에디터에서 자동 완성 기능이 나타날 것이다.
그러나, TodoTemplate.js 컴포넌트가 VS Code에서 다른 탭으로 열려 있지 않으면 자동 완성이 작동하지 않는다.
닫혀 있는 파일에도 자동 완성이 제대로 작동하려면 프로젝트 최상위 디렉터리에 jsconfig.json 파일을 만들어줘야 한다.
jsconfig.json 파일을 만들고 해당 파일을 열어서 Ctrl + Space
를 눌러 보아라.
위와 같은 자동 완성 박스가 나타나면 Enter
를 눌러보면 다음과 같은 코드가 자동 완성 된다.
{
"compilerOptions": {
"target": "es2020"
}
}
이제 스타일을 작성해보록 한다.
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.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;
여기서 처음으로 react-icons의 아이콘을 사용했다.
https://react-icons.github.io/react-icons/icons?name=md 이 페이지에 들어가면 수많은 아이콘과 이름이 보인다.
여기서 사용하고 싶은 아이콘을 고른 다음, import 구문을 다음과 같이 불러온다.
import { 아이콘 이름 } from 'react-icons/md';
이제 이 컴포넌트를 App에서 불러와 렌더링 해보겠다.
import React from 'react';
import TodoInsert from './components/TodoInsert';
import TodoTemplate from './components/TodoTemplate';
const App = () => {
return <TodoTemplate><TodoInsert /></TodoTemplate>
};
export default App;
이제 TodoInsert 컴포넌트의 스타일링을 해보도록 하자.
.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.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;
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;
위의 TodoListItem 컴포넌트와 TodoList 컴포넌트를 App에 렌더링 해보자.
import React from 'react';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';
import TodoTemplate from './components/TodoTemplate';
const App = () => {
return (
<TodoTemplate>
<TodoInsert />
<TodoList />
</TodoTemplate>
);
};
export default App;
이제 컴포넌트에 스타일링을 해보자.
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; } }
TodoListItem 컴포넌트와 TodoList 컴포넌트에 scss 파일을 import하여 주석 처리한 부분을 주석 해제해보자.
이렇게 컴포넌트의 스타일링이 끝이 났다.
다음은 기능 구현을 해보도록 하자.
니증에 추가할 일정 항목에 대한 상태들은 모두 App 컴포넌트에서 관리한다. App에서 useState를 사용하여 todos라는 상태를 정의하고, todos를 TodoList의 props로 전달해보자.
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: true, } ]); return ( <TodoTemplate> <TodoInsert /> <TodoList todos={todos} /> </TodoTemplate> ); }; export default App;
todos 배열은 TodoList에 props로 전달되는데, TodoList에서 이 값을 받아 온 후 TodoItem으로 변환하여 렌더링하도록 설정해야 한다.
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;
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 컴포넌트에서 input에 입력하는 값을 관리할 수 있도록 useState를 사용하여 value라는 상태를 정의하겠다.
그리고 input에 넣을 onChange 함수도 작성하고, 컴포넌트가 리렌더링될 때마다 함수를 새로 만드는 것이 아니라 한번 함수를 만들고 재사용할 수 있도록 useCallback Hook를 사용할 것이다.
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;
value 값과 onChange 함수로 인해 이제 input에 입력하는 값들을 추적할 수 있습니다.
이번에는 App 컴포넌트에서 todos 배열에 새 객체를 추가하는 onInsert 함수를 만들어 보겠다. 이 함수에서 새로운 객체를 만들 때마다 id 값이 1씩 더해줘야 하는데, id 값은 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; // nextId 1씩 더하기 }, [todos], ); return ( <TodoTemplate> <TodoInsert onInsert={onInsert} /> <TodoList todos={todos} /> </TodoTemplate> ); }; export default App;
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('');
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 이벤트는 브라우저를 새로고침 시킨다. 이때 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={onClilck}>
<MdAdd />
</button>
</form>
);
};
💭 이렇게 클릭 이벤트만으로도 할 수 있는데 굳이 form과 onSubmit 이벤트를 사용한 이유는 뭘까?
onSubmit 이벤트의 경우 input에서 Enter
를 눌렀을 때도 발생하기 때문이다.
반면, 버튼에서 onClick만 사용했다면, input에서 onKeyPress 이벤트를 통해 Enter
를 감지하는 로직을 따로 작성해야 한다.
코드를 저장하고 브라우저에서 직접 새 일정 항목을 추가해보자. 그럼 아래와 같은 결과가 나타날 것이다.
리액트 컴포넌트에서 배열의 불변성을 지키면서 배열 원소를 제거해야 할 경우, 배열 내장 함수인 filter를 사용하면 매우 간편하다.
filter 함수를 사용하여 onRemove 함수를 작성해보겠다. App 컴포넌트에 id를 파라미터로 받아 와서 같은 id를 가진 항목을 todos 배열에서 지우는 함수이다. 이 함수를 만들고 나서 TodoList의 props로 설정해보자.
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 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;
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.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;
코드를 다 작성했으면 브라우저를 열어 일정 항목의 우측에 나타나는 빨간색 아이콘 버튼을 눌러 보아라. 항목이 삭제된다.
수정 기능도 삭제 기능과 비슷하다. onToggle이라는 함수를 App에 만들고, 해당 함수를 TodoList 컴포넌트에게 props로 넣어 준다. 그러고 나서 TodoList를 통해 TodoListItem까지 전달해주면 된다.
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이 사용된 것인지 알아보도록 하자.
map 함수는 배열을 전체적으로 새로운 형태로 변환하여 새로운 배열을 생성해야 할 때 사용한다.
onToggle 함수를 보면 todo.id === id ? ··· : ···
이라는 삼항 연산자가 사용되었다. todo.id와 현재 파라미터로 사용된 id 값이 같을 때는 우리가 정해 준 규칙대로 새로운 객체를 생성하지만, id 값이 다를 때는 변화를 주지 않고 처음 받아 왔던 상태 그대로 반환한다. 그렇기 때문에 map을 사용하여 만든 배열에서 변화가 필요한 원소만 업데이트 되고 나머지는 그대로 남아있게 되는 것이다.
위에서 onRemove() 함수를 호출했던 것과 같이 작성해보자.
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
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;
참고문헌
김민준,「리액트를 다루는 기술 :실무에서 알아야 할 기술은 따로 있다!」, 길벗, 개정판[실은 2판] 2019 (개정판)