React 데이터 다루기 (4)

깨진알·2023년 12월 29일

React

목록 보기
6/12

데이터 보내기

1. 글 작성하기

// api.js
const BASE_URL = 'https://learn.codeit.kr/api';

export async function getReviews({
  order = 'createdAt',
  offset = 0,
  limit = 6,
}) {
  const query = `order=${order}&offset=${offset}&limit=${limit}`;
  const response = await fetch(`${BASE_URL}/film-reviews?${query}`);
  if (!response.ok) {
    throw new Error('리뷰를 불러오는데 실패했습니다');
  }
  const body = await response.json();
  return body;
}

export async function createReview(formData) {
  const response = await fetch(`${BASE_URL}/film-reviews`, {
    method: 'POST',
    body: formData,
  });
  if (!response.ok) {
    throw new Error('리뷰를 생성하는데 실패했습니다.');
  }
  const body = await response.json();
  return body;
}

// ReviewForm.js
const handleSubmit = async (e) => {
  e.preventDefault();
  const formData = new FormData();
  formData.append('title', values.title);
  formData.append('rating', values.rating);
  formData.append('content', values.content);
  formData.append('imgFile', values.imgFile);
  try {
    setSubmittingError(null);
    setIsSubmitting(true);
    await createReview(formData);
  } catch (error) {
    setSubmittingError(error);
    return;
  } finally {
    setIsSubmitting(false);
  }
  setValues(INITIAL_VALUES);
};

2. 리스폰스 데이터 반영하기

// App.js
const handleSubmitSuccess = (review) => {
  setItems((prevItems) => [review, ...prevItems]);
};

3. 글 수정하기 I

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

