코드잇 Codeit/Front-End

[코드잇] React로 데이터 다루기 ① - 배열 렌더링, 데이터 가져오기, 입력폼 다루기

꼽파 2024. 3. 30. 13:55


 

◆ 배열 렌더링하기

◆ 데이터 가져오기

◆ 입력 폼 다루기


배열 렌더링하기

mock 데이터 추가하기

ReviewList.js

function ReviewList({ items }) {
    console.log(items);
    return <ul></ul>;
}

export default ReviewList;

 

components/App.js

import ReviewList from "./ReviewList";
import items from '../mock.json';

function App() {
    return <div><ReviewList items={items}/></div>
}

export default App;

 

index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './components/App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);


map으로 배열 렌더링하기

function ReviewList({ items }) {
    return (
        <ul>
            {items.map((item) => {
                return <li>{item.title}</li>;
            })}
        </ul>
        );
    }

export default ReviewList;

item의 각 프로퍼티를 만들어준다

 

ReviewList.css

.ReviewListItem {
    display: flex;
    padding: 10px;
    align-items: center;
}

.ReviewListItem-img {
    width: 200px;
    height: 300px;
    object-fit: cover;
    margin-right: 20px;
}

sort로 정렬 바꾸기

App.js

import ReviewList from "./ReviewList";
import items from '../mock.json';

function App() {
    // 내림차순 정렬 (별점이 높은 순서대로 정렬하도록 함)
    const sortedItems = items.sort((a, b) => b.rating - a.rating);

    return (
        <div>
            <ReviewList items={sortedItems}/>
        </div>
    );
}

export default App;

 

정렬 기준 만들어주기

import ReviewList from "./ReviewList";
import items from '../mock.json';
import { useState } from 'react';

function App() {
    // 정렬 기준 만들어주기
    const [order, setOrder] = useState('createdAt');
    const sortedItems = items.sort((a, b) => b[order] - a[order]);

    return (
        <div>
            <ReviewList items={sortedItems}/>
        </div>
    );
}

export default App;

order State의 값이 
· createdAt에 있을 때는 최신순으로 정렬이 되고,

· rating일 때는 평점이 높은 순으로 정렬이 됨.

 

 

App.js

import ReviewList from "./ReviewList";
import items from '../mock.json';
import { useState } from 'react';

function App() {
    // 정렬 기준 만들어주기
    const [order, setOrder] = useState('');
    const sortedItems = items.sort((a, b) => b[order] - a[order]);

    const handleNewestClick = () => setOrder('createdAt');

    const handleBestClick = () => setOrder('rating');

    return (
        <div>
            <div>
                <button onClick={handleNewestClick}>최신순</button>
                <button onClick={handleBestClick}>베스트순</button>
            </div>
            <ReviewList items={sortedItems} />
        </div>
    );
}

export default App;

 

onClick 이벤트 핸들러로 handleBestClick을 prop으로 내려줌.

· '최신순' 버튼 클릭 → hooks의 state가 'createdAt'으로 바뀜.

· '베스트순' 버튼 클릭 → hooks의 state가 'rating'으로 바뀜.

 

createdAt과 rating은 mock.json의 속성에 있는 것인데, 오타나서 계속 안 됐었음.


filter로 아이템 삭제하기

App.js

import ReviewList from "./ReviewList";
import mockItems from '../mock.json';
import { useState } from 'react';

function App() {
    // 정렬 기준 만들어주기
    const [items, setItems] = useState(mockItems);
    const [order, setOrder] = useState('');
    const sortedItems = items.sort((a, b) => b[order] - a[order]);

    const handleNewestClick = () => setOrder('createdAt');

    const handleBestClick = () => setOrder('rating');

    const handleDelete = (id) => {
        // item의 id값이 파라미터로 들어온 id가 아닌 item 값들이 배열에 담김
        const nextItems = items.filter((item) => item.id !== id); 
        setItems(nextItems);
    }

    return (
        <div>
            <div>
                <button onClick={handleNewestClick}>최신순</button>
                <button onClick={handleBestClick}>베스트순</button>
            </div>
            <ReviewList items={sortedItems} onDelete={handleDelete}/>
        </div>
    );
}

export default App;

useState Hook : 구성 요소 내에서 상태를 관리할 수 있음
- items : 현재 상태 값
- setItems : 해당 값을 업데이트하는 함수

 

ReviewList.js

import './ReviewList.css';

