Skip to content

Latest commit

 

History

History
1103 lines (897 loc) · 31.4 KB

File metadata and controls

1103 lines (897 loc) · 31.4 KB

6주차 React 스터디 정리

제목
14장 외부 API를 연동하여 뉴스 뷰어 만들기
전역상태관리 recoil

14 장

14.4 뉴스 뷰어 UI 만들기

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이라는 객체를 만들어 미리 예시 데이터를 넣어 컴포넌트에 전달하여 렌더링하는 것까지 했다.

스크린샷 2021-11-07 오후 5 02 51

14.5 데이터 연동하기

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가 변경되었기 때문에 다시 리렌더링 한다.

리렌더링 조건

  1. state 변경
  2. props 변경
  3. 부모 컴포넌트가 리렌더링될 때
  4. this.forceUpdate로 강제로 렌더링을 트리거할 때

console.log(response)을 찍으면 다음과 같은 객체를 확인할 수 있다. 따라서 뉴스 기사들을 불러올 때는 response.data.articles가 된다.

스크린샷 2021-11-07 오후 5 03 30

if (loading) return <NewsListBlock>대기 중..</NewsListBlock>;

loading의 참/거짓에 따라 대기중 컴포넌트가 뜨거나 뜨지 않는다.

스크린샷 2021-11-07 오후 5 04 00

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;
  1. loading의 초기값이 false이기 때문에 if (loading) 건너뜀
  2. articles의 초기값이 null이므로 null이 반환됨
  3. 배열이 없기 때문에 useEffect 실행
  4. fetchData 실행 (결과는 안 기다리고 끝냄)
  5. 내부에서 fetchData 실행
  6. setLoading(true) → state(loading) 변경 → if (loading)으로 리렌더링
  7. try/catch (await 끝날 때까지 대기) → setArticles(response.data.articles) → state(articles) 변경 → if (loading)에 걸려 리렌더링(loading 값이 변경 안됨)
  8. setLoading(false) → state(loading) 변경 → if 조건문 다 건너뜀 → return ( ... )

스크린샷 2021-11-07 오후 5 04 31

14.6 카테고리 기능 구현하기

뉴스의 카테고리는 총 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라는 배열 안에 nametext 값이 들어가있는 객체들을 넣어서 한글로 된 카테고리와 실제 카테고리 값을 연결시켜 주었다. 여기서 name은 실제 카테고리 값을 가리키고, text 값은 렌더링할 때 사용할 한글 카테고리를 가리킨다.

스크린샷 2021-11-07 오후 5 04 57

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를 사용한다. onSelectcategory 값을 업데이트하는 함수이다. categoryonSelect 함수를 CategoriesNewsListprops로 전달해준다.

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로 전달받은 onSelectonClick으로 설정한다.

${(props) =>
  props.active && css`
    font-weight: 600;
    border-bottom: 2px solid #22b8cf;
    color: #22b8cf;
    &:hover {
      color: #3bc9db;
    }
 `}

props로 전달받은 active={category === c.name}의 참(선택된 카테고리)/거짓에 따라 적용될 스타일을 추가한다.

스크린샷 2021-11-07 오후 5 05 16

이제 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]가 와야한다.

스크린샷 2021-11-07 오후 5 05 41

14.7 리액트 라우터 적용하기

리액트 라우터의 설치 및 적용은 생략한다.

// 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);

스크린샷 2021-11-07 오후 5 06 06

// 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}}를 설정하여 페이지 주소를 바꿀 수 있다.

14.8 usePromise custom hook 만들기

컴포넌트에서 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

주요 개념

Recoil을 사용하면 atoms (공유 상태)에서 selectors (순수 함수)를 거쳐 React 컴포넌트로 내려가는 data-flow graph를 만들 수 있다. Atoms는 컴포넌트가 구독할 수 있는 상태의 단위다. Selectors는 atoms 상태값을 동기 또는 비동기 방식을 통해 변환한다.

설치

npm install recoil 혹은 yarn add recoil

Recoil 시작하기

RecoilRoot

// App.js

*import* React *from* 'react';
*import* {
  RecoilRoot,
  atom,
  selector,
  useRecoilState,
  useRecoilValue,
} *from* 'recoil';

*function* App() {
  *return* (
    <RecoilRoot>
      <CharacterCounter />
    </RecoilRoot>
  );
}

recoil 상태를 사용하는 컴포넌트는 부모 트리 어딘가에 나타나는 RecoilRoot가 필요하다. 루트 컴포넌트가 RecoilRoot를 넣기에 가장 좋은 장소다.

Atom

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

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()를 이용하지 않는다.

전체 코드

스크린샷 2021-11-07 오후 5 07 00

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}</>;
}

todo 리스트 애플리케이션

도입부(RecoilRoot)

import React, { useState } from "react";
import {
  RecoilRoot,
  atom,
  selector,
  useRecoilState,
  useRecoilValue,
  useSetRecoilState,
} from "recoil";

export default function App() {
  return (
    <RecoilRoot>
      <TodoList />
    </RecoilRoot>
  );
}
  • TodoList
    • TodoListStats
    • TodoListFilters
    • TodoItemCreator
    • TodoItem

Atoms

  • TodoList
    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)];
    }

Selectors

todo 리스트 애플리케이션에서는 파생된 상태가 다음과 같다.

  • 필터링 된 todo 리스트 : 전체 todo 리스트에서 일부 기준에 따라 특정 항목이 필터링 된 새 리스트(예: 이미 완료된 항목 필터링)를 생성되어 파생된다.
  • Todo 리스트 통계 : 전체 todo 리스트에서 목록의 총 항목 수, 완료된 항목 수, 완료된 항목의 백분율 같은 리스트의 유용한 속성들을 계산하여 파생된다.

  • TodoList
    • TodoListFilters
      // 리스트 상태
      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} />
            ))}
          </>
        );
      }
      아래 filteredTodoListState selector는 todoListFilterStatetodoListState의 값을 가져와서 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>
          </>
        );
      }
    • TodoListStats
      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>
        );
      }

전체 코드

스크린샷 2021-11-07 오후 5 07 31

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>
  );
}