| 장 | 제목 |
|---|---|
| 14장 | 외부 API를 연동하여 뉴스 뷰어 만들기 |
| 전역상태관리 | recoil |
const sampleArticle = {
title: "제목",
description: "내용",
url: "https://google.com",
urlToImage: "https://via.placeholder.com/160",
};
const NewsList = () => {
return (
<NewsListBlock>
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
</NewsListBlock>
);
};NewsList.js 파일에서 sampleArticle이라는 객체를 만들어 미리 예시 데이터를 넣어 컴포넌트에 전달하여 렌더링하는 것까지 했다.
useEffect를 사용하여 컴포넌트가 처음 렌더링되는 시점, 즉 컴포넌트가 화면에 보이는 시점에 API를 요청하여 데이터를 연동해볼 것이다.
import axios from "axios";
const NewsList = () => {
const [articles, setArticles] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await axios.get(
"https://newsapi.org/v2/top-headlines?country=kr&apiKey=3949b624949f4dcb85e223d42cec8177"
);
setArticles(response.data.articles);
} catch (e) {
console.log(e);
}
setLoading(false);
};
fetchData();
}, []);여기서 주의할 점은 useEffect에 등록되는 함수에 async를 붙이면 안된다. useEffect의 첫번째 인자로는 프로미스가 들어갈 수 없는데 async를 붙이면 반환 타입이 프로미스가 되기 때문이다.
따라서 fetchData라는 프로미스 함수를 또 만들어서 실행을 하게 되고, 프로미스를 반환하기는 하지만 성공, 실패 결과를 기다리지 않고 실행만 시키고 끝낸다. fetchData 함수는 알아서 실행되고, 이 과정에서 세터함수를 통해 state의 값이 바뀐다. 프로미스가 끝날 때까지 실행되다가 state의 값이 바뀌면, 리액트 컴포넌트가 리렌더링 조건 중 state가 변경되었기 때문에 다시 리렌더링 한다.
리렌더링 조건
- state 변경
- props 변경
- 부모 컴포넌트가 리렌더링될 때
- this.forceUpdate로 강제로 렌더링을 트리거할 때
console.log(response)을 찍으면 다음과 같은 객체를 확인할 수 있다. 따라서 뉴스 기사들을 불러올 때는 response.data.articles가 된다.
if (loading) return <NewsListBlock>대기 중..</NewsListBlock>;loading의 참/거짓에 따라 대기중 컴포넌트가 뜨거나 뜨지 않는다.
if (!articles) return null;
return (
<NewsListBlock>
{articles.map((article) => (
<NewsItem key={article.url} article={article} />
))}
</NewsListBlock>
);데이터를 불러와서 뉴스 데이터 배열을 map 함수를 써서 컴포넌트 배열로 변환할 때 !articles의 값이 현재 null인지 아닌지 검사해야 한다. 이 작업을 하지 않으면 아직 데이터가 없을 때 null에는 map 함수가 없기 때문에 렌더링 과정에서 오류가 발생하여 흰 페이지만 보이게 된다.
전체 코드와 동작 순서는 이렇다.
import React, { useEffect, useState } from "react";
import styled from "styled-components";
import NewsItem from "./NewsItem";
import axios from "axios";
const NewsListBlock = styled.div` 생략 `;
const NewsList = () => {
const [articles, setArticles] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await axios.get(
"https://newsapi.org/v2/top-headlines?country=kr&apiKey=3949b624949f4dcb85e223d42cec8177"
);
setArticles(response.data.articles);
} catch (e) {
console.log(e);
}
setLoading(false);
};
fetchData();
}, []);
if (loading) return <NewsListBlock>대기 중..</NewsListBlock>;
if (!articles) return null;
return (
<NewsListBlock>
{articles.map((article) => (
<NewsItem key={article.url} article={article} />
))}
</NewsListBlock>
);
};
export default NewsList;loading의 초기값이false이기 때문에if (loading)건너뜀articles의 초기값이null이므로null이 반환됨- 배열이 없기 때문에
useEffect실행 fetchData실행 (결과는 안 기다리고 끝냄)- 내부에서
fetchData실행 setLoading(true)→ state(loading) 변경 →if (loading)으로 리렌더링try/catch(await끝날 때까지 대기) →setArticles(response.data.articles)→ state(articles) 변경 →if (loading)에 걸려 리렌더링(loading값이 변경 안됨)setLoading(false)→ state(loading) 변경 → if 조건문 다 건너뜀 →return ( ... )
뉴스의 카테고리는 총 6개로 비즈니스, 과학, 연예, 스포츠, 건강, 기술로 분류된다(실제 카테고리 값은 영어로 되어 있다). 화면에 카테고리를 보여줄 때는 한글로 보여준 뒤 클릭했을 때는 영어로 된 카테고리 값을 사용할 예정이다.
import React from "react";
import styled from "styled-components";
const categories = [
{
name: "all",
text: "전체보기",
},
{
name: "business",
text: "비즈니스",
},
...
];
const CategoriesBlock = styled.div` ... `;
const Category = styled.div` ... `;
const Categories = () => {
return (
<CategoriesBlock>
{categories.map((c) => (
<Category key={c.name}>{c.text}</Category>
))}
</CategoriesBlock>
);
};
export default Categories;categories라는 배열 안에 name과 text 값이 들어가있는 객체들을 넣어서 한글로 된 카테고리와 실제 카테고리 값을 연결시켜 주었다. 여기서 name은 실제 카테고리 값을 가리키고, text 값은 렌더링할 때 사용할 한글 카테고리를 가리킨다.
import React, { useCallback, useState } from "react";
import NewsList from "./components/NewsList";
import Categories from "./components/Categories";
const App = () => {
const [category, setCategory] = useState("all");
const onSelect = useCallback((category) => setCategory(category), []);
return (
<>
<Categories category={category} onSelect={onSelect} />
<NewsList category={category} />
</>
);
};
export default App;category 상태를 관리하기 위해 useState를 사용한다. onSelect는 category 값을 업데이트하는 함수이다. category와 onSelect 함수를 Categories와 NewsList에 props로 전달해준다.
const Categories = ({ onSelect, category }) => {
return (
<CategoriesBlock>
{categories.map((c) => (
<Category
key={c.name}
active={category === c.name}
onClick={() => onSelect(c.name)}
>
{c.text}
</Category>
))}
</CategoriesBlock>
);
};Categories에서는 props로 전달받은 onSelect를 onClick으로 설정한다.
${(props) =>
props.active && css`
font-weight: 600;
border-bottom: 2px solid #22b8cf;
color: #22b8cf;
&:hover {
color: #3bc9db;
}
`}props로 전달받은 active={category === c.name}의 참(선택된 카테고리)/거짓에 따라 적용될 스타일을 추가한다.
이제 NewsList 컴포넌트에서 현재 props로 받아 온 category에 따라 카테고리를 지정하며 API를 요청하도록 구현해 본다. NewsList에서 props로 받아 온 category를 추가한 후에 API를 요청하고 있는 useEffect 함수 부분만 수정하면 된다.
// NewsList.js
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const query = category === "all" ? "" : `&category=${category}`;
const response = await axios.get(
`https://newsapi.org/v2/top-headlines?country=kr${query}&apiKey=3949b624949f4dcb85e223d42cec8177`
);
setArticles(response.data.articles);
} catch (e) {
console.log(e);
}
setLoading(false);
};
fetchData();
}, [category]);변수 query를 선언하여 category의 값이 all(초기값, 전체보기)일 때는 빈 문자열을, 특정 카테고리 값으로 변한다면 그 값을 url 안에 양식에 맞게 적용하여 넣는다.
특정 값(category)를 업데이트하는 것이므로 두 번째 파라미터는 [category]가 와야한다.
리액트 라우터의 설치 및 적용은 생략한다.
// pages/NewsPage.js
import React from "react";
import Categories from "../components/Categories";
import NewsList from "../components/NewsList";
const NewsPage = ({ match }) => {
const category = match.params.category || "all";
return (
<>
<Categories />
<NewsList category={category} />
</>
);
};
export default NewsPage;카테고리가 선택되지 않았다면 기본값으로 'all'을 사용하고 카테고리가 선택된다면 선택된 카테고리 값을 URL 파라미터를 통해 사용할 것이다. 따라서 Categories 컴포넌트에서 현재 선택된 카테고리 값을 알려줄 필요도 없고, onSelect 함수를 따로 전달해 줄 필요가 없다.
console.log(match);
console.log(match.params.category);// App.js
import React from "react";
import { Route } from "react-router-dom";
import NewsPage from "./pages/NewsPage";
const App = () => {
return <Route path="/:category?" component={NewsPage} />;
};
export default App;path="/:category?"와 같은 형태로 맨 뒤에 물음표가 들어가면 category 값이 있을 수도 있고 없을 수도 있다는 선택적인 의미이다. category URL 파라미터가 없다면 전체 카테고리를 선택한 것으로 간주한다.
import React from "react";
import styled from "styled-components";
import { NavLink } from "react-router-dom";
const categories = [ ... ];
const CategoriesBlock = styled.div` ... `;
const Category = styled(NavLink)` ... `;
const Categories = () => {
return (
<CategoriesBlock>
{categories.map((c) => (
<Category
key={c.name}
activeClassName="active"
exact={c.name === "all"}
to={c.name === "all" ? "/" : `/${c.name}`}
>
{c.text}
</Category>
))}
</CategoriesBlock>
);
};
export default Categories;NavLink를 통해 기존의 onSelect 함수로 카테고리를 선택하고 선택된 카테고리에 다른 스타일을 주는 기능을 대체한다.
일반 HTML 요소가 아닌 특정 컴포넌트와 styled-component를 쓰려면 styled.(컴포넌트 이름) 형식으로 사용해야 한다.
NavLink로 만들어진 Category 컴포넌트를 통해 to={c.name === "all" ? "/" : /${c.name}}를 설정하여 페이지 주소를 바꿀 수 있다.
컴포넌트에서 API 호출처럼 Promise를 사용해야 하는 경우 간결하게 코드를 작성할 수 있도록 해주는 커스텀 Hook을 만들어 본다.
// src/lib/usePromise.js
import { useEffect, useState } from "react";
export default function usePromise(promiseCreator, deps) {
const [loading, setLoading] = useState(false);
const [resolved, setResolved] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const process = async () => {
setLoading(true);
try {
const resolved = await promiseCreator();
setResolved(resolved);
} catch (e) {
setError(e);
}
setLoading(false);
};
process();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return [loading, resolved, error];
}대기 중, 완료, 실패에 대한 상태 관리를 useState를 통해 선언해주었다.
usePromise Hook은 의존 배열 deps를 두 번째 파라미터로 받아 온다.
import React from "react";
import styled from "styled-components";
import NewsItem from "./NewsItem";
import axios from "axios";
import usePromise from "../lib/usePromise";
const NewsListBlock = styled.div` ... `;
const NewsList = ({ category }) => {
const [loading, response, error] = usePromise(() => {
const query = category === "all" ? "" : `&category=${category}`;
return axios.get(
`https://newsapi.org/v2/top-headlines?country=kr${query}&apiKey=3949b624949f4dcb85e223d42cec8177`
);
}, [category]);
if (loading) return <NewsListBlock>대기 중..</NewsListBlock>;
if (!response) return null;
if (error) return <NewsListBlock>에러 발생!</NewsListBlock>;
const { articles } = response.data;
return (
<NewsListBlock>
{articles.map((article) => (
<NewsItem key={article.url} article={article} />
))}
</NewsListBlock>
);
};
export default NewsList;Recoil을 사용하면 atoms (공유 상태)에서 selectors (순수 함수)를 거쳐 React 컴포넌트로 내려가는 data-flow graph를 만들 수 있다. Atoms는 컴포넌트가 구독할 수 있는 상태의 단위다. Selectors는 atoms 상태값을 동기 또는 비동기 방식을 통해 변환한다.
npm install recoil 혹은 yarn add recoil
// App.js
*import* React *from* 'react';
*import* {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue,
} *from* 'recoil';
*function* App() {
*return* (
<RecoilRoot>
<CharacterCounter />
</RecoilRoot>
);
}recoil 상태를 사용하는 컴포넌트는 부모 트리 어딘가에 나타나는 RecoilRoot가 필요하다. 루트 컴포넌트가 RecoilRoot를 넣기에 가장 좋은 장소다.
Atoms는 **상태(state)의 단위(일부)**이며, 업데이트와 구독이 가능하다. 컴포넌트가 구독할 수 있는 React state라고 생각하면 된다. atom이 업데이트되면 각각의 구독된 컴포넌트는 새로운 값을 반영하여 다시 렌더링 된다.
동일한 atom이 여러 컴포넌트에서 사용되는 경우 모든 컴포넌트는 상태를 공유한다.
- Atom 생성
Atoms는 atom함수를 사용해 생성
*const* textState = atom({
key: 'textState', *// unique ID (다른 atoms/selectors에 대한 고유 ID)*
*default*: '', *// default value 기본값 (aka initial value 혹은 초기값)*
});CharacterCounter컴포넌트 생성
function CharacterCounter() {
return (
<div>
<TextInput />
<CharacterCount />
</div>
);
}TextInput컴포넌트 생성
function TextInput() {
const [text, setText] = useRecoilState(textState); // atom
const onChange = (event) => {
setText(event.target.value);
};
return (
<div>
<input type="text" value={text} onChange={onChange} />
<br />
Echo: {text}
</div>
);
}컴포넌트에서 atom을 읽고 쓰려면 useRecoilState라는 훅을 사용한다. atom의 값을 구독하여 업데이트할 수 있는 hook이다. React의 useState와 비슷하지만 상태가 컴포넌트 간에 공유될 수 있다는 차이가 있다. (방식은 동일하다)
Selector는 atoms나 다른 selectors를 입력으로 받아들이는 순수 함수(pure function)다. 상위의 atoms 또는 selectors가 업데이트되면 하위의 selector 함수도 다시 실행된다. 컴포넌트들은 selectors를 atoms처럼 구독할 수 있으며 selectors가 변경되면 컴포넌트들도 다시 렌더링된다.
Selector는 **파생된 상태(derived state)**의 일부를 나타낸다. atom이 상태의 단위이므로, 예를 들어 atom이 데이터 조각이라면 selector는 아톰에서 파생된 데이터 조각이라고 할 수 있다. 파생된 상태는 (주어진 상태를 수정하는) 순수 함수에 전달된 상태의 결과물이라고 할 수 있다. 파생된 상태는 다른 데이터에 의존하는 동적인 데이터를 만들 수 있다.
- Selector 정의
Selectors는 selector함수를 사용해 정의
const charCountState = selector({
key: "charCountState",
get: ({ get }) => {
const text = get(textState);
return text.length;
},
});get은 계산될 함수다. 전달되는 get 인자를 통해 atoms와 다른 selectors에 접근할 수 있다. 즉, atom인 textState를 받아와 text.length로 반환하는 파생된 데이터를 만든다.
CharacterCount컴포넌트 생성
function CharacterCount() {
const count = useRecoilValue(charCountState);
return <> Character Count : {count}</>;
}Selectors는 useRecoilValue()를 사용해 읽을 수 있다. useRecoilValue()는 하나의 atom이나 selector를 인자로 받아 대응하는 값을 반환한다. 즉, setter 함수 없이 atom의 값을 반환만 한다. **get 함수만 제공되면 selector는 읽기만 가능한 RecoilValueReadOnly 객체를 반환한다.** 따라서 charCountState selector는 writable하지 않기 때문에 useRecoilState()를 이용하지 않는다.
import React from "react";
import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue,
} from "recoil";
// RecoilRoot
export default function App() {
return (
<RecoilRoot>
<CharacterCounter />
</RecoilRoot>
);
}
// Atom
*const* textState = atom({
key: 'textState',
** *default*: '',
**});
// * * * * * * * * * * * * * * * * * * * * * * * * * * * //
function CharacterCounter() {
return (
<div>
<TextInput />
<CharacterCount />
</div>
);
}
// * * * * * * * * * * * * * * * * * * * * * * * * * * * //
function TextInput() {
const [text, setText] = useRecoilState(textState);
const onChange = (event) => {
setText(event.target.value);
};
return (
<div>
<input type="text" value={text} onChange={onChange} />
<br />
Echo: {text}
</div>
);
}
// Selector
const charCountState = selector({
key: "charCountState",
get: ({ get }) => {
const text = get(textState);
return text.length;
},
});
function CharacterCount() {
const count = useRecoilValue(charCountState);
return <> Character Count : {count}</>;
}import React, { useState } from "react";
import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from "recoil";
export default function App() {
return (
<RecoilRoot>
<TodoList />
</RecoilRoot>
);
}TodoListTodoListStatsTodoListFiltersTodoItemCreatorTodoItem
TodoListconst todoListState = atom({ key: "todoListState", default: [], }); function TodoList() { const todoList = useRecoilValue(filteredTodoListState); return ( <> <TodoListStats /> <TodoListFilters /> <TodoItemCreator /> {todoList.map((todoItem) => ( <TodoItem key={todoItem.id} item={todoItem} /> ))} </> ); }
TodoItemCreator
function TodoItemCreator() { const [inputValue, setInputValue] = useState(""); const setTodoList = useSetRecoilState(todoListState); const addItem = () => { setTodoList((oldTodoList) => [ ...oldTodoList, { id: getID(), text: inputValue, isComplete: false, }, ]); setInputValue(""); }; const onChange = ({ target: { value } }) => { setInputValue(value); }; return ( <div> <input type="text" value={inputValue} onChange={onChange} /> <button onClick={addItem}>Add</button> </div> ); } // 고유 ID let id = 0; function getID() { return id++; }
TodoItem
function TodoItem({ item }) { const [todoList, setTodoList] = useRecoilState(todoListState); const index = todoList.findIndex((listItem) => listItem === item); const editItemIndex = ({ target: { value } }) => { const newList = replaceItemAtIndex(todoList, index, { ...item, text: value, }); setTodoList(newList); }; const toggleItemCompletion = () => { const newList = replaceItemAtIndex(todoList, index, { ...item, isComplete: !item.isComplete, }); setTodoList(newList); }; const deleteItem = () => { const newList = removeItemAtIndex(todoList, index); setTodoList(newList); }; return ( <div> <input type="text" value={item.text} onChange={editItemIndex} /> <input type="checkbox" checked={item.isComplete} onChange={toggleItemCompletion} /> <button onClick={deleteItem}>X</button> </div> ); } function replaceItemAtIndex(arr, index, newValue) { return [...arr.slice(0, index), newValue, ...arr.slice(index + 1)]; } function removeItemAtIndex(arr, index) { return [...arr.slice(0, index), ...arr.slice(index + 1)]; }
todo 리스트 애플리케이션에서는 파생된 상태가 다음과 같다.
- 필터링 된 todo 리스트 : 전체 todo 리스트에서 일부 기준에 따라 특정 항목이 필터링 된 새 리스트(예: 이미 완료된 항목 필터링)를 생성되어 파생된다.
- Todo 리스트 통계 : 전체 todo 리스트에서 목록의 총 항목 수, 완료된 항목 수, 완료된 항목의 백분율 같은 리스트의 유용한 속성들을 계산하여 파생된다.
TodoListTodoListFilters아래// 리스트 상태 const todoListState = atom({ key: "todoListState", default: [], }); // 필터링된 리스트 목록을 사용하는 컴포넌트 function TodoList() { const todoList = useRecoilValue(filteredTodoListState); return ( <> <TodoListStats /> <TodoListFilters /> <TodoItemCreator /> {todoList.map((todoItem) => ( <TodoItem key={todoItem.id} item={todoItem} /> ))} </> ); }
filteredTodoListStateselector는todoListFilterState와todoListState의 값을 가져와서list.filter( ... )된 값을 반환하는 파생 데이터를 만들어 낸다.// 필터링 리스트 상태 const todoListFilterState = atom({ key: "todoListFilterState", default: "Show All", }); // 파생된 리스트 필터링 목록 const filteredTodoListState = selector({ key: "filteredTodoListState", get: ({ get }) => { const filter = get(todoListFilterState); const list = get(todoListState); switch (filter) { case "Show Completed": return list.filter((item) => item.isComplete); case "Show Uncompleted": return list.filter((item) => !item.isComplete); default: // Show All return list; } }, }); function TodoListFilters() { const [filter, setFilter] = useRecoilState(todoListFilterState); const updateFilter = ({ target: { value } }) => { setFilter(value); }; return ( <> Filter: <select value={filter} onChange={updateFilter}> <option value="Show All">All</option> <option value="Show Completed">Completed</option> <option value="Show Uncompleted">Uncompleted</option> </select> </> ); }
TodoListStatsconst todoListStatsState = selector({ key: "todoListStatsState", get: ({ get }) => { const todoList = get(todoListState); const totalNum = todoList.length; const totalCompletedNum = todoList.filter( (item) => item.isComplete ).length; const totalUncompletedNum = totalNum - totalCompletedNum; const percentCompleted = totalNum === 0 ? 0 : totalCompletedNum / totalNum; return { totalNum, totalCompletedNum, totalUncompletedNum, percentCompleted, }; }, }); function TodoListStats() { const { totalNum, totalCompletedNum, totalUncompletedNum, percentCompleted, } = useRecoilValue(todoListStatsState); const formattedPercentCompleted = Math.round(percentCompleted * 100); return ( <ul> <li>Total items: {totalNum}</li> <li>Items completed: {totalCompletedNum}</li> <li>Items not completed: {totalUncompletedNum}</li> <li>Percent completed: {formattedPercentCompleted}</li> </ul> ); }
import React, { useState } from "react";
import {
RecoilRoot,
atom,
selector,
useRecoilState,
useRecoilValue,
useSetRecoilState,
} from "recoil";
export default function App() {
return (
<RecoilRoot>
<TodoList />
</RecoilRoot>
);
}
// * * * * * * * * * * * * * * * * * * * * * * * * * * * //
// Atom
const todoListState = atom({
key: "todoListState",
default: [],
});
function TodoList() {
const todoList = useRecoilValue(filteredTodoListState);
return (
<>
<TodoListStats />
<TodoListFilters />
<TodoItemCreator />
{todoList.map((todoItem) => (
<TodoItem key={todoItem.id} item={todoItem} />
))}
</>
);
}
// * TodoItemCreator
function TodoItemCreator() {
const [inputValue, setInputValue] = useState("");
const setTodoList = useSetRecoilState(todoListState);
const addItem = () => {
setTodoList((oldTodoList) => [
...oldTodoList,
{
id: getID(),
text: inputValue,
isComplete: false,
},
]);
setInputValue("");
};
const onChange = ({ target: { value } }) => {
setInputValue(value);
};
return (
<div>
<input type="text" value={inputValue} onChange={onChange} />
<button onClick={addItem}>Add</button>
</div>
);
}
// 고유 ID
let id = 0;
function getID() {
return id++;
}
// * TodoItem
function TodoItem({ item }) {
const [todoList, setTodoList] = useRecoilState(todoListState);
const index = todoList.findIndex((listItem) => listItem === item);
const editItemIndex = ({ target: { value } }) => {
const newList = replaceItemAtIndex(todoList, index, {
...item,
text: value,
});
setTodoList(newList);
};
const toggleItemCompletion = () => {
const newList = replaceItemAtIndex(todoList, index, {
...item,
isComplete: !item.isComplete,
});
setTodoList(newList);
};
const deleteItem = () => {
const newList = removeItemAtIndex(todoList, index);
setTodoList(newList);
};
return (
<div>
<input type="text" value={item.text} onChange={editItemIndex} />
<input
type="checkbox"
checked={item.isComplete}
onChange={toggleItemCompletion}
/>
<button onClick={deleteItem}>X</button>
</div>
);
}
function replaceItemAtIndex(arr, index, newValue) {
return [...arr.slice(0, index), newValue, ...arr.slice(index + 1)];
}
function removeItemAtIndex(arr, index) {
return [...arr.slice(0, index), ...arr.slice(index + 1)];
}
// * TodoListFilters
// Atom
const todoListFilterState = atom({
key: "todoListFilterState",
default: "Show All",
});
// Selector
const filteredTodoListState = selector({
key: "filteredTodoListState",
get: ({ get }) => {
const filter = get(todoListFilterState);
const list = get(todoListState);
switch (filter) {
case "Show Completed":
return list.filter((item) => item.isComplete);
case "Show Uncompleted":
return list.filter((item) => !item.isComplete);
default:
// Show All
return list;
}
},
});
function TodoListFilters() {
const [filter, setFilter] = useRecoilState(todoListFilterState);
const updateFilter = ({ target: { value } }) => {
setFilter(value);
};
return (
<>
Filter:
<select value={filter} onChange={updateFilter}>
<option value="Show All">All</option>
<option value="Show Completed">Completed</option>
<option value="Show Uncompleted">Uncompleted</option>
</select>
</>
);
}
// * TodoListStats
// Selector
const todoListStatsState = selector({
key: "todoListStatsState",
get: ({ get }) => {
const todoList = get(todoListState);
const totalNum = todoList.length;
const totalCompletedNum = todoList.filter((item) => item.isComplete).length;
const totalUncompletedNum = totalNum - totalCompletedNum;
const percentCompleted = totalNum === 0 ? 0 : totalCompletedNum / totalNum;
return {
totalNum,
totalCompletedNum,
totalUncompletedNum,
percentCompleted,
};
},
});
function TodoListStats() {
const { totalNum, totalCompletedNum, totalUncompletedNum, percentCompleted } =
useRecoilValue(todoListStatsState);
const formattedPercentCompleted = Math.round(percentCompleted * 100);
return (
<ul>
<li>Total items: {totalNum}</li>
<li>Items completed: {totalCompletedNum}</li>
<li>Items not completed: {totalUncompletedNum}</li>
<li>Percent completed: {formattedPercentCompleted}</li>
</ul>
);
}