// 날짜 형식 변환하는 함수
function formatDate(value) {
    const date = new Date(value);
    return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}`
}

// 개별 리뷰 항목 렌더링하는 컴포넌트
function ReviewListItem({ item, onDelete }) {
    // handleDeleteClick 함수가 실행되면 onDelete함수의 item.id값으로 실행
    const handleDeleteClick = () => onDelete(item.id);

    return (
        <div className="ReviewListItem">
            <img className="ReviewListItem-img" 
                src={item.imgUrl}
                alt={item.title}>
            </img>
            <div>
                <h1>{item.title}</h1>
                <p>{item.rating}</p>
                <p>{formatDate(item.createdAt)}</p>
                <p>{item.content}</p>
                <button onClick={handleDeleteClick}>삭제</button>
            </div>
        </div>
        );
    }

// 리뷰 항목 목록을 렌더링하는 컴포넌트 
function ReviewList({ items, onDelete }) {
        return (
            <ul>
                {items.map((item) => {
                    return (
                        <li>
                            <ReviewListItem item={item} onDelete={onDelete}/>
                        </li>);
                })}
            </ul>
            );
    }  

export default ReviewList;
ReviewListItem item={item} onDelete={onDelete}

 

- onDelete : 전달되는 컴포넌트 이름
- {onDelete} : 'onDelete'라는 prop의 값으로 전달되는 실제 함수 참조


배열 렌더링할 때 key 쓰기

리스트의 각 child는 반드시 고유한 key prop을 가져야 한다.

여기서 child라는 것은 map 메소드에서 렌더링한 것들을 말함.

 

이 경고를 없애려면 각 요소를 렌더링할 때 최상위 태그key prop을 지정해주면 됨.

// 리뷰 항목 목록을 렌더링하는 컴포넌트 
function ReviewList({ items, onDelete }) {
        return (
            <ul>
                {items.map((item) => {
                    return (
                        <li key={item.id}>
                            <ReviewListItem item={item} onDelete={onDelete}/>
                        </li>
                        );
                })}
            </ul>
            );
    }

map으로 렌더링하는 부분에 <li key={item.id}>를 달아준다.

배열을 렌더링할 때는 반드시 key를 설정해줘야 함.

 

그럼 key값이 없으면 어떤 문제가 발생하는가?

각 item마다 input 태그로 입력창을 만들어 본 후, 아무거나 입력한 다음 '삭제'를 누르면 해당 input 값이 엉뚱한 곳으로 감.

// 리뷰 항목 목록을 렌더링하는 컴포넌트 
function ReviewList({ items, onDelete }) {
        return (
            <ul>
                {items.map((item, index) => {
                    return (
                        // map 메소드의 두 번째 파라미터인 index값 사용
                        <li key={index}>
                            <ReviewListItem item={item} onDelete={onDelete}/>
                            <input></input>
                        </li>
                        );
                })}
            </ul>
            );
    }

map의 두 번째 메소드인 배열의 index값을 key값으로 사용함.

그래도 역시나 마찬가지로 input의 값이 엉뚱한 곳으로 감.

  • 배열의 index는 배열의 순서에 따라서 그때그때 부여되는 값이기 때문에 key값으로 사용할 수 없음.
  • 반면 id값은 배열의 상태와는 무관하게 항상 같은 데이터를 가리키는 고유한 값임.

item의 id값을 key값으로 지정함.

// 리뷰 항목 목록을 렌더링하는 컴포넌트 
function ReviewList({ items, onDelete }) {
        return (
            <ul>
                {items.map((item, index) => {
                    return (
                        // item의 id값 (mock.json에서 각 항목들의 고유한 id 번호)
                        <li key={item.id}>
                            <ReviewListItem item={item} onDelete={onDelete}/>
                            <input></input>
                        </li>
                        );
                })}
            </ul>
            );
    }

제대로 동작하는 것을 확인할 수 있음.

 

결론 :

  • 각 요소를 렌더링할 때 최상위 태그에 key prop을 지정해주어야 함.
  • 항상 같은 데이터(고유한 데이터)를 가리키는 값은 key로 활용할 수 있음.

데이터 가져오기

React에서 fetch 사용하기

paging : 데이터 추가 로딩에 쓸 값을 담고 있음.

reviews : 받아서 사용할 리뷰 데이터가 배열로 되어 있음.

 

api.js

// 리퀘스트 함수 모음

export async function getReviews() {
    const response = await fetch('http://learn.codeit.kr/api/film-reviews');
    const body = await response.json();
    return body;
}

리퀘스트 함수는 src의 api.js에 모아둘 것임.

mock.json 대신 통신을 통해 api를 호출하여 불러온 데이터를 사용할 것임.

 

App.js

    const handleLoadClick = async () => {
        const { reviews } = await getReviews();
        setItems(reviews)
    }
    return (
        <div>
            <div>
                <button onClick={handleNewestClick}>최신순</button>
                <button onClick={handleBestClick}>베스트순</button>
            </div>
            <ReviewList items={sortedItems} onDelete={handleDelete}/>
            <button onClick={handleLoadClick}>불러오기</button>
        </div>
    );
}

불러오기 버튼을 누르면 getReviews 함수가 호출되어 item의 상태가 reviews(불러온 데이터)로 변경됨.


useEffect로 초기 데이터 가져오기

버튼 누를 때 말고 페이지를 열었을 때 알아서 데이터를 불러오도록 만들어보기

 

App.js

    const handleLoad = async () => {
        const { reviews } = await getReviews();
        setItems(reviews)
    }

    handleLoad();

    return (
        <div>
            <div>
                <button onClick={handleNewestClick}>최신순</button>
                <button onClick={handleBestClick}>베스트순</button>
            </div>
            <ReviewList items={sortedItems} onDelete={handleDelete}/>
            <button onClick={handleLoad}>불러오기</button>
        </div>
    );
}

export default App;

여기서 handleLoad 함수 실행시키면 무한로딩 걸려서 오류남.

무한루프가 발생하면 당황하지 말고 source일시정지 버튼을 누르면 됨.

function App() {
    const [items, setItems] = useState([]);
    const [order, setOrder] = useState('');
    const sortedItems = items.sort((a, b) => b[order] - a[order]);

    const handleNewestClick = () => setOrder('createdAt');

    const handleBestClick = () => setOrder('rating');

    const handleDelete = (id) => {
        const nextItems = items.filter((item) => item.id !== id); 
        setItems(nextItems);
    }

    const handleLoad = async () => {
        const { reviews } = await getReviews();
        setItems(reviews)
    }

    handleLoad();

    return (
        <div>
            <div>
                <button onClick={handleNewestClick}>최신순</button>
                <button onClick={handleBestClick}>베스트순</button>
            </div>
            <ReviewList items={sortedItems} onDelete={handleDelete}/>
            <button onClick={handleLoad}>불러오기</button>
        </div>
    );
}

 

무한루프가 발생하는 이유

1. 함수 App()이 실행되고 리액트가 return값을 반환하여 화면을 그려줌.

2. 이때 handleLoad 함수는 비동기로 request를 보냈다가 response가 도착함.

    해당 response를 reviews 변수를 통해 지정하고, setItems를 통해 state를 변경해줌.

3. 리액트는 state가 변경되어 App 컴포넌트를 재렌더링

4. App 함수가 다시 실행되어서 state가 바뀌고 무한루프가 발생함.

 

이런 경우는 useEffect를 활용할 수 있음.

    const handleLoad = async () => {
        const { reviews } = await getReviews();
        setItems(reviews);
    };

    useEffect(() => {
        handleLoad();
    }, []);

리액트는 콜백함수를 맨 처음 렌더링할 때만 실행하기 때문에 무한 루프가 생기는 것을 막을 수 있음.

useEffect는 컴포넌트가 처음 mount될 때 1회만 실행됨.

 


서버에서 정렬한 데이터 받아오기

초기 데이터를 불러오고 정렬을 변경해보면 한 가지 문제점이 있음.

전체 데이터에서 정렬하는게 아니라 받아온 데이터 안에서만 정렬함.

이런 경우는 웹 브라우저에서 정렬하는 게 아니라 서버에서 정렬된 데이터를 받아와야 함.

    // 콜백함수, dependency list
    useEffect(() => {
        handleLoad();
    }, []);

useEffect는 콜백함수를 예약해 뒀다가 렌더링이 끝나고 나면 실행해 줌.

이때 dependency list도 같이 기억해 둠.

 

콜백함수를 실행해서 handleLoad 함수를 실행하면 state가 변경

→ 다시 렌더링하면서 컴포넌트 함수를 실행

→ useEffect 함수도 다시 실행

이때 dependency list에 있는 값들을 앞에서 기억한 값이랑 비교함.

→ 빈 배열이라서 변한게 없으므로, 렌더링 끝나면 콜백함수는 실행되지 않음.

 

useEffect 함수

  • 맨 처음 렌더링이 끝나면 콜백함수를 실행해줌.
  • 그 다음부터는 dependency list를 비교해서 기억했던 값이랑 다른 경우에만 콜백을 실행해줌.
    // 콜백함수, dependency list
    useEffect(() => {
        handleLoad();
    }, [order]);

 

order state가 바뀔 때마다 렌더링이 일어남.

  • 빨간 체크 : 맨 처음 1회 보낸 request
  • 노란 밑줄 : 버튼 (베스트순, 최신순) 누를 때마다 발송된 request

최신순이랑 베스트순 버튼 누를때마다 order State가 바뀌니까 재랜더링이 일어남.

여기서 useEffect가 기억하고 있는 order값이랑 현재 order값이 많이 다름.

그래서 렌더링이 끝나고 콜백 함수를 실행해서 리퀘스트를 보냄.

데이터 정렬을 rating 프로퍼티를 기준으로 받아오라고 보냄.

 

ReviewList.js

    const handleLoad = async (orderQuery) => {
        const { reviews } = await getReviews(orderQuery);
        setItems(reviews);
    };

    useEffect(() => {
        handleLoad(order); // orderState값으로 handlLoad 함수 실행
    }, [order]);

api.js

export async function getReviews(order = 'createdAt') {
    const query = `order=${order}`;
    const response = await fetch(`http://learn.codeit.kr/api/film-reviews?${query}`);
    const body = await response.json();
    return body;
}