function formatDate(value) {
  const date = new Date(value);
  return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}`;
}

function ReviewListItem({ item, onDelete, onEdit }) {
  const handleDeleteClick = () => {
    onDelete(item.id);
  };

  const handleEditClick = () => {
    onEdit(item.id);
  };

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

function ReviewList({ items, onDelete }) {
  const [editingId, setEditingId] = useState(null);

  const handleCancel = () => setEditingId(null);

  return (
    <ul>
      {items.map((item) => {
        if (item.id === editingId) {
          const { imgUrl, title, rating, content } = item;
          const initialValues = { title, rating, content, imgFile: null };
          return (
            <li key={item.id}>
              <ReviewForm
                initialValues={initialValues}
                initialPreview={imgUrl}
                onCancel={handleCancel}
              />
            </li>
          );
        }
        return (
          <li key={item.id}>
            <ReviewListItem
              item={item}
              onDelete={onDelete}
              onEdit={setEditingId}
            />
          </li>
        );
      })}
    </ul>
  );
}

export default ReviewList;

4. 글 수정하기 II

// ReviewForm.js
import { useState } from 'react';
import { createReview } from '../api';
import FileInput from './FileInput';
import RatingInput from './RatingInput';
import './ReviewForm.css';

const INITIAL_VALUES = {
  title: '',
  rating: 0,
  content: '',
  imgFile: null,
};

function ReviewForm({
  initialValues = INITIAL_VALUES,
  initialPreview,
  onCancel,
  onSubmit,
  onSubmitSuccess,
}) {
  const [values, setValues] = useState(initialValues);
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submittingError, setSubmittingError] = useState(null);

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

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    handleChange(name, value);
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const formData = new FormData();
    formData.append('title', values.title);
    formData.append('rating', values.rating);
    formData.append('content', values.content);
    formData.append('imgFile', values.imgFile);
    let result;
    try {
      setSubmittingError(null);
      setIsSubmitting(true);
      result = await onSubmit(formData);
    } catch (error) {
      setSubmittingError(error);
      return;
    } finally {
      setIsSubmitting(false);
    }
    const { review } = result;
    setValues(INITIAL_VALUES);
    onSubmitSuccess(review);
  };

  return (
    <form className="ReviewForm" onSubmit={handleSubmit}>
      <FileInput
        name="imgFile"
        value={values.imgFile}
        initialPreview={initialPreview}
        onChange={handleChange}
      />
      <input name="title" value={values.title} onChange={handleInputChange} />
      <RatingInput
        name="rating"
        value={values.rating}
        onChange={handleChange}
      />
      <textarea
        name="content"
        value={values.content}
        onChange={handleInputChange}
      />
      {onCancel && <button onClick={onCancel}>취소</button>}
      <button disabled={isSubmitting} type="submit">
        확인
      </button>
      {submittingError && <div>{submittingError.message}</div>}
    </form>
  );
}

export default ReviewForm;

// App.js
import { useEffect, useState } from 'react';
import ReviewList from './ReviewList';
import ReviewForm from './ReviewForm';
import { createReview, getReviews, updateReview } from '../api';

const LIMIT = 6;

function App() {
  const [order, setOrder] = useState('createdAt');
  const [offset, setOffset] = useState(0);
  const [hasNext, setHasNext] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [loadingError, setLoadingError] = useState(null);
  const [items, setItems] = 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 (options) => {
    let result;
    try {
      setLoadingError(null);
      setIsLoading(true);
      result = await getReviews(options);
    } catch (error) {
      setLoadingError(error);
      return;
    } finally {
      setIsLoading(false);
    }

    const { paging, reviews } = result;
    if (options.offset === 0) {
      setItems(reviews);
    } else {
      setItems((prevItems) => [...prevItems, ...reviews]);
    }
    setOffset(options.offset + options.limit);
    setHasNext(paging.hasNext);
  };

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

  const handleCreateSuccess = (review) => {
    setItems((prevItems) => [review, ...prevItems]);
  };

  const handleUpdateSuccess = (review) => {
    setItems((prevItems) => {
      const splitIdx = prevItems.findIndex((item) => item.id === review.id);
      return [
        ...prevItems.slice(0, splitIdx),
        review,
        ...prevItems.slice(splitIdx + 1),
      ];
    });
  };

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

  return (
    <div>
      <div>
        <button onClick={handleNewestClick}>최신순</button>
        <button onClick={handleBestClick}>베스트순</button>
      </div>
      <ReviewForm
        onSubmit={createReview}
        onSubmitSuccess={handleCreateSuccess}
      />
      <ReviewList
        items={sortedItems}
        onItemDelete={handleDelete}
        onUpdate={updateReview}
        onUpdateSuccess={handleUpdateSuccess}
      />
      {hasNext && (
        <button disabled={isLoading} onClick={handleLoadMore}>
          더 보기
        </button>
      )}
      {loadingError?.message && <span>{loadingError.message}</span>}
    </div>
  );
}

export default App;

// api.js
const BASE_URL = 'https://learn.codeit.kr/api';

export async function getReviews({
  order = 'createdAt',
  offset = 0,
  limit = 6,
}) {
  const query = `order=${order}&offset=${offset}&limit=${limit}`;
  const response = await fetch(`${BASE_URL}/film-reviews?${query}`);
  if (!response.ok) {
    throw new Error('리뷰를 불러오는데 실패했습니다');
  }
  const body = await response.json();
  return body;
}

export async function createReview(formData) {
  const response = await fetch(`${BASE_URL}/film-reviews`, {
    method: 'POST',
    body: formData,
  });
  if (!response.ok) {
    throw new Error('리뷰를 생성하는데 실패했습니다.');
  }
  const body = await response.json();
  return body;
}

export async function updateReview(id, formData) {
  const response = await fetch(`${BASE_URL}/film-reviews/${id}`, {
    method: 'PUT',
    body: formData,
  });
  if (!response.ok) {
    throw new Error('리뷰를 수정하는데 실패했습니다.');
  }
  const body = await response.json();
  return body;
}

5. 글 삭제하기

// api.js
export async function deleteReview(id) {
  const response = await fetch(`${BASE_URL}/film-reviews/${id}`, {
    method: 'DELETE',
  });
  if (!response.ok) {
    throw new Error('리뷰를 삭제하는데 실패했습니다.');
  }
  const body = await response.json();
  return body;
}

// App.js
const handleDelete = async (id) => {
  const result = await deleteReview(id);
  if (!result) return;

  setItems((prevItems) => prevItems.filter((item) => item.id !== id));
};

6. 리액트 Hook

프로그래밍에서 Hook이란, 작성한 코드를 다른 프로그램에 연결해서 그 값이나 기능을 사용하는 것을 의미한다.


7. 리액트 Hook의 규칙

  1. 리액트 컴포넌트 함수나 커스텀훅 함수 안에서만 작성해야 한다.
  2. 반드시 함수의 최상위에서 사용해야 한다. (반복문, 조건문 안에서 사용하면 안된다.)

8. 나만의 Hook으로 코드 정리하기

import { useState } from 'react';

function useAsync(asyncFunction) {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState(null);

  const wrappedFunction = async (...args) => {
    setPending(true);
    setError(null);
    try {
      return await asyncFunction(...args);
    } catch (error) {
      setError(error);
    } finally {
      setPending(false);
    }
  };

  return [pending, error, wrappedFunction];
}

export default useAsync;

9. useCallback

const handleLoad = useCallback(
  async (options) => {
    const result = await getReviewsAsync(options);
    if (!result) return;

    const { paging, reviews } = result;
    if (options.offset === 0) {
      setItems(reviews);
    } else {
      setItems((prevItems) => [...prevItems, ...reviews]);
    }
    setOffset(options.offset + options.limit);
    setHasNext(paging.hasNext);
  },
  [getReviewsAsync]
);

10. 빠짐없는 디펜던시 (exhaustive-deps)

(1) exhaustive-deps 규칙

아래 코드는 num 버튼을 누르면 num 스테이트 값이 증가되고, count 버튼을 누르면 count 스테이트 값을 증가시키는 컴포넌트이다. 이때 count 스테이트 값을 증가시키면서 콘솔에는 num 스테이트 값을 출력한다. useEffect Hook에서는 1초마다 addCount 함수를 실행하는 타이머를 실행한다.

import { useEffect, useState } from 'react';

function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);

  const addCount = () => {
    setCount(c => c + 1);
    console.log(`num: ${num}`);
  }

  const addNum = () => setNum(n => n + 1);

  useEffect(() => {
    console.log('timer start');
    const timerId = setInterval(() => {
      addCount();
    }, 1000);

    return () => {
      clearInterval(timerId);
      console.log('timer end');
    };
  }, []);

  return (
    <div>
      <button onClick={addCount}>count: {count}</button>
      <button onClick={addNum}>num: {num}</button>
    </div>
  );
}

export default App;

1초마다 count 값이 증가하는데, 버튼을 클릭해서 num 스테이트의 값이 바뀌더라도 콘솔 출력에서는 숫자가 바뀌지 않고 0만 계속 출력된다는 문제가 있다. 그 이유는 useEffect 안에서 addCount라는 함수를 사용하는데, 이 함수에서는 num 스테이트 값을 잘못 참조하기 때문이다. 과거의 num 스테이트 값을 계속해서 참조하고 있기 때문이다. 이런 문제점을 경고해주는 규칙이 react-hooks/exhaustive-deps라는 규칙이다. 리액트에서는 Prop이나 State와 관련된 값을 되도록이면 빠짐없이 디펜던시에 추가해서 항상 최신 값으로 useEffectuseCallback을 사용하도록 권장하고 있다.

import { useEffect, useState } from "react";

function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);

  const addCount = () => {
    setCount((c) => c + 1);
    console.log(`num: ${num}`);
  };

  const addNum = () => setNum((n) => n + 1);

  useEffect(() => {
    console.log('timer start');
    const timerId = setInterval(() => {
      addCount();
    }, 1000);

    return () => {
      clearInterval(timerId);
      console.log('timer end');
    };
  }, [addCount]);

  return (
    <div>
      <button onClick={addCount}>count: {count}</button>
      <button onClick={addNum}>num: {num}</button>
    </div>
  );
}

export default App;

이렇게 작성하면 한 가지 문제가 있다. 실행해서 콘솔을 확인해보면 count가 바뀔 때마다 타이머를 새로 시작하고 종료하는 걸 반복한다는 걸 알 수 있다. addCount라는 함수는 렌더링 할 때마다 새로 만들어지는데, 이걸 디펜던시 리스트에 추가했기 때문에 useEffect의 콜백이 매번 불필요하게 실행되는 버그가 있는 것이다.

(2) useCallback으로 함수 재사용하기

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

function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);

  const addCount = useCallback(() => {
    setCount((c) => c + 1);
    console.log(`num: ${num}`);
  }, [num]);

  const addNum = () => setNum((n) => n + 1);

  useEffect(() => {
    console.log('timer start');
    const timerId = setInterval(() => {
      addCount();
    }, 1000);

    return () => {
      clearInterval(timerId);
      console.log('timer end');
    };
  }, [addCount]);

  return (
    <div>
      <button onClick={addCount}>count: {count}</button>
      <button onClick={addNum}>num: {num}</button>
    </div>
  );
}

export default App;

앞에서처럼 디펜던시 리스트에 추가한 함수가 매번 바뀌는 문제를 해결하려면 함수를 useCallback으로 감싸주면 된다. useCallback을 사용하면 함수를 매번 생성하는 게 아니라 리액트에다 함수를 기억해둘 수 있다. 이때 리액트는 useCallback의 디펜던시 리스트 값이 바뀔 때만 함수를 새로 만들어준다. addCount 함수에서는 num이라는 스테이트를 참조하고 있으니까 이 값을 디펜던시 리스트에 추가했다. 이렇게하면 리액트는 num 값이 바뀔 때만 addCount 함수를 새로 만들 것이다.

이런 식으로 컴포넌트 안에서 만든 함수를 디펜던시 리스트에 사용할 때는 useCallback 훅으로 매번 함수를 새로 생성하는 걸 막을 수 있다.

(3) 되도록이면 파라미터를 활용하자

사실 addCount라는 함수에서 num 값을 꼭 직접 참조할 필요는 없다. 그래서 useCallback을 쓰지 않고, 아래처럼 파라미터로 받아오게 할 수 있다. 이렇게 하면 addCount 함수 자체만 놓고 보면 바깥에 있는 스테이트 값을 직접적으로 참조하지 않기 때문에 오래된 스테이트 값을 참조할 염려가 없다.

import { useEffect, useState } from "react";

function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);

  const addCount = (log) => {
    setCount((c) => c + 1);
    console.log(log);
  }

  const addNum = () => setNum((n) => n + 1);

  useEffect(() => {
    console.log('timer start');
    const timerId = setInterval(() => {
      addCount(`num ${num}`);
    }, 1000);

    return () => {
      clearInterval(timerId);
      console.log('timer end');
    };
  }, [num]);

  return (
    <div>
      <button onClick={addCount}>count: {count}</button>
      <button onClick={addNum}>num: {num}</button>
    </div>
  );
}

export default App;

그리고 디펜던시 리스트를 이렇게 바꾸면 num 값이 바뀔 때마다 타이머를 재시작한다는 게 좀 더 명확해졌다. Prop이나 State 값을 사용할 때는 이렇게 되도록이면 파라미터로 넘겨서 사용하면, 어떻게 사용되는지 코드에서 명확하게 보여줄 수 있다.


11. 리액트 Hook 정리

(1) Hook의 규칙

  • 반드시 리액트 컴포넌트 함수(Functional Component) 안에서 사용해야 한다.
  • 컴포넌트 함수의 최상위에서만 사용이 가능하다. (조건문, 반복문 안에서는 쓰지 못한다.)

(2) useState

1. State 사용하기

const [state, setState] = useState(initialState);

2. 콜백으로 초깃값 지정하기

초깃값을 계산하는 코드가 복잡한 경우에 활용한다.

const [state, setState] = useState(() => {
  // ...
  return initialState;
});

3. State 변경

setState(nextState);

4. 이전 State를 참조해서 State 변경

비동기 함수에서 최신 State 값을 가져와서 새로운 State 값을 만들 때 사용한다.

setState((prevState) => {
  // ...
  return nextState
});

(3) useEffect

컴포넌트 함수에서 사이드 이펙트(리액트 외부의 값이나 상태를 변경할 때)에 활용하는 함수

1. 처음 렌더링 후에 한 번만 실행

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

2. 렌더링 후에 특정 값이 바뀌었으면 실행

참고로 처음 렌더링 후에도 한 번 실행된다.

useEffect(() => {
  // ...
}, [dep1, dep2, dep3, ...]);

3. 사이드 이펙트 정리(Cleanup)하기

useEffect(() => {
  // 사이드 이펙트

  return () => {
    // 정리
  }
}, [dep1, dep2, dep3, ...]);

(4) useRef

1. 생성하고 DOM 노드에 연결하기

const ref = useRef();

// ...

return <div ref={ref}>안녕 리액트!</div>;

2. DOM 노드 참조하기

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

(5) useCallback

함수를 매번 새로 생성하는 것이 아니라 디펜던시 리스트가 변경될 때만 함수를 생성한다.

const handleLoad = useCallback((option) => {
  // ...
}, [dep1, dep2, dep3, ...]);

(6) Custom Hook

자주 사용하는 Hook 코드들을 모아서 함수로 만들 수 있다. 이때 useOOO 처럼 반드시 맨 앞에 use라는 단어를 붙여서 다른 개발자들이 Hook이라는 걸 알 수 있게 해줘야 한다.

useHooks
streamich/react-hooks

(7) useAsync

비동기 함수의 로딩, 에러 처리를 하는 데 사용할 수 있는 함수이다. 함수를 asyncFunction 이라는 파라미터로 추상화해서 wrappedFunction 이라는 함수를 만들어 사용하는 방식을 눈여겨보시면 좋을 것 같다.

function useAsync(asyncFunction) {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState(null);

  const wrappedFunction = useCallback(async (...args) => {
    setPending(true);
    setError(null);
    try {
      return await asyncFunction(...args);
    } catch (error) {
      setError(error);
    } finally {
      setPending(false);
    }
  }, [asyncFunction]);

  return [pending, error, wrappedFunction];
}

(8) useToggle

toggle 함수를 호출할 때마다 value 값이 참/거짓으로 번갈아가면 바뀐다. ON/OFF 스위치 같은 걸 만들 때 유용하다.

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  const toggle = () => setValue((prevValue) => !prevValue);
  return [value, toggle];
}

(9) useTimer

start를 실행하면 callback이라는 파라미터로 넘겨준 함수를 timeout 밀리초 마다 실행하고, stop을 실행하면 멈춘다. setInterval이란 함수는 웹 브라우저에 함수를 등록해서 일정한 시간 간격마다 실행하는데, 실행할 때마다 사이드 이펙트를 만들고, 사용하지 않으면 정리를 해줘야 한다. clearInterval 이라는 함수를 실행해서 사이드 이펙트를 정리하는 부분을 눈여겨 보면 좋다.

function useTimer(callback, timeout) {
  const [isRunning, setIsRunning] = useState(false);

  const start = () => setIsRunning(true);

  const stop = () => setIsRunning(false);

  useEffect(() => {
    if (!isRunning) return;

    const timerId = setInterval(callback, timeout); // 사이드 이펙트 발생
    return () => {
      clearInterval(timerId); // 사이드 이펙트 정리
    };
  }, [isRunning, callback, timeout]);
  
  return [start, stop];
}
profile
프론트엔드 지식으로 가득찰 때까지

0개의 댓글