7b. 투두리스트 기능 구현하기
App 에 state 추가하기
App.js
import React, { Component } from 'react';
import CreateForm from './components/CreateForm';
import TodoList from './components/TodoList';
import './App.css';
class App extends Component {
id = 3;
// state 의 초깃값을 설정합니다.
state = {
// 그 초깃값은 배열 형태로 넣어주었고, 내부에 기본 값들을 넣어주었습니다.
todos: [
{
id: 0,
text: '앵귤러 배우고',
checked: true
},
{
id: 1,
text: '리액트 배우고',
checked: false
},
{
id: 2,
text: '뷰 배우자',
checked: false
}
]
};
render() {
return (
<div className="App">
<div className="header">
<h1>오늘 뭐할까?</h1>
</div>
<CreateForm />
<div className="white-box">
<TodoList todos={this.state.todos} />
</div>
</div>
);
}
}
export default App;
초기데이터 지정 후 TodoList 로 이 todos 값을 전달합니다.
자바스크립트 배열의 내장함수 몇가지 배워보기
계속해서 진행하기전에 자바스크립트 배열의 내장함수 몇가지를 배워보겠습니다.
concat
concat 은 배열에 특정 값, 혹은 또다른 배열을 붙여주는 내장함수입니다. 그리고, 기존의 배열은 건드리지 않습니다.
const array = [0, 1, 2];
const after = array.concat(3); // 결과 [0, 1, 2, 3]
const stick = array.concat([4, 5, 6]); // 결과 [0, 1, 2, 4, 5, 6]
map
기존에 있는 배열을 가지고 특정 로직을 통하여 새로운 배열을 만들어 낼 때 사용합니다.
const array = [0, 1, 2, 3, 4, 5];
function square(number) {
return number * number;
}
const squared = array.map(square);
// 결과: [0, 2, 4, 9, 16, 25]
map 에서 사용 할 함수를 따로 선언하지 않고, map 내부에 작성 할 수도 있죠.
const array = [0, 1, 2, 3, 4, 5];
const squared = array.map(n => n * n);
map 함수의 핵심은, 기존의 배열은 건들이지 않는다는 것 입니다.
리액트에서는 이 map 함수가 주로 두가지 용도로 사용됩니다.
(1) 컴포넌트 배열 렌더링
첫번째는 배열의 내용을 렌더링 해야 할 때입니다. 우리가 방금 숫자를 제곱한 것 처럼, 데이터들로 이뤄진 배열을 가지고 JSX 의 배열로 변환 할 수 있습니다.
const ShowNumbers = () => {
const array = [0, 1, 2, 3, 4, 5];
const numberList = array.map(n => <div key={n}>{n}</div>)
return (
<div>{numberList}</div>
)
}
컴포넌트를 배열형태로 렌더링할 때에는, key 값을 사용해야하는데, 이 값은 고유해야합니다. 리액트에서는 배열 형태의 컴포넌트들을 렌더링하고, 내부의 정보가 변경되거나, 새로 추가될 시에 이 key 값을 참고하여 효율적으로 변화를 일으킵니다.
주로 이 key 값은, 고유해야 하기에 데이터베이스에서 전달되는 고유 ID 로 설정됩니다. 만약에 데이터베이스가 따로 없는 경우엔, 데이터를 등록 할 때마다 고유한 ID 값을 직접 집어넣어주어야 하고, 고유한 ID 가 없으면 기본적으로는 그냥 배열의 순서값 (index) 가 key 로 설정됩니다.
만약에 key 를 설정하지 않으면 리액트에서 경고를 띄우게 되는데 이 경고가 보고싶지 않다면 직접 index 값을 key 로 넣어주면 되긴합니다. map 함수에서 두번째 index 값이여서, 이렇게 할 수 있습니다.
const numberList = array.map((n, i) => <div key={i}>{n}</div>)
그런데 결코 좋은 방법은 아니므로 왠만하면 피해야합니다 (경고만 안뜰뿐 key 값을 설정하지 않은 것과 똑같은 성능입니다.)
key 가 없을 때
key 가 있을 때
(2) 불변성 지키면서 데이터 변환
두번째로는, 리액트에서 컴포넌트에서 사용하는 상태를 업데이트 해주려면 기존의 데이터를 건들이지 않고 새로 만들어주는 방식으로 값을 설정해주어야합니다.
따라서, 만약에 배열에 있는 특정 데이터를 바꾸고 싶을땐 이렇게 할 수 있습니다.
const data = [
{
id: 0,
value: true
},
{
id: 1,
value: false
}
];
const nextData = data.map(
o => (o.id === 1)
? { ...o, value: !o.value }
: o
);
이렇게 하면 기존 데이터를 수정하지 않으면서 새 값을 지니고있는 새 데이터를 만들 수 있죠.
불변성을 지키는 이유는, shouldComponentUpdate 로 필요할 때 성능 최적화를 할 수 있게 하기 위함입니다. 이에 관해서는 여기 에서 더욱 자세히 볼 수 있답니다.
filter
filter 함수는 데이터 배열에서 특정 조건을 만족하는 원소들만 골라서 새 배열을 만들어줄 때 사용합니다.
const numbers = [0, 1, 2, 3, 4, 5];
const gtThree = numbers.filter(n => n > 3);
// 결과: [4, 5]
이것을 응용해서, 기존의 데이터는 그대로 두면서 특정 원소를 제거시킨 배열을 새로 만들어 낼 수도 있습니다. 예를들어서 위 배열에서 3을 없애고 싶다면 이렇게 할 수 있죠.
const numbers = [0, 1, 2, 3, 4, 5];
const withoutThree = numbers.filter(n => n !== 3);
// 결과: [0, 1, 2, 4, 5]
이는 불변성을 지키며 데이터를 제거시켜야 할 때 유용하게 사용 할 수 있습니다.
TodoList 에서 배열 map 하기
데이터 배열을 컴포넌트로 map 하여 렌더링 해봅시다!
components/TodoList.js
import React from 'react';
import TodoItem from './TodoItem';
const TodoList = ({ todos }) => {
const todoList = todos.map(todo => (
<TodoItem
id={todo.id}
key={todo.id}
checked={todo.checked}
text={todo.text}
/>
));
// 배열에 key 가 설정되어있다면
// 배열을 그대로 렌더링 할 수도 있습니다 - 리액트 16 기준
return todoList;
};
export default TodoList;
TodoItem 에서 데이터 정보 반영하기
방금 전달한 checked, text 값을 TodoItem 컴포넌트에서 반영합시다.
import React from 'react';
import './TodoItem.css';
const TodoItem = ({ checked, text, id }) => (
<div className={`TodoItem ${checked && 'active'}`}>
<div className="check">✔</div>
<div className="text">{text}</div>
<div className="remove">[지우기]</div>
</div>
);
export default TodoItem;
여기까지 하면 다음과 같이 App 에서의 state 가 TodoItem 까지 전달 될 것입니다.
데이터 추가기능 구현하기
우리가 이전에 배운 concat 함수를 사용해서 배열에 새 데이터를 집어넣고 이를 setState 를 통하여 설정하겠습니다. handleCreate 를 다음과 같이 구현하세요. 구현 이후엔, CreateForm 컴포넌트에 onSubmit 값으로 전달해줍니다.
App.js
import React, { Component } from 'react';
import CreateForm from './components/CreateForm';
import TodoList from './components/TodoList';
import './App.css';
class App extends Component {
id = 3;
// state 의 초깃값을 설정합니다.
state = {
// 그 초깃값은 배열 형태로 넣어주었고, 내부에 기본 값들을 넣어주었습니다.
todos: [
{
id: 0,
text: '앵귤러 배우고',
checked: true
},
{
id: 1,
text: '리액트 배우고',
checked: false
},
{
id: 2,
text: '뷰 배우자',
checked: false
}
]
};
handleCreate = text => {
// 데이터 만들고
const todoData = {
id: this.id++,
text,
checked: false
};
// 데이터를 등록
this.setState({
todos: this.state.todos.concat(todoData)
});
};
render() {
return (
<div className="App">
<div className="header">
<h1>오늘 뭐할까?</h1>
</div>
<CreateForm onSubmit={this.handleCreate} />
<div className="white-box">
<TodoList todos={this.state.todos} />
</div>
</div>
);
}
}
export default App;
그 다음에는, CreateForm 컴포넌트에서 state 에 input 의 상태를 관리하도록 하고, 버튼이 눌리면 props 로 받은 onSubmit 에 현재 input 값을 넣어서 호출해줍니다.
components/CreateForm.js
import React, { Component } from 'react';
import './CreateForm.css';
class CreateForm extends Component {
state = {
input: ''
};
handleChange = e => {
this.setState({
// 앞으로 바뀔 input 의 값은 e.target.value 에 있습니다.
input: e.target.value
});
};
handleSubmit = e => {
// Form Submit 은 페이지를 새로고침을 트리거하는데
// 이를 방지해줍니다.
e.preventDefault();
this.props.onSubmit(this.state.input);
this.setState({ input: '' });
};
render() {
return (
<form className="CreateForm" onSubmit={this.handleSubmit}>
<input placeholder="오늘 뭐하지..?" onChange={this.handleChange} value={this.state.input}/>
<button type="submit">추가</button>
</form>
);
}
}
export default CreateForm;
이제, 새 데이터를 추가 할 수 있게 될 것입니다.
투두 토글하기
각 투두 데이터의 체크 값을 껐다 키는 기능을 구현하겠습니다. 이걸 구현하기 위해서는, map 을 통해서 원하는 값을 바꾼 새 배열을 만든 다음에 이 값을 setState 로 설정하면 됩니다.
handleCreate 를 다음과 같이 구현하세요, 그리고 그 handleCreate 를 TodoList 로 onCheck 라는 props 로 전달하세요.
App.js
import React, { Component } from 'react';
import CreateForm from './components/CreateForm';
import TodoList from './components/TodoList';
import './App.css';
class App extends Component {
id = 3;
// state 의 초깃값을 설정합니다.
state = {
// 그 초깃값은 배열 형태로 넣어주었고, 내부에 기본 값들을 넣어주었습니다.
todos: [
{
id: 0,
text: '앵귤러 배우고',
checked: true
},
{
id: 1,
text: '리액트 배우고',
checked: false
},
{
id: 2,
text: '뷰 배우자',
checked: false
}
]
};
handleCreate = text => {
// 데이터 만들고
const todoData = {
id: this.id++,
text,
checked: false
};
// 데이터를 등록
this.setState({
todos: this.state.todos.concat(todoData)
});
};
handleCheck = id => {
// map 을 통하여 원하는 데이터만 바꿔줍니다.
const nextTodos = this.state.todos.map(todo => {
if (todo.id === id) {
return { ...todo, checked: !todo.checked };
}
return todo;
});
this.setState({
todos: nextTodos
});
};
render() {
return (
<div className="App">
<div className="header">
<h1>오늘 뭐할까?</h1>
</div>
<CreateForm onSubmit={this.handleCreate} />
<div className="white-box">
<TodoList todos={this.state.todos} onCheck={this.handleCheck} />
</div>
</div>
);
}
}
export default App;
TodoList 에서는 방금 전달받은 onCheck 을 TodoItem 으로 그대로 전달하세요.
components/TodoList.js
import React from 'react';
import TodoItem from './TodoItem';
const TodoList = ({ todos, onCheck }) => {
const todoList = todos.map(todo => (
<TodoItem
id={todo.id}
key={todo.id}
checked={todo.checked}
text={todo.text}
onCheck={onCheck}
/>
));
// 배열에 key 가 설정되어있다면
// 배열을 그대로 렌더링 할 수도 있습니다 - 리액트 16 기준
return todoList;
};
export default TodoList;
TodoItem 에서는 최상위 DOM 에 onClick 이벤트로 onCheck 을 넣어줍니다. 이 과정에서 () => onCheck(id)
이런식으로 입력을 하게 되는데요, 이렇게 함으로서, 전달받은 함수에 id 값을 파라미터로 넣어서 호출하게 됩니다. 여기서 그냥 onClick={onCheck(id)
라고 작성하면 안됩니다. 그렇게 하면, 렌더링 될 때마다 이 함수가 호출되면서 리액트 앱이 크래쉬됩니다.
components/TodoItem.js
import React from 'react';
import './TodoItem.css';
const TodoItem = ({ checked, text, id, onCheck }) => (
<div
className={`TodoItem ${checked && 'active'}`}
onClick={() => onCheck(id)}
>
<div className="check">✔</div>
<div className="text">{text}</div>
<div className="remove">[지우기]</div>
</div>
);
export default TodoItem;
각 TodoItem 눌러서 체크가 껐다 켜지는지 확인해보세요~
투두 지우기
마지막으로, 우리가 등록한 투두를 지우는 방법을 알아보겠습니다. 배열에서 특정 데이터를 지울 땐 filter 를 사용해서 지우면 편합니다. handleRemove 를 구현하고, 우리가 방금 체크 기능을 구현한것처럼 TodoItem 쪽 까지 props 를 쭉 전달해주세요.
App.js
import React, { Component } from 'react';
import CreateForm from './components/CreateForm';
import TodoList from './components/TodoList';
import './App.css';
class App extends Component {
id = 3;
// state 의 초깃값을 설정합니다.
state = {
// 그 초깃값은 배열 형태로 넣어주었고, 내부에 기본 값들을 넣어주었습니다.
todos: [
{
id: 0,
text: '앵귤러 배우고',
checked: true
},
{
id: 1,
text: '리액트 배우고',
checked: false
},
{
id: 2,
text: '뷰 배우자',
checked: false
}
]
};
handleCreate = text => {
// 데이터 만들고
const todoData = {
id: this.id++,
text,
checked: false
};
// 데이터를 등록
this.setState({
todos: this.state.todos.concat(todoData)
});
};
handleCheck = id => {
// map 을 통하여 원하는 데이터만 바꿔줍니다.
const nextTodos = this.state.todos.map(todo => {
if (todo.id === id) {
return { ...todo, checked: !todo.checked };
}
return todo;
});
this.setState({
todos: nextTodos
});
};
handleRemove = id => {
// filter 를 통하여 불필요한 데이터는 필터링합니다.
const nextTodos = this.state.todos.filter(todo => todo.id !== id);
this.setState({
todos: nextTodos
});
};
render() {
return (
<div className="App">
<div className="header">
<h1>오늘 뭐할까?</h1>
</div>
<CreateForm onSubmit={this.handleCreate} />
<div className="white-box">
<TodoList
todos={this.state.todos}
onCheck={this.handleCheck}
onRemove={this.handleRemove}
/>
</div>
</div>
);
}
}
export default App;
components/TodoList.js
import React from 'react';
import TodoItem from './TodoItem';
const TodoList = ({ todos, onCheck, onRemove }) => {
const todoList = todos.map(todo => (
<TodoItem
id={todo.id}
key={todo.id}
checked={todo.checked}
text={todo.text}
onCheck={onCheck}
onRemove={onRemove}
/>
));
// 배열에 key 가 설정되어있다면
// 배열을 그대로 렌더링 할 수도 있습니다 - 리액트 16 기준
return todoList;
};
export default TodoList;
이제 지우기가 눌려지면 onRemove 가 호출되게 할 것인데요, 지우기가 눌려졌을 때, 최상위 DOM 에 적용되어있는 onClick 이벤트도 함께 발생하게 됩니다. 때문에, 지우기가 눌렸을때는, 클릭 이벤트가 그 위까지 전달되는것을 방지하도록 e.stopPropagation();
를 호출하겠습니다.
components/TodoItem.js
import React from 'react';
import './TodoItem.css';
const TodoItem = ({ checked, text, id, onCheck, onRemove }) => (
<div
className={`TodoItem ${checked && 'active'}`}
onClick={() => onCheck(id)}
>
<div className="check">✔</div>
<div className="text">{text}</div>
<div
className="remove"
onClick={e => {
e.stopPropagation();
onRemove(id);
}}
>
[지우기]
</div>
</div>
);
export default TodoItem;
투두 리스트를 완성했습니다!
handleToggle 과 handleRemove 의 경우 다음과 같은 형식으로도 작성 할 수 있습니다.
handleCheck = id => {
// 이렇게 작성 할 수도 있음
this.setState(({ todos }) => ({
todos: todos.map(
todo => (todo.id === id ? { ...todo, checked: !todo.checked } : todo)
)
}));
};
handleRemove = id => {
// 이것도 이렇게 할 수 있음..
this.setState(({ todos }) => ({
todos: todos.filter(todo => todo.id !== id)
}));
};
handleCheck 을 이렇게도 작성 할 수 있습니다.
handleCheck = id => {
// map 을 통하여 원하는 데이터만 바꿔줍니다.
const { todos } = this.state; // todos 를 자주 조회할거니까 비구조화 할당으로 레퍼런스 미리 만들고
const nextTodos = todos.slice(); // slice 를 통하여 배열을 복사하고
const index = todos.findIndex(todo => todo.id === id);
const todo = todos[index]; // 우리가 업데이트하고 싶은 todo
// index 번째 아이템을 수정하고
nextTodos[index] = {
...todo,
checked: !todo.checked
};
this.setState({
todos: nextTodos // 이를 state 에 집어넣기
});
};
조금 길긴하죠? 핵심은 기존의 객체나 배열은 건들이지 않고 새로운 배열이나 객체를 만들어서 교체해주는 것 입니다.
이러한 작업이 귀찮을 수도 있긴한데, 당연하게도, 더 편하게 하는 방법들은 우리가 차차 배워나갈 것 입니다. (사실 위 방식들도 익숙해지면 정말 간단한 작업들이긴 합니다.) 그리고, 이렇게 불변성 유지를 해서 얻을 수 있는 이점은, 컴포넌트 업데이트 성능을 더욱 쉽게 최적화 할 수 있다는 점 입니다.