확인해보면 최신순/베스트순 버튼을 눌렀을 때 잘 동작함.


페이지네이션

서버에서 모든 데이터를 한번에 받아온다면?
→ 접속 할 때마다 몇 시간씩 기다리거나 아예 못 받게 됨.
     뉴스 사이트에서는 글을 조금씩 받아서 보여줌.

페이지네이션(pagination) : 데이터를 나눠서 제공하는 것

오프셋 기반

  • 지금까지 받아온 데이터의 개수

Request

GET https://example.com/posts?offset=20&limit=10

 

지금까지 20개 받았으니까 10개 더 보내줘

  • offset=20 : 지금까지 받은 데이터 개수
  • limit=10 : 더 받아올 데이터의 개수

Response

{
	"paging": {
	"count": 30,
	"hasNext": false
},
	"posts": [...]
}
  • 장점 : 구현이 간단함 (건너뛸 항목 수 = 오프셋, 가져올 항목 수 = 제한)
  • 단점 : 페이지를 매기는 동안 데이터 세트에 항목이 추가되거나 제거되면 결과가 일관되지 않음.


커서 기반

  • 커서(Cursor) : 데이터를 가리키는 값
  • 지금까지 받은 데이터를 표시한 책갈피

Request

GET https://example.com/posts?limit=10

 

Response

{
    "paging": {
    "count": 30,
    "nextCursor": WerZxc
},
    "posts": [...]
}

 

Request

GET https://example.com/posts?cursor=WerZxc&limit=10

 

커서 데이터 이후로 10개 보내줘

  • 장점 : 데이터의 중복이나 빠짐이 없이 가져올 수 있음.
  • 단점 : 복잡하고 예측성이 낮음.

데이터 더 불러오기

개발자도구에서 사용할 API를 테스트

처음 6개가 잘 불러와짐.

 

import ReviewList from "./ReviewList";
import { useState, useEffect } from 'react';
import { getReviews } from "../api";

const LIMIT = 6;

function App() {
    const [items, setItems] = useState([]);
    const [order, setOrder] = useState('');
    const [offset, setOffset] = useState(0);

    const sortedItems = items.sort((a, b) => b[order] - a[order]);

    const handleNewestClick = () => setOrder('createdAt');

    const handleBestClick = () => setOrder('rating');

    const handleDelete = (id) => {
        const nextItems = items.filter((item) => item.id !== id); 
        setItems(nextItems);
    }

    const handleLoad = async (options) => {
        const { reviews } = await getReviews(options);
        if (options.offset === 0) {
            setItems(reviews);
        } else {
            setItems([...items, ...reviews]);
        }
        setOffset(options.offset + reviews.length);
    };

    const handleLoadMore = () => {
        handleLoad({ order, offset, limit: LIMIT });
    }

    useEffect(() => {
        handleLoad({ order, offset: 0, limit: LIMIT }); 
    }, [order]);

    return (
        <div>
            <div>
                <button onClick={handleNewestClick}>최신순</button>
                <button onClick={handleBestClick}>베스트순</button>
            </div>
            <ReviewList items={sortedItems} onDelete={handleDelete}/>
            <button onClick={handleLoadMore}>더 보기</button>
        </div>
    );
}

export default App;

 

  1. App component가 렌더링되면 useEffect에서 handleLoad를 실행함.
  2. handleLoad 함수에서는 order값이랑 offset 0, limit 6이라는 값으로 리퀘스트를 보냄.
    offset값이 0이니까 리스폰스로 받은 데이터로 items State를 변경하고 (setItems)
    데이터를 6개 받아왔다면 offset State 값은 0 + 6
    더보기 버튼 클릭하면 handleLoadMore 함수가 실행됨.
  3. 이 함수가 order 값이랑 offset 6, limit 6이라는 값으로 리퀘스트를 보냄.
  4. 이번에는 else문(offset의 값이 0이 아님)으로 처리되어 배열의 뒤에다가 요소들을 추가함.
  5. 재랜더링 되면서 추가된 데이터가 보임.

 

버튼 없을 때는 안 보이게 하기

    const handleLoad = async (options) => {
        const { reviews, paging } = await getReviews(options);
        if (options.offset === 0) {
            setItems(reviews);
        } else {
            setItems([...items, ...reviews]);
        }
        setOffset(options.offset + reviews.length);
        setHasNext(paging.hasNext);
    };
    return (
        <div>
            <div>
                <button onClick={handleNewestClick}>최신순</button>
                <button onClick={handleBestClick}>베스트순</button>
            </div>
            <ReviewList items={sortedItems} onDelete={handleDelete}/>
            <button
                disabled={!hasNext}
                onClick={handleLoadMore}>더 보기</button>
        </div>
    );
}

hasNext가 없을 때에만 disabled가 동작하도록 함.


데이터가 있을 때만 버튼 보여주기

            {hasNext && 
            <button onClick={handleLoadMore}>더 보기</button>}

hasNext가 true일 때만 해당 버튼을 렌더링함.

리액트에서는 false값은 렌더링하지 않기 때문에 버튼이 보이지 않게 됨.

이렇게 조건에 따라 다르게 렌더링하는 것을 조건부 렌더링이라고 함.


조건부 렌더링 꿀팁

      {show && <p>보인다 👀</p>}

 

show 값이 true 이면 렌더링 하고, false 이면 렌더링 하지 않음.

      {hide || <p>보인다 👀</p>}

 

hide 값이 true 이면 렌더링 하지 않고, false 이면 렌더링 함.

 

  • 렌더링되지 않는 값들 : null, undefined, true, false, '', []
  • 주의할 점 : num 값이 0일 때는 false로 계산되므로 뒤의 값을 계산하지 않음.

비동기로 State를 변경할 때 주의점

네트워크 쓰로틀링
네트워크 속도가 느린 상황이나 오프라인 상태를 테스트할 때 유용하게 사용할 수 있음.

삭제 버튼 누르고, 더보기 버튼 누르면 사라졌던 것이 다시 로딩되면서 생겨남.

 

비동기로 state를 변경할 때는 잘못된 시점의 값을 사용하는 문제가 있음.

    const handleLoad = async (options) => {
        const { reviews, paging } = await getReviews(options);
        if (options.offset === 0) {
            setItems(reviews);
        } else {
            setItems([...items, ...reviews]);
        }
        setOffset(options.offset + reviews.length);
        setHasNext(paging.hasNext);
    };

 

setter 함수에 값이 아니라 콜백을 전달해서 해결할 수 있음.

이전 state값을 받아서 변경할 state 값을 리턴하면 됨.

    const handleLoad = async (options) => {
        const { reviews, paging } = await getReviews(options);
        if (options.offset === 0) {
            setItems(reviews);
        } else {
            setItems((prevItems) => [...prevItems, ...reviews]);
        }
        setOffset(options.offset + reviews.length);
        setHasNext(paging.hasNext);
    };

여기서 setItems의 prevItems는 파라미터이므로, 고정된 게 아니라 함수의 파라미터임.

리액트가 현재 시점state값을 전달해줌.

 

결론 :

  • 비동기 상황에서 state를 변경할 때 이전 state 값을 사용하려면 setter 함수에서 콜백을 사용해서 이전 state를 사용해야 함.

useState 정리

초기값 지정하기 : useState값에 함수 넣기, 콜백으로 초기값 지정

 

useState값에 함수 넣기

const [state, setState] = useState(initialState);
  • state : state 값
  • setState() : state를 변경하는 함수

콜백으로 초기값 지정

const [state, setState] = useState(() => {
  // 초기값을 계산
  return initialState;
});

 

function ReviewForm() {
  const savedValues = getSavedValues(); // ReviewForm을 렌더링할 때마다 실행됨
  const [values, setValues] = useState(savedValues);
  // ...
}

이렇게 쓰면 처음 렌더링될 때마다 getSavedValues()가 실행되는 문제점이 있음.

 

function ReviewForm() {
  const [values, setValues] = useState(() => {
    const savedValues = getSavedValues(); // 처음 렌더링할 때만 실행됨
    return savedValues
  });
  // ...
}

여기다가 useState()안에 콜백 형태로 초기값을 지정해주면 
이 콜백은 처음 렌더링될 때만 실행되기 때문에 getSavedValue()가 한번만 실행됨.

 

Setter 함수 사용하기 : 기본, 콜백으로 state 변경

기본

  • 여기서 state값이 참조형(배열, 객체)인 경우 반드시 새로운 값을 만들어서 전달해야함.
const [state, setState] = useState(0);

const handleAddClick = () => {
  setState(state + 1);
}

 

잘못된 예시

const [state, setState] = useState({ count: 0 });

const handleAddClick = () => {
  state.count += 1; // 참조형 변수의 프로퍼티를 수정
  setState(state); // 참조형이기 때문에 변수의 값(레퍼런스)는 변하지 않음
}

 

올바른 예시

const [state, setState] = useState({ count: 0 });

const handleAddClick = () => {
  setState({ ...state, count: state.count + 1 }); // 새로운 객체 생성
}

 

콜백으로 state 변경

setState((prevState) => {
	// 다음 state 값을 계산
	return nextState;
})
  • 비동기 함수에서 State를 변경하게 되면 최신 값이 아닌 State 값을 참조하게 됨.
  • setter 함수 안에 콜백으로 이전 state값 참조하게 해야됨!
  • 이전 State값으로 새로운 State를 만드는 경우엔 항상 콜백 형태를 사용하면 됨.
const [count, setCount] = useState(0);

const handleAddClick = async () => {
	await addCount();
	setCount((prevCount) => prevCount + 1);
}

여기서 prevCount는 바로 직전 state값임.


네트워크 로딩 처리하기

현재 네트워크가 리퀘스트 중이면 true, 아니면 false값을 갖는 state 제작

    const [isLoading, setIsLoading] = useState(false);

현재 네트워크가 리퀘스트 중이면 true, 아니면 false값을 갖는 state 제작

    const [isLoading, setIsLoading] = useState(false);
const handleLoad = async (options) => {
        let result;
        try {
            setIsLoading(true);
            result = await getReviews(options);
        } catch (error) {
            console.error(error);
            return;
        } finally {  // 오류가 나서 리턴되어도 finally 블록은 실행됨.
            setIsLoading(false);
        }

원래 '더보기' 버튼을 누르면 네트워크 요청이 도착하기 전까지 같은 요청이 여러번 전달되었음.

그런데 이제 로딩중 상태에는 요청이 못 가도록 설정됨. 

(더보기 버튼을 누르지 못하도록 비활성화됨)


네트워크 에러 처리하기

리퀘스트를 보냈는데 실패하거나, 에러 리스폰스가 도착하는 경우는 어떻게 할까?

→ 잘못된 값으로 state 값을 변경하거나, 심한 경우에는 동작이 멈출 수도 있음.

 

api.js

export async function getReviews({
    order = 'createdAt', 
    offset = 0, 
    limit = 6
}) {
    throw new Error('버그가 아니라 기능입니다');
    const query = `order=${order}&offset=${offset}&limit=${limit}`;
    const response = await fetch(
        `http://learn.codeit.kr/api/film-reviews?${query}`
    );
    const body = await response.json();
    return body;
}

api 통신을 하는 곳에서 일부러 에러를 던진다.

이걸 handleload에서 catch문으로 console.error로 에러가 뜨게 한다.

 

    const handleLoad = async (options) => {
        let result;
        try {
            setIsLoading(true);
            setLoadingError(null);
            result = await getReviews(options);
        } catch (error) {
            setLoadingError(error);
            return;
        } finally {  // 오류가 나서 리턴되어도 finally 블록은 실행됨.
            setIsLoading(false);
        }
        const { reviews, paging } = await getReviews(options);
        if (options.offset === 0) {
            setItems(reviews);
        } else {
            setItems((prevItems) => [...prevItems, ...reviews]);
        }
        setOffset(options.offset + reviews.length);
        setHasNext(paging.hasNext);
    };

입력 폼 다루기

리액트에서 입력 폼 만들기

  • 리액트에서는 주로 input 값을 state로 관리함.
    state 값과 input값을 동일하게 만드는 것이 핵심(제어 컴포넌트)

리액트 개발자 도구에 있는 Components 탭을 보면 ReviewForm 컴포넌트에 state가 보임.

input의 값을 변경해보면 state에 잘 반영되는 것을 확인할 수 있다.

 

import { useState } from "react";
import './ReviewForm.css';

function ReviewForm() {
    const [title, setTitle] = useState('');
    const [rating, setRating] = useState(0);
    const [content, setContent] = useState('');

    const handleTitleChange = (e) => {
        setTitle(e.target.value);
    };

    const handleRatingChange = (e) => {
        const nextRating = Number(e.target.value) || 0;
        setRating(nextRating);
    };

    const handleContentChange = (e) => {
        setContent(e.target.value);
    };

    return (
        <form className="ReviewForm">
                <input 
                    value={title}
                    onChange={handleTitleChange} />
                <input 
                    type="number"
                    value={rating}
                    onChange={handleRatingChange} />
                <textarea 
                    value={content} 
                    onChange={handleContentChange} />
        </form>
    )
}

export default ReviewForm;
.ReviewForm {
    display: flex;
    flex-direction: column;
    padding: 10px 0;
}

/* 마지
  • HTML에서는 사용자가 입력할 떄마다 onInput이라는 이벤트가 발생했음.
    onChange 이벤트는 사용자가 입력이 끝날때 발생하는 이벤트
    이 상황에서 input태그에 oninput을 써야되지 않나 생각이 들지만,
    리액트에서는 onChange라는 pops을 사용함.
  • 리액트의 onChange는 순수 자바스크립트에서 onChange 이벤트랑 다르게 동작함.
    onInput처럼 사용자가 값을 입력할 때마다 onChange 이벤트가 발생함.
    리액트를 만든 개발자들이 onChange라는 이름이 좀 더 직관적이라고 생각해서 이렇게 만들었다고 함.

onSubmit

HTML form 태그의 기본동작은 submit 버튼을 눌렀을 때 입력 폼의 값과 함께 GET 리퀘스트를 보내는 것임.
→ event 객체의 preventDefault 함수로 해결할 수 있음.

import { useState } from "react";
import './ReviewForm.css';

function ReviewForm() {
    const [title, setTitle] = useState('');
    const [rating, setRating] = useState(0);
    const [content, setContent] = useState('');

    const handleTitleChange = (e) => {
        setTitle(e.target.value);
    };

    const handleRatingChange = (e) => {
        const nextRating = Number(e.target.value) || 0;
        setRating(nextRating);
    };

    const handleContentChange = (e) => {
        setContent(e.target.value);
    };

    const handleSubmit = (e) => {
        e.preventDefault();
        console.log({
            title,
            rating,
            content,
        });
    }

    return (
        <form 
            className="ReviewForm"
            onSubmit={handleSubmit}>
                <input 
                    value={title}
                    onChange={handleTitleChange} />
                <input 
                    type="number"
                    value={rating}
                    onChange={handleRatingChange} />
                <textarea 
                    value={content} 
                    onChange={handleContentChange} />
                <button 
                    type="submit">확인</button>
        </form>
    )
}

export default ReviewForm;

input 태그에 커서를 클릭하고 엔터를 누르면 submit 이벤트가 발생함.  
(textarea는 아님!)


하나의 state로 폼 구현하기

각각의 state로 관리하는 게 아니라 하나의 state로 바꾸고 싶음.

이벤트 객체에서 name을 가져올 수 있다는 점을 활용하면 됨.

 

브라우저에서 event 객체를 출력해보면 target이 있음.

저기서 'input' 클릭하면 input으로 이동함.

 

그니까 이벤트 객체의 target의 name과 value는 input 태그의 것과 같다는 말임.

 

import { useState } from "react";
import './ReviewForm.css';

function ReviewForm() {
    const [values, setValues] = useState({
        title: '',
        rating: 0,
        content: '',
    });

    const handleChange = (e) => {
        const { name, value } = e.target;
        setValues((prevValues) => ({
            ...prevValues,
            [name]: value,
        }));
    }

    const handleSubmit = (e) => {
        e.preventDefault();
        console.log(values);
    }

    return (
        <form 
            className="ReviewForm"
            onSubmit={handleSubmit}>
                <input 
                    name="title"
                    value={values.title}
                    onChange={handleChange} />
                <input 
                    name="rating"
                    type="number"
                    value={values.rating}
                    onChange={handleChange} />
                <textarea 
                    name="content"
                    value={values.content} 
                    onChange={handleChange} />
                <button 
                    type="submit">확인</button>
        </form>
    )
}

export default ReviewForm;

입력폼에서 여러 개 관리하던 객체를 하나의 state로 변경할 수 있음.


제어 컴포넌트와 비제어 컴포넌트

제어 컴포넌트(Controlled Component) : 인풋의 value값을 리액트에서 지정

import { useState } from "react";

function MyComponent() {
  const [value, setValue] = useState("");

  const handleChange = (e) => {
    const nextValue = e.target.value.toUpperCase();
    setValue(nextValue);
  };

  return <input value={value} onChange={handleChange} />;
}

function App() {
  return (
    <div>
      <MyComponent />
    </div>
  );
}

export default App;

target.value값을 대문자로 바꿔서 state에 반영해줌.

input 폼 안에 입력하면 대문자로 글자가 나옴.

 

비제어 컴포넌트(Uncontrolled Component) : 인풋의 value 값을 리액트에서 지정하지 않음.

import { useState } from "react";

function MyComponent() {
  const [value, setValue] = useState("");

  const handleChange = (e) => {
    const nextValue = e.target.value.toUpperCase();
    setValue(nextValue);
  };

  return <input onChange={handleChange} />;
}

function App() {
  return (
    <div>
      <MyComponent />
    </div>
  );
}

export default App;

소문자로 react를 입력했을 때 소문자 react라고 나오는데,

리액트 개발자 도구에서 state값을 확인해보면 'REACT'라고 대문자로 나와있음.

리액트에서 사용하는 값(REACT)와 실제 인풋값(react)이 다름.

 

폼 태그의 값을 참조하려면 리액트의 prop이 아닌 이벤트 객체를 활용

const handleSubmit = (e) => {
  e.preventDefault();
  const form = e.target;
  const location = form['location'].value;
  const checkIn = form['checkIn'].value;
  const checkOut = form['checkOut'].value;
  // ....
}

폼 태그로 곧바로 formdata를 만들 수 있음.

const handleSubmit = (e) => {
  e.preventDefault();
  const form = e.target;
  const formData = new FormData(form);
  // ...
}

 

상위 컴포넌트에서 받은 prop으로 input값을 내려주는 경우 (제어 컴포넌트)

import { useState } from "react";

function MyComponent({ value, onChange }) {
  const handleChange = (e) => {
    const nextValue = e.target.value.toUpperCase();
    onChange(nextValue);
  };

  return <input value={value} onChange={handleChange} />;
}

function App() {
  const [value, setValue] = useState("");

  const handleClear = () => setValue("");

  return (
    <div>
      <MyComponent value={value} onChange={setValue} />
      <button onClick={handleClear}>지우기</button>
    </div>
  );
}

export default App;

 

App 컴포넌트에서 MyComponent 컴포넌트로 value와 onChange props를 전달함.

  • value : MyComponent에서 입력 필드의 으로 사용될 상태
  • onChange : MyComponent에서 입력 필드의 값이 변경될 때 호출할 콜백함수, 새로운 값으로 업데이트된 입력 필드의 값을 전달함.

MyComponent를 제어 컴포넌트로 만들었기 때문에 항상 input의 값이 value prop의 값, 
즉 App 컴포넌트에 있는 value state값과 같음.

그래서 지우기 버튼을 누르면 실제 input의 값도 지워질 것이라고 예측할 수 있음.

제어 컴포넌트에서는 REACT에서의 값과 실제 input의 값이 항상 일치하기 때문에 동작을 예측하기가 쉽고
input값을 여러 군데서 쉽게 바꿀 수 있다는 장점이 있음.

 

요약

제어 컴포넌트 비제어 컴포넌트
· 인풋의 value값을 리액트에서 지정
· 리액트에서 사용하는 값과 실제 인풋 값이 항상 일치
· 주로 권장되는 방법
· 인풋의 value값을 리액트에서 지정하지 않음 
· 경우에 따라서 필요한 방법 (파일 인풋)

파일 인풋

FileInput은 value prop을 지정할 수 없어서 반드시 비제어 컴포넌트로 만들어야 함.

이 파일 객체를 활용하면 네트워크로 파일을 전송하거나 이미지 미리보기를 만들 수 있음.

 

 

A component is changing an uncontrolled input to be controlled.

 

비제어 인풋을 제어하려고 해서 그렇다.
→ 파일 인풋은 반드시 비제어 인풋으로 만들어야 하기 때문에 발생한 오류임.
스택 트레이스(stack trace)에서 at input, FileInput이 나옴.

This input element accepts a filename, which may only be programmatically set to the empty string.

 

자바스크립트로는 빈 문자열로 밖에 설정할 수 없다고 함.
보안문제 때문에 HTML에서 파일 인풋은 반드시 사용자만 값을 바꿀 수 있음.

실제 파일 경로가 아니라 fakepath라는 문자열이 출력됨.

보안을 위해서 웹 브라우저는 사용자의 파일 경로를 숨겨주는 것임.

 

State Lifting

FileInput의 State를 props로 바꾸고, ReviewForm 컴포넌트에 있는 State를 props로 내려줄 것임.

function FileInput({ name, value, onChange }) {
    const handleChange = (e) => {
        const nextValue = e.target.files[0];
        onChange(name, nextValue);
    };

    return <input type="file" onChange={handleChange} />;
}

export default FileInput;

파일 인풋에서는 이벤트 객체의 target.value 값이 아니라 target.files를 사용함.
그래서 handleChange()라는 함수를 만들고, 첫 번째 파일을 nextValue라는 변수로 지정한 다음에 이걸 onChange() 함수로 name과 함께 넘겨주면 됨.


ref로 DOM 노드 가져오기

ref : 원하는 시점에 실제 DOM 노드에 접근하고 싶을 때 사용할 수 있는 prop

import { useRef, useEffect } from "react";

function FileInput({ name, value, onChange }) {
    const inputRef = useRef();

    const handleChange = (e) => {
        const nextValue = e.target.files[0];
        onChange(name, nextValue);
    };

    useEffect(() => {
        console.log(inputRef);
    }, []);

    return <input type="file" onChange={handleChange} ref={inputRef}/>;
}

export default FileInput;

inputRef라는 이름을 가진 useRef 훅은 input 요소에 대한 참조를 생성함.
이렇게 생성된 참조는 input요소를 가리키는 객체임.

 

DOM 노드는 반드시 렌더링이 끝나야 생기니까
ref 객체의 current 값도 화면에 컴포넌트가 렌더링 되었을 때만 존재함.

조건부 렌더링으로 컴포넌트가 사라지거나 하는 경우에는 이 값이 없을 수도 있음.

그래서 항상 inputRef의 current값이 있는지 확인하고 사용해야 함.

    useEffect(() => {
        if (inputRef.current) {
            console.log(inputRef);
        }
    }, []);

파일 인풋 초기화

FileInput의 value 속성은 사용자만 직접 바꿀 수 있고, 자바스크립트로 바꿀 때는 빈 문자열로만 바꿀 수 있음.
value 속성을 빈 문자열로 바꿔주면 선택한 파일이 초기화됨.

import { useRef } from "react";

function FileInput({ name, value, onChange }) {
    const inputRef = useRef();

    const handleChange = (e) => {
        const nextValue = e.target.files[0];
        onChange(name, nextValue);
    };

    const handleClearClick = () => {
        // useRef를 통해 생성한 참조를 사용하여 input 요소를 가져옴.
        const inputNode = inputRef.current;
        if (!inputNode) return;

        // input 요소의 값(value)을 빈 문자열로 설정하여 파일을 선택하지 않은 상태로 만듦.
        inputNode.value = '';
        // 파일 입력값 초기화한 후,
        // 부모컴포넌트로부터 전달된 onChange 함수를 호출하여 값 초기화를 알림.
        onChange(name, null);
    }

    return (
        <div>
            <input type="file" onChange={handleChange} ref={inputRef} />
            {value && <button onClick={handleClearClick}>X</button>}
        </div>
    );
}

export default FileInput;


ref와 useRef

Ref 객체 생성

import { useRef } from 'react';

const ref = useRef();

useRef를 호출하면 리액트는 ref 객체를 반환한다.

이 ref 객체는 컴포넌트의 생애주기 동안 유지되며, 해당 DOM 요소나 React 컴포넌트 인스턴스를 가리키는데 사용된다.

 

ref Prop 사용하기

<div ref={ref}> ... </div>

ref를 사용하여 DOM요소에 접근하려면 해당 요소'ref' 속성을 설정해야함.

React는 컴포넌트가 렌더링될 때 ref 속성이 설정된 DOM요소에 대한 참조를 ref 객체의 current 속성에 할당함.

 

Ref 객체에서 DOM 노드 참조하기

const node = ref.current;
if (node) {
	// node를 사용하는 코드
}

ref.current를 사용하여 DOM 노드를 참조할 수 있음.

current값은 없을 수 있으니 반드시 값이 존재하고 검사하기


이미지 파일 미리보기

ObjectURL

  • 파일 객체를 ObjectURL로 만들면 파일에 대한 주소를 만들 수 있음.
  • 인터넷에 올린 파일 링크 같이 사용자 컴퓨터에 있는 파일을 주소로 사용할 수 있음.

useEffect

  • 처음 렌더링을 하고 난 다음 비동기로 콜백함수가 실행됨.
  • 그 다음 렌더링때부터는 dependency의 값이 바뀔 때만 콜백함수가 실행됨.
import { useRef, useEffect, useState } from "react";

function FileInput({ name, value, onChange }) {
    const [preview, setPreview] = useState();

    const inputRef = useRef();

    const handleChange = (e) => {
        const nextValue = e.target.files[0];
        onChange(name, nextValue);
    };

    const handleClearClick = () => {
        const inputNode = inputRef.current;
        if (!inputNode) return;

        inputNode.value = '';
        onChange(name, null);
    }

    // 파일을 선택할 때마다 value 값을 바꿀 것임.
    useEffect(() => {
        // 값이 없을 경우 실행되지 않음
        if (!value) return;
    
        // 파일 객체를 URL.createObjectURL을 사용하여 프리뷰 URL로 변환
        const nextPreview = URL.createObjectURL(value);
        
        // 프리뷰를 설정하여 컴포넌트 상태를 업데이트
        setPreview(nextPreview);
    }, [value])
    
    return (
        <div>
            <img src={preview} alt="이미지 미리보기" />
            <input type="file" onChange={handleChange} ref={inputRef} />
            {value && <button onClick={handleClearClick}>X</button>}
        </div>
    );
}

export default FileInput;

ObjectURL을 만들면 웹 브라우저는 메모리를 할당하고, 파일에 해당하는 주소를 만들어줌.

만든 FileInput 컴포넌트는 렌더링하는 과정에서 리액트 외부의 상태를 바꾸게 됨.

 

컴포넌트 함수에서 외부의 상태를 바꾸는 것 = side effect

ex. 네트워크 리퀘스트 (웹브라우저의 상태를 바꿔서 리스폰스를 받아옴)


사이드 이펙트 정리하기

ObjectURL은 만들 때마다 웹 브라우저의 메모리를 할당함.

그런데 파일을 선택할 때마다 메모리를 할당하기만 하면 메모리가 낭비됨.

다른 파일을 선택하거나 파일을 해제했을 때 메모리도 같이 해제해 줘야됨.

revokeObjectURL 함수 사용 : 메모리 할당 해제, 사이트 이펙트 정리

이 useEffect 함수는 나중에 디펜던시 리스트 값이 바뀌어서 새로 콜백을 실행하게 되는데, 새로 콜백을 실행하기 전 앞에서 리턴한 정리 함수를 실행해서 사이드 이펙트를 정리함.

import { useRef, useEffect, useState } from "react";

function FileInput({ name, value, onChange }) {
    const [preview, setPreview] = useState();

    const inputRef = useRef();

    const handleChange = (e) => {
        const nextValue = e.target.files[0];
        onChange(name, nextValue);
    };

    const handleClearClick = () => {
        const inputNode = inputRef.current;
        if (!inputNode) return;

        inputNode.value = '';
        onChange(name, null);
    }

    useEffect(() => {
        if (!value) return;
    
        const nextPreview = URL.createObjectURL(value);
        setPreview(nextPreview);

        // 함수에서 사이트 이펙트 정리
        return () => {
            setPreview();
            URL.revokeObjectURL(nextPreview);
        }
    }, [value])

    return (
        <div>
            <img src={preview} alt="이미지 미리보기" />
            <input type="file" accept="image/png, image/jpeg" onChange={handleChange} ref={inputRef} />
            {value && <button onClick={handleClearClick}>X</button>}
        </div>
    );
}

export default FileInput;


사용자가 파일을 선택
→ value prop의 값이 바뀌고 재랜더링이 시작됨.
→  useEffect를 실행

  • 디펜던시 리스트에 있는 value값이 변경되었으니까 리액트는 렌더링이 끝나고 나서 콜백함수를 실행해줌.
  • 콜백함수에는 ObjectURL을 만들고 preview State를 변경하는 코드가 있는데, 이때 sideEffect가 발생함.
    ObjectURL을 만들면서 웹 브라우저가 할당한 메모리가 바로 사이드 이펙트임.
  • 콜백함수는 마지막으로 정리함수를 리턴함.
    preview state를 빈값으로 만든 다음에 ObjectURL을 해제함.

→ 리액트는 이 정리함수를 기억해둠.

파일 인풋을 이미지 파일 하나만 선택하는 데 사용할 것이기 때문에
input에다가 accept 속성만 추가해줌.

 

요약

  • useEffect의 콜백 함수에서 이렇게 정리함수를 리턴하면 ObjectURL을 더 이상 사용하지 않을 때 해제할 수 있음.

사이드 이펙트와 useEffect

사이드 이펙트 : 함수 안에서 함수 바깥에 있는 값이나 상태를 변경하는 것

 

useEffect

  • 리액트 컴포넌트 함수 안에서 사이드 이펙트를 실행하고 싶을 때 사용하는 함수
  • 주로 리액트 외부에 있는 데이터나 상태를 변경할 때 사용
    ex. DOM 노드를 직접 변경, 브라우저에 데이터를 저장, 네트워크에 리쿼스트를 보냄.

 

페이지 정보 변경

useEffect(() => {
	document.title = title;  // 페이지 데이터를 변경
}, [title]);

 

네트워크 요청

useEffect(() => {
  fetch('https://example.com/data') // 외부로 네트워크 리퀘스트
    .then((response) => response.json())
    .then((body) => setData(body));
}, [])

 

데이터 저장

useEffect(() => {
  localStorage.setItem('theme', theme); // 로컬 스토리지에 테마 정보를 저장
}, [theme]);

 

타이머

useEffect(() => {
  const timerId = setInterval(() => {
    setSecond((prevSecond) => prevSecond + 1);
  }, 1000); // 1초마다 콜백 함수를 실행하는 타이머 시작
  
  return () => {
    clearInterval(timerId);
  }
}, []);

 

핸들러 함수만 사용

import { useState } from 'react';

const INITIAL_TITLE = 'Untitled';

function App() {
  const [title, setTitle] = useState(INITIAL_TITLE);

  const handleChange = (e) => {
    const nextTitle = e.target.value;
    setTitle(nextTitle);
    document.title = nextTitle;
  };

  const handleClearClick = () => {
    const nextTitle = INITIAL_TITLE;
    setTitle(nextTitle);
    document.title = nextTitle;
  };

  return (
    <div>
      <input value={title} onChange={handleChange} />
      <button onClick={handleClearClick}>초기화</button>
    </div>
  );
}

export default App;

 

useEffect를 사용

import { useEffect, useState } from 'react';

const INITIAL_TITLE = 'Untitled';

function App() {
  const [title, setTitle] = useState(INITIAL_TITLE);

  const handleChange = (e) => {
    const nextTitle = e.target.value;
    setTitle(nextTitle);
  };

  const handleClearClick = () => {
    setTitle(INITIAL_TITLE);
  };

  useEffect(() => {
    document.title = title;
  }, [title]);

  return (
    <div>
      <input value={title} onChange={handleChange} />
      <button onClick={handleClearClick}>초기화</button>
    </div>
  );
}

export default App;

 

결론

setTitle 함수를 쓸 때마다 document.title 을 변경하는 코드를 신경 쓰지 않아도 됨.
처음 렌더링될 때 Untitled라고 페이지 제목을 변경할 수 있음.

 

콜백 1회 실행 → 정리함수 1회 시행
- 새로운 콜백 함수가 호출되기 전에 실행(앞에서 실행한 콜백의 사이드 이펙트를 정리)
- 혹은 컴포넌트가 화면에서 사라지기 전에 실행(맨 마지막으로 실행한 콜백의 사이드 이펙트를 정리).


별점 컴포넌트 만들기

Rating.js

import './Rating.css';

const RATINGS = [1, 2, 3, 4, 5];

function Star({ selected = false }) {
    const className = `Rating-star ${selected ? 'selected' : ''}`;
    return <span className={className}>★</span>;
}

function Rating({ value = 0 }) {
    return (
        <div>
            {RATINGS.map((rating) => (             
                <Star key={rating} selected={value >= rating} />
            ))}
        </div>
    );
}

export default Rating;


각 별점이 나타내는 요소를 배열로 만듦.


나만의 별점 인풋 만들기

import { useState } from "react"; 
import Rating from './Rating';
import './RatingInput.css';

function RatingInput({ name, value, onChange }) {
    // 선택한 별점을 보여주거나 마우스를 올렸을 때 별점을 미리 보여주는데 사용하는 함수
    const [rating, setRating] = useState(value);

    const handleSelect = (nextValue) => onChange(name, nextValue);

    const handleMouseOut = () => setRating(value);

    return (
        <Rating 
            className="RatingInput"
            value={rating} 
            onSelect={handleSelect} 
            onHover={setRating} 
            onMouseOut={handleMouseOut}></Rating>
    );
}

export default RatingInput;

728x90