본문 바로가기

코드잇 스터디

[React] React로 데이터 다루기

배열과 데이터 렌더링

렌더링

React는 데이터(state)가 변하면 자동으로 화면 리렌더링을 한다. 

바닐라 JS는 "데이터 수정 + 화면수정" 두번 일해야하고 / React는 데이터 수정 한번이면 끝이다. 

 

⭐리렌더링이 일어나는 경우 (꼭 외워야함)

1. 상태가 바뀌면 해당 상태를 가진 컴포넌트는 리렌더링 된다
2.
부모가 리렌더링되면 자식도 리렌더링 된다
3.
컴포넌트는 props가 바뀌면 리렌더링된다

 

이 리렌더링을 위해서 

- 일반 데이터는 단순하게 setState해도 되지만

- 참조형 데이터는 새 참조형 데이터 (새 객체 / 배열)을 setState해줘야함

    -> 새 배열 리턴하는 map / filter / spread사용 (reduce도 가능)

- 왜냐하면 React가 바뀌었는지를 참조값을 기준으로 비교하기 때문이다. 같은 메모리 주소를 가리키면 react는 암 바뀌었다고 판단한다. 따라서 불변성(Immutability)를 지켜줘야한다. 

이때 key로는 read외의 다른 crud 작업을 고려해서 index보다는 id를 사용하는게 좋다.
index는 create/delete할 시에 요소가 index를 새로 받아서,       
key값이 바뀌어서 리렌더링이 많아지기 떄문이다
+ Math.random()은 리렌더링할때마다 새값을 만들어서 절대쓰면 안된다 (index보다 더 심함!!)
+ 서버에서 id를 주지 않으면 클라이언트에서 uuidv4()를 사용해서 id를 직접 만들어줘야한다. 
+ 어쩔수 없다면 다른 요소들을 조합해서 복합키를 만들면 되긴 하다 (추천 안함)

 

(+)다만, filter을 삭제 용도가 아닌, 순전히 filter(카테고리별 보여주기)용도로 사용할거면

state변경 없이 쓰는 것이 옳다. 

보다시피 아래 코드에 products.filter안에 setState가 없다. 

카테고리만 state로 지정해서 바꿔주고, 데이터는 고스란히 state 변경없이 filter해서 보여준다. 

function ProductList() {
  const [products, setProducts] = useState([
    { id: 1, name: '노트북', price: 1200000, category: '전자기기' },
    { id: 2, name: '마우스', price: 30000, category: '전자기기' },
    { id: 3, name: '티셔츠', price: 25000, category: '의류' },
  ]);
  const [filter, setFilter] = useState('all');

  // 👇 렌더링될 때마다 필터링된 배열 생성
  const filteredProducts = products.filter(product => {
    if (filter === 'all') return true;              // 전체 보기
    if (filter === 'electronics') return product.category === '전자기기';
    if (filter === 'clothes') return product.category === '의류';
    return true;
  });

  return (
    <div>
      <div>
        <button onClick={() => setFilter('all')}>전체</button>
        <button onClick={() => setFilter('electronics')}>전자기기</button>
        <button onClick={() => setFilter('clothes')}>의류</button>
      </div>

      {filteredProducts.map(product => (
        <div key={product.id}>{product.name}</div>
      ))}
    </div>
  );
}

 

조건부 렌더링

- 삼항 연산자: 조건 ? 값1 : 값2

    중첩하지 마라. 그정도 복잡해지만 밖으로 빼서 early return 방식 사용해라

&& 연산자: 조건이 참이면 보여주고 아니면 아무것도 안 보임

    함정: 숫자 0이 화면에 찍힌다. 

        -> truthy / falsy 값을 조건에 넣지 말고, 찐 true/false(boolean)값을 넣어줘야한다 (아니면 전환해라!!!)

early return: 복잡한 조건 깔끔하게

    예외 상황은 일찍 보내자 - 예외 상황부터 먼저 처리하고 일찍 return해버리는 패턴

    실무에서 else-if는 더러워서 잘 안쓴다

function UserProfile({ user }) {
  // 🚪 예외 1: 사용자가 없음
  if (!user) {
    return <p>사용자를 찾을 수 없습니다.</p>;
  }

  // 🚪 예외 2: 비활성 사용자
  if (!user.isActive) {
    return <p>비활성화된 사용자입니다.</p>;
  }

  // ✅ 정상 케이스 (나머지는 여기서 끝!)
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

 

데이터 형식

정적 데이터: 안 변하는 것은 일반 변수로 선언하면 된다. 

동적 데이터: 사용자 입력 / 버튼 클릭 / API 응답에 인해 실행중 변화되는 것은 state로 정의해야한다

    - 데이터 형식은 웬만해서는 객체 배열이다. 

아래의 코드에 보다싶이

CRUD 함수들은 대상 element의 id를 받아서 어떤 놈에게 작업할지 파악한다. 

그래서 id 설정하는 것잉 중요하다. 

각 데이터에 id 설정하고, CRUD 함수들은 id를 받아서 대상을 파악해라

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '운동하기' },
    { id: 2, text: '책 읽기' },
    { id: 3, text: '코딩하기' },
  ]);

  function deleteTodo(id) {
    // "클릭한 id가 아닌 것만 남겨라"
    const filtered = todos.filter(todo => todo.id !== id);
    setTodos(filtered);
  }

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          {todo.text}
          <button onClick={() => deleteTodo(todo.id)}>삭제</button>
        </li>
      ))}
    </ul>
  );
}
핵심 요약
  1. React = 데이터 주도 UI — 데이터만 바꿔라, 화면은 React가 그려준다.
  2. 변하는 데이터는 무조건 State — useState 안 쓰면 화면 안 바뀜.
    -> 수정에는 map, spread, 삭제에는 filter
    -> 일반 데이터를 jsx로 전환할때도 map을 사용한다. 
    -> 조건부 렌더링은 &&으로 깔끔하
  3. 실무에선 객체 배열 + id — 거의 모든 상황에서 이 패턴을 씁니다.

    map = 배열 → JSX 변환기
    🥮
    key는 항상 필수, 가능하면 id를 쓰자 🏷️
    JSX가 길어지면 컴포넌트로 쪼개자 🧩
    조건부 렌더링은 &&로 깔끔하게

Todo CRUD (전체 예제)

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '운동하기', done: false },
    { id: 2, text: '책 읽기', done: false },
  ]);

  // 📌 추가
  function addTodo(text) {
    const newTodo = { id: Date.now(), text, done: false };
    setTodos([...todos, newTodo]);
  }

  // 📌 토글 (완료/미완료 전환)
  function toggleTodo(id) {
    setTodos(todos.map(todo =>
      todo.id === id
        ? { ...todo, done: !todo.done }
        : todo
    ));
  }

  // 📌 삭제
  function deleteTodo(id) {
    setTodos(todos.filter(todo => todo.id !== id));
  }

  // 📌 일괄 삭제
  function deleteCompleted() {
    setTodos(todos.filter(todo => !todo.done));
  }

  return (/* JSX */);
}

⭐State는 언제 분리하고 언제 묶을까?

1. 분리된 State(기본)

function ProductList() {
  const [products, setProducts] = useState([]);
  const [sortOrder, setSortOrder] = useState('asc');
  const [filter, setFilter] = useState('all');
  const [searchTerm, setSearchTerm] = useState('');
  return (/* JSX */);
}

👍 장점

  • 각 State가 독립적이라 업데이트가 간단
  • 어떤 State가 바뀌었는지 명확
  • 실수할 여지가 적음

💡 기본은 "분리"예요!

처음에는 무조건 분리해서 시작하세요.

나중에 필요하면 묶으면 됩니다.

 

2. 객체로 묶기

관련 있는 state가 여러개면 객체로 묶는 것이 깔끔할때가 있다. 

보통은 form 관련된 작업일 시에 이렇게 관리한다. 

function ProductList() {
  const [viewOptions, setViewOptions] = useState({
    sortOrder: 'asc',
    filter: 'all',
    searchTerm: '',
  });

  // 일부 속성만 업데이트 (스프레드 필수!)
  function updateSortOrder(newOrder) {
    setViewOptions({
      ...viewOptions,       // 기존 값들 유지
      sortOrder: newOrder,  // sortOrder만 변경
    });
  }
}

 

State 관련 자주하는 실수

⭐다른 State에서 계산으로 얻을 수 있는 값은 절대로 state로 만들지 마라

이를 파생 상태라고 한다. 

어차피 파생해오는 애가 state로 정의되어서 리렌더링 되면,

파생된 일반 변수 애들은 알아서 재계산된다. 

state로 만들면 안되는 것 체크리스트

다음에 해당하면 일반 변수로 계산하세요, State 금지!

✅ 기존 state나 props로부터 계산할 수 있는가?
✅ 렌더링 중에 항상 같은 값이 나오는가?
✅ 한 데이터가 바뀌면 자동으로 따라 바뀌어야 하는가?

 

예제

보다시피 filterProducts와 totalPrice는 원본 데이터인 products에서 derive할 수 있기 때문에

절대로 state를 새로 작성하지 않고 일반 변수로 선언한다. 

어차피 파생해오는 products가 state라서

얘가 바뀌면 리렌더링이 되고, 그로 인해 자연히 파생된 filterProducts와 totalPrice도 재계산된다

// ─────────────────────────────────────────────
// 문제 3: 파생 상태 — "계산할 수 있는 값은 State로 만들지 말 것"
// ─────────────────────────────────────────────
// 검색 결과, 총합, 개수 같은 값은 useState 없이 렌더링 시점에 계산하세요.
// 이렇게 해야 원본(products/searchTerm)이 바뀔 때 자동으로 따라서 갱신됩니다.
// ─────────────────────────────────────────────
function Problem3() {
  const [products] = useState([
    { id: 1, name: "노트북", price: 1200000 },
    { id: 2, name: "마우스", price: 30000 },
    { id: 3, name: "키보드", price: 80000 },
    { id: 4, name: "모니터", price: 350000 },
    { id: 5, name: "이어폰", price: 120000 },
    { id: 6, name: "이어폰", price: 150000 },
  ]);
  const [searchTerm, setSearchTerm] = useState("");

  // TODO 3-1: filteredProducts를 계산하세요.
  const filteredProducts = !searchTerm
    ? products
    : products.filter((p) => p.name.includes(searchTerm));

  // TODO 3-2: totalPrice를 계산하세요.
  const totalPrice = filteredProducts.reduce(
    (sum, curr) => sum + curr.price,
    0,
  );

  return (
    <div className="exercise">
      <h3>문제 3: 검색 결과와 총합 (파생 상태)</h3>
      <p>
        상품 이름 검색 결과와 그 총 가격을 표시하세요. ⚠️ 이 두 값은{" "}
        <code>useState</code>로 만들면 안 됩니다. 원본이 바뀔 때 동기화가
        어긋나요.
      </p>

      <div className="toolbar">
        <input
          type="search"
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          placeholder="상품 이름 검색"
          style={{ flex: 1, minWidth: 160 }}
        />
        <span style={{ alignSelf: "center" }}>
          총 {filteredProducts.length}개 / {totalPrice.toLocaleString()}원
        </span>
      </div>

      <ul className="practice-list">
        {filteredProducts.map((product) => (
          <li key={product.id}>
            <span>{product.name}</span>
            <span>{product.price.toLocaleString()}원</span>
          </li>
        ))}
      </ul>

      <p className="expected">
        기대 결과: 검색어를 입력할 때마다 목록, 개수, 총 가격이 자동으로 함께
        갱신됩니다.
      </p>
    </div>
  );
}

 

파생 데이터가 많아져서 성능이 느려지면? - Memoization

useMemo를 사용해서 특정 값이 바뀔때에만 리렌더링하게 개발자가 직접 지정할 수 있다. 

이 작업 자체가 무겁긴 해서 남발하면 안되고,

안에 있는 작업이 그거보다는 훨씬 무거워서 자주 리렌더링하는 것이 손해일때 사용한다. 

const filteredProducts = usememo(
    () => products.filter((product) => product.name.includes(searchTerm),
    [searchTerm]
);

근데 현재 상황에서는 memoization은 쓸모가 없고

(어차피 모든 변수들이 searchTerm 중심으로 바뀌기 때문에)

이 경우에는 debouncing을 사용해야한다.

 

Debouncing

 


데이터 가져오기

리액트에서 fetch 사용하기

- fetch는 브라우저에서 서버로 요청을 보내는 JS 내장 기능이다. 

- fetch를 컴포넌트 안에 바로 쓰면 문제가 생긴다.

    처음 렌더링 -> fetch실행 -> 데이터 도착해서 setUsers(data)호출 -> state바뀌어서 컴포넌트 리렌더링 -> 또 fetch 실행...

useEffect: 컴포넌트가 처음 화면에 나타날때 딱 한번 fetch하고 싶어!

async function getUsers() {
  try {
    const response = await fetch('https://api.example.com/users');
    const data = await response.json();
    return data; // ✅ 반드시 return
  } catch (error) {
    console.error('에러:', error);
    return null;
  }
}

useEffect(() => {
  const fetchData = async () => {
    const result = await getUsers();
    setData(result);
  };

  fetchData();
}, []);


useEffect

정의

"컴포넌트가 그려진 후에" 실행할 작업을 등록하는 Hook

 

🎬 영화관 비유

  • 영화(컴포넌트)가 상영 끝나면 → 청소부(useEffect)가 들어와서 일을 함
  • 관객이 영화 보는 중에는 끼어들지 않음 (렌더링 방해 X)
  • 영화 끝난 "뒤에" 조용히 할 일 처리

useEffect는 "렌더링이 끝난 후"에 실행되는 코드 블록이에요.

(+) 개발 모드에서는 useEffect가 2번 실행된다. React Strict Mode 때문이다

💡만약 코드가 2번 실행돼서 문제가 생긴다면,
그건 코드에 숨은 버그가 있다는 신호예요. React가 일부러 드러내주는 거죠. (예: cleanup을 제대로 안 했거나)

 

useEffect 기본 구조

  1. 첫 번째 인자: 실행할 함수
  2. 두 번째 인자: 의존성 배열 (언제 실행할지 결정)

- 보통은 컴포넌트의 return문 바로 위에다가 작성한다. 

import { useEffect } from 'react';

useEffect(() => {
  // 실행할 코드
  const timerId = setInterval(()=>{}, 1000);
  console.log('Effect 실행');
  
  //생략 가능한 뒷정리 함수
  return () => {
      clearInterval(timerId);
  }
}, []);  // ← 의존성 배열 (중요!)

 

① 의존성 배열에 따른 다른 작용

dependency array는 꼭 작성해줘야한다. 

1. 빈 배열 [] - 딱 한번만

useEffect(() => {
  console.log('컴포넌트가 처음 생겼을 때 1번만');
}, []);

2. 값이 있는 배열 [count] - 값 바뀔때마다

count를 계속 지켜보다가 변할 때마다 실행. 검색어 바뀔 때 재검색, 페이지 번호 바뀔 때 재요청 등

useEffect(() => {
  console.log('count가 바뀔 때마다');
}, [count]);

3. 배열 없음 - 매렌더링마다 (잘 안씀)

렌더링마다 실행되니 성능 문제 + 무한 루프 위험. 실수로 빠뜨리지 마세요!

useEffect(() => {
  console.log('렌더링할 때마다');
});  // 배열을 아예 안 씀

 

② cleanup 함수

useEffect가 실행한 작업을 나중에 치워주는 함수이다. 

- 의존성 배열이 빈 배열이면 컴포넌트가 unmount될때 실행되고,

- 의존성 배열에 변수가 들어있으면 해당 변수가 업데이트되어서 useEffect가 다시 실행되기 전에 실행된다. 

useEffect 안에서 리턴된 함수가 cleanup 함수이다.

 

cleanup 함수를 제대로 안 쓰면 메모리 누수버그 폭탄이 터진다. 

대분의 경우에서 필요없지만 딱 3가지 상황에서 필요하다

- 타이머 정리: cleanup안하면 다른 페이지 갔다가 돌아오면 처음부터 시작하는게 아니라 계속 진행되어 있었던 값이 나온다

이벤트 리스너 정리

function MouseTracker() {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    function handleMouseMove(e) {
      setPos({ x: e.clientX, y: e.clientY });
    }

    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
    // ☝️ 제거 안 하면 컴포넌트 사라져도 이벤트 계속 실행됨!
  }, []);

  return <div>X: {pos.x}, Y: {pos.y}</div>;
}

fetch 요청 취소

 

useEffect로 async await fetch 받기

useEffect는 안의 콜백함수를 async로 만들 수 없다.

쓸꺼면 콜백함수 안에서 따로 정의해서 사용해야한다. 

async 함수는 무조건 Promise를 반환한다.
근데 useEffect는 반환값으로 cleanup 함수(뒤 챕터에서 배움)만 받아서, 타입이 안 맞아 리액트가 에러낸다.
function PostList() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    // 내부에 async 함수를 만들고
    async function fetchPosts() {
      try {
        const res = await fetch('https://jsonplaceholder.typicode.com/posts');
        const data = await res.json();
        setPosts(data);
      } catch (err) {
        console.error('에러:', err);
      }
    }

    // 바로 호출!
    fetchPosts();
  }, []);

  return (
    <div>
      {posts.map(post => (
        <div key={post.id}><h3>{post.title}</h3></div>
      ))}
    </div>
  );
}

 

⭐실행 순서

- 데이터 없이 빈 JSX 깡통 보임 (왜냐하면 useEffect는 렌더링 다 끝나고 실행되기 때문)

- useEffect 실행되어 데이터가 불려옴

- 데이터 도착하면 useState로 인해 리렌더링되고
    (useEffect의 [] 덕분에 또 무한 리렌더링은 피하면서)

    데이터가 찬 JSX 화면이 보임

🤔 "근데 로딩 중에 아무것도 안 보이는 건 UX가 별로 아니야?"
맞아요! 그래서 로딩 상태 관리가 다음다음 챕터에서 나옵니다. 기대하세요!

 

useEffect는 여러개 사용하는걸 권장

- 관심사별로 분리해야한다. 

function UserDashboard({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [comments, setComments] = useState([]);

  // 🧑 사용자 정보 Effect
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setUser);
  }, [userId]);

  // 📄 게시물 Effect
  useEffect(() => {
    fetch(`/api/users/${userId}/posts`)
      .then(res => res.json())
      .then(setPosts);
  }, [userId]);

  // 💬 댓글 Effect
  useEffect(() => {
    fetch(`/api/users/${userId}/comments`)
      .then(res => res.json())
      .then(setComments);
  }, [userId]);
}

 

조건부 useEffect 사용

- 서버 부담 감소, 비용 절: 검색 요청 할 필요 없을때는 굳이 안하도록 막아줌

- UX 향상

function SearchBox() {
  const [keyword, setKeyword] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    // 🚪 early return 패턴!
    if (keyword.length < 2) {
      setResults([]);
      return;  // Effect 본문 실행 안 됨
    }

    async function search() {
      const res = await fetch(`/api/search?q=${keyword}`);
      const data = await res.json();
      setResults(data);
    }

    search();
  }, [keyword]);

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      {results.map(r => <div key={r.id}>{r.title}</div>)}
    </div>
  );
}

 

⭐Hook은 항상 컴포넌트 최상단에서 호출해야한다. 


Pagination 기법

많은 데이터를 여러 페이지로 나눠서 조금씩 보여주는 기법

 

페이지네이션 기법 3가지

1. 페이지 기반 (Page-based)

이전 데이터를 품고 누적하는것이 아니라, 교체해버리는 방식이다. 

  • 👍 특정 페이지로 바로 점프 가능
  • 👍 "몇 개 중 몇 번째"가 명확 (북마크하기 좋음)
  • 👎 중간에 데이터 추가되면 순서가 밀리는 문제
[ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ] ... [ 100 ] →
1페이지: 1~10번
2페이지: 11~20번
...

 

2. 무한 스크롤 (Infinite Scroll)

이전 데이터를 품고 누적한다. 

인스타그램, 페이스북, 트위터, 틱톡... SNS의 정석

  • 👍 몰입감 최고, 자연스러움
  • 👍 모바일 UX에 최적
  • 👎 특정 위치 다시 찾기 어려움
  • 👎 푸터(footer)를 영원히 못 봄 😂
📱 스크롤 내림 → 자동으로 다음 데이터 로드
↓ 계속 내림 → 또 로드
↓ 계속 내림 → 또 로드

 

3. Load More 버튼

유튜브 댓글, 깃허브 이슈 목록 등

  • 👍 사용자가 주도권을 가짐
  • 👍 데이터 절약 (모바일 배려)
  • 👎 한 번 더 클릭해야 함
[ 게시글 1 ]
[ 게시글 2 ]
...
[ 게시글 10 ]
[ 더 보기 ▼ ] ← 클릭해야 다음 로드

 

API Query Parameter

방식1) limit & offset - 예전 방식

- limit: 한번에 가져올 개수

- offset: 앞에서부터 얼마나 무시할지

fetch('/api/posts?limit=10&offset=0')   // 1~10번
fetch('/api/posts?limit=10&offset=10')  // 11~20번
fetch('/api/posts?limit=10&offset=20')  // 21~30번

 

방식2) page & size - 가장 친숙⭐

- page: 몇 페이지?

- size: 한 페이지에 몇개 가져와?

fetch('/api/posts?page=1&size=10')  // 1페이지 (1~10번)
fetch('/api/posts?page=2&size=10')  // 2페이지 (11~20번)
fetch('/api/posts?page=3&size=10')  // 3페이지 (21~30번)

 

방식3) cursor 기반 - 실무 고급⭐

- 첫 요청은 cursor이 없음. 응답 리턴에 nextCursor이 있음

- 이 nextCursor을 다음 요청에 queryparams로 넣어주면, 그 위치로 시작해서 limit개수의 데이터를 가져와줌

cursor: 어디 다음부터 줘?

// 첫 요청
fetch('/api/posts?limit=10')
// 응답: { data: [...], nextCursor: 'abc123' }

// 다음 요청 (커서로 이어서)
fetch('/api/posts?limit=10&cursor=abc123')
// 응답: { data: [...], nextCursor: 'xyz789' }

 

응답 구조

- 데이터

- 페이지네이션 관련 정보도 같이 옴

{
  "data": [
    { "id": 1, "title": "첫 번째 글" },
    { "id": 2, "title": "두 번째 글" },
    ...
  ],
  "pagination": {
    "page": 1,
    "size": 10,
    "totalCount": 247,
    "totalPages": 25,
    "hasNext": true
  }
}

 

페이지네이션 구현하기

 

1) LoadMore 방식 구현

- setPosts(prev => [...prev, ...data])를 해서 이어붙여줘야한다. 

- useEffect가 offset 의존성을 가져서, 

    offset이 바뀔때마다 재실행되고, 새 데이터를 가져와준다. 

- 끝났는지 판단하는 로직

function PostList() {
  const [posts, setPosts] = useState([]);
  const [offset, setOffset] = useState(0);
  const [hasMore, setHasMore] = useState(true);  // 👈 추가!
  const limit = 10;

  useEffect(() => {
    async function loadPosts() {
      const res = await fetch(
        `https://jsonplaceholder.typicode.com/posts?_start=${offset}&_limit=${limit}`
      );
      const data = await res.json();

      // 받은 개수가 limit보다 적으면 끝!
      if (data.length < limit) {
        setHasMore(false);
      }

      setPosts(prev => [...prev, ...data]);
    }
    loadPosts();
  }, [offset]);

  return (
    <div>
      {posts.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}

      {hasMore ? (
        <button onClick={() => setOffset(prev => prev + limit)}>
          더 보기
        </button>
      ) : (
        <p>✅ 모든 게시물을 불러왔습니다.</p>
      )}
    </div>
  );
}

 

2) 페이지 기반 방식 구현

- LoadMore과 달리, 기존 데이터에 이어붙이지 않고 교체한다. 

- 1 페이지에서는 '이전'버튼이 비활성화 되게 하기 위해 

    disabled 속성을 넣고 booleans로 제어한다. 

function PostList() {
  const [posts, setPosts] = useState([]);
  const [currentPage, setCurrentPage] = useState(1);
  const [totalPages, setTotalPages] = useState(1);
  const size = 10;

  useEffect(() => {
    async function loadPage() {
      const res = await fetch(`/api/posts?page=${currentPage}&size=${size}`);
      const data = await res.json();

      setPosts(data.posts);  // 👈 교체! (이어붙이기 X)
      setTotalPages(data.totalPages);
    }
    loadPage();
  }, [currentPage]);

  return (
    <div>
      {posts.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}

      <div className="pagination">
        <button
          disabled={currentPage === 1}
          onClick={() => setCurrentPage(prev => prev - 1)}
        >
          이전
        </button>

        <span>{currentPage} / {totalPages}</span>

        <button
          disabled={currentPage === totalPages}
          onClick={() => setCurrentPage(prev => prev + 1)}
        >
          다음
        </button>
      </div>
    </div>
  );
}

 


⭐비동기로 State 변경할때 주의사항

⭐비동기에서 state를 변경하려면 setState의 함수형 업데이트를 사용해줘야한다. 

 

문제

밑에 경우에는 setState가 Promise의 then에 들어있는 경우이다. 

 

count 는 "렌더링 순간에 찍은 사진" 같아요.

[렌더링 #1, count = 0]
  📸 사진 찍음: count = 0
  handleClick 함수가 이 사진을 "기억"함

  사용자가 클릭 → handleClick 실행
  → await... 대기 중
  → 3초 후: 이 함수는 여전히 사진 속 count = 0을 봄
  → setCount(0 + 1)

중간에 State가 바뀌든 말든, 이미 만들어진 함수는 옛날 사진만 봐요.

시간 →

렌더링#1        렌더링#2        렌더링#3
count=0         count=1         count=2
  │                │               │
  │ handleClick    │ handleClick   │ handleClick
  │ 생성됨         │ 생성됨        │ 생성됨
  │ (count=0 캡처) │ (count=1 캡처)│ (count=2 캡처)
  │                │               │
  ▼                ▼               ▼
  클릭됨(A)         클릭됨(B)       클릭됨(C)
  await...         await...        await...
     │                │               │
     │ 3초 후         │ 3초 후         │ 3초 후
     ▼                ▼               ▼
  setCount(0+1)    setCount(1+1)   setCount(2+1)
  → 1              → 2             → 3

근데 빠르게 클릭하면 타이밍이 꼬여요:

빠른 연속 클릭 시나리오:

클릭A (count=0 캡처)
클릭B (count=0 캡처) ← State 업데이트 전이라 여전히 0
클릭C (count=0 캡처) ← 여전히 0
    │
    │ 모두 대기
    ▼
await 끝 (A): setCount(0+1) = 1
await 끝 (B): setCount(0+1) = 1  ← 또 1!
await 끝 (C): setCount(0+1) = 1  ← 또 1!

이게 "오래된 값(Stale State)" 문제예요.

 

해결법

함수형 엄데이트를 사용하면 해결이 된다. 

setCount 에 함수를 넘기면, React가 그 함수를 호출할 때 현재 시점의 최신 값을 인자로 넣어줘요.

setCount(prevCount => prevCount + 1);
//       ↑
//   React가 호출할 때 "지금 이 순간의 count 값"을 넣어줌

 

📸 사진이 아니라 "지금 이 순간"

❌ 값 전달: "내가 본 count(옛날 사진)에 1 더해줘"
✅ 함수 전달: "현재(=React가 아는 최신) count가 뭐든, 거기에 1 더해줘"

 

클릭A: setCount(prev => prev + 1)
클릭B: setCount(prev => prev + 1)
클릭C: setCount(prev => prev + 1)
    │
    ▼
await 끝 (A): React가 실행 → prev=0 → 1
await 끝 (B): React가 실행 → prev=1 → 2  ✨
await 끝 (C): React가 실행 → prev=2 → 3  ✨

각 시점의 최신 값으로 계산되니 정확해요!

 

함수형 업데이트가 꼭 필요한 경우

1. 이전 값을 기반으로 계산할 때

setCount(prev => prev + 1);       // 증가
setPosts(prev => [...prev, newPost]);  // 배열에 추가
setUser(prev => ({ ...prev, age: prev.age + 1 }));  // 객체 업데이트

2. 비동기 작업 안에서 업데이트할 때

setTimeout(() => {
  setCount(prev => prev + 1);  // ✅ 안전
}, 1000);

async function save() {
  await api.save();
  setCount(prev => prev + 1);  // ✅ 안전
}

3. 같은 렌더링에서 여러 번 업데이트할 때

function handleClick() {
  setCount(prev => prev + 1);  // count: 0 → 1
  setCount(prev => prev + 1);  // count: 1 → 2
  setCount(prev => prev + 1);  // count: 2 → 3
}

⚠️ 만약 값 전달로 했다면?

setCount(count + 1);  // 0 → 1
setCount(count + 1);  // 0 → 1 (count는 여전히 0!)
setCount(count + 1);  // 0 → 1

결과: 3이 아니라 1! 😱

 

🟡 값 전달해도 OK인 경우

이전 값과 무관한 새 값일 때 — 그냥 값 전달해도 괜찮아요.

setName('새 이름');           // 이전 이름과 무관
setUser(newUserFromAPI);      // 통째로 교체
setIsOpen(true);              // 명시적 true/false

"이전 값 + 뭔가" = 함수형

"완전히 새 값" = 그냥 전달

또는 안전하게 항상 함수형 쓰기도 괜찮아요. 습관 들이면 버그 방지에 도움.


⭐Laxy Initial State

문제

useState 초기값 설정할때 안에 함수 그 자체를 넣으면 조용히 성능 문제를 만든다.

왜냐하면 리렌더링 시 계속 호출되기 때문이다. 

function App() {
  console.log('🎬 App 함수 실행');
  const [data, setData] = useState(expensiveCalculation());
  //                               ↑ 이 함수는 매번 호출됨!
  return <div>{data}</div>;
}
첫 렌더링: 🎬 App 함수 실행 → 💪 계산 실행 → 결과: 100ms
setState 호출
두번째 렌더링: 🎬 App 함수 실행 → 💪 계산 또 실행 → 100ms (무시됨!)
세번째 렌더링: 🎬 App 함수 실행 → 💪 계산 또 실행 → 100ms (무시됨!)
...

 

useState의 동작 규칙:

초기값은 첫 렌더링에만 사용됨. 그 이후엔 State 저장소의 값을 씀.

즉, 2번째 렌더링부터는 expensiveCalculation()의 결과를 React가 버려요.

계산은 했는데 안 쓰는 거예요

= 100% 낭비되는 연산

 

해결책

함수로 전달하면 문제가 해결된다. 

이걸 바로 Lazy initial state (게으른 초기화)라고 한다. 

function App() {
  const [data, setData] = useState(() => expensiveCalculation());
  //                               ↑
  //                          함수를 전달!
  return <div>{data}</div>;
}
// ❌ 값을 전달 (계산 매번 실행)
useState(expensiveCalculation())
// ↑ 함수 "호출" → 결과값 전달
// ✅ 함수를 전달 (첫 렌더링에만 실행)
useState(() => expensiveCalculation())
// ↑ 함수 "자체" 전달 → React가 필요할 때만 호출

 

매번 쓸 필요는 없다. 가벼운 값이면 그냥 전달하는 것이 오히려 가독성이 좋다

ex) 비싼 계산, localStorgae 읽기, 복잡한 객체/배열 생성, 외부 라이브러리 초기화 시에 사용하면 좋다

// ❌ 매 렌더링마다 localStorage 접근
const [user, setUser] = useState(
  JSON.parse(localStorage.getItem('user'))
);

// ✅ 첫 렌더링에만 접근
const [user, setUser] = useState(() =>
  JSON.parse(localStorage.getItem('user'))
);

// ❌
const [grid, setGrid] = useState(
  Array(100).fill(null).map(() => Array(100).fill(0))
);

// ✅
const [grid, setGrid] = useState(() =>
  Array(100).fill(null).map(() => Array(100).fill(0))
);

const [parser, setParser] = useState(() => new ExpensiveParser());

네트워크 로딩 상태 관리

로딩 상태가 없을 경우

- 로딩중인지 에러인지 구분 불가능하다

- 사용자 불안

- 체감 속도 느림

- 새로고침 연타 가능성 증

function PostList() {
  const [posts, setPosts] = useState([]);
  const [isLoading, setIsLoading] = useState(true);  // 👈 true로 시작!

  useEffect(() => {
    async function loadPosts() {
      try {
        const res = await fetch('/api/posts');
        const data = await res.json();
        setPosts(data);
      } finally {
        setIsLoading(false);  // 로딩 완료
      }
    }
    loadPosts();
  }, []);

  if (isLoading) {
    return <div>로딩 중...</div>;
  }

  return (
    <div>
      {posts.map(post => <div key={post.id}>{post.title}</div>)}
    </div>
  );
}

- 배칭(batching)처리 때문에 setPosts와 setIsLoading이 한번에 일어난다. (한 리렌더에 처리됨)

tanstack query안의 isLoading이 이런식으로 구현되어 있다. 

 

무조건 try/catch/finally를 사용해야한다. 

- 물론, fetch의 400~500대 에러를 잡을려면 response.ok에서 확인하고 catch에서 해결해줘야하긴 한다. 

- 근데 그거 외의 문제가 있다. 

이렇게 하면 안 돼요:

async function loadPosts() {
  setIsLoading(true);
  const res = await fetch('/api/posts');  // 💥 여기서 에러!
  const data = await res.json();
  setPosts(data);
  setIsLoading(false);  // ❌ 실행 안 됨! 영원히 로딩 중
}

에러 나면 setIsLoading(false) 못 하고 멈춤 → 사용자는 영원히 스피너만 봄 

 

단계별 로딩창

Level 1: 단순 텍스트

if (isLoading) return <div>로딩 중...</div>;

장점: 간단

단점: 투박, 체감 속도 느림

 

Level 2: 스피너

if (isLoading) {
  return (
    <div className="loading">
      <div className="spinner"></div>
      <p>게시물을 불러오는 중...</p>
    </div>
  );
}

장점: 시각적 피드백

단점: 여전히 "빈 화면" 느낌

 

Level 3: 스켈레톤 UI ⭐ (실무 표준)

if (isLoading) {
  return (
    <div className="skeleton-list">
      {[1, 2, 3, 4, 5].map(i => (
        <div key={i} className="skeleton-item">
          <div className="skeleton-title"></div>
          <div className="skeleton-text"></div>
          <div className="skeleton-text"></div>
        </div>
      ))}
    </div>
  );
}

 

(+) Layout Shift 없음 -> 로딩 전후 레이아웃 동일

 

중복 요청 방지

로딩 처리의 또 다른 중요한 역할 - 버튼 연타 방지

⭐disabled를 요긴하게 잘 사용해야한다. 

<button onClick={loadMore} disabled={isLoadingMore}>
  {isLoadingMore ? '불러오는 중...' : '더 보기'}
</button>

- 로딩 중에 버튼 클릭 불가능하게 만듦

- 경쟁 조건 예방

- 한번에 하나의 요청만 보기


총 결과

function PostList() {
  const [posts, setPosts] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);  // 👈 에러 State

  useEffect(() => {
    async function loadPosts() {
      try {
        const res = await fetch('https://jsonplaceholder.typicode.com/posts');

        if (!res.ok) {
          throw new Error('데이터를 불러오는 데 실패했습니다');
        }

        const data = await res.json();
        setPosts(data);
      } catch (err) {
        setError(err.message);  // 👈 에러 메시지 저장
      } finally {
        setIsLoading(false);
      }
    }
    loadPosts();
  }, []);

  if (isLoading) return <div>로딩 중...</div>;

  if (error) {
    return (
      <div className="error">
        <p>⚠️ {error}</p>
        <button onClick={() => window.location.reload()}>
          다시 시도
        </button>
      </div>
    );
  }

  return (
    <div>
      {posts.map(post => <div key={post.id}>{post.title}</div>)}
    </div>
  );
}

 

에러의 3가지 종류

- HTTP 에러 (res.status 확인시 서버가 400대, 500대 반환 / res.ok로 걸러냄)

- 네트워크 에러 (TypeError로 온다)

- 응담이 JSON 형식 아닐시 (SyntaxError로 온다 - JSON파싱 에러)

⭐diving point -> 깊게 파는 내용


⭐⭐4단계 상태 렌더링⭐⭐

function PostList() {
  const [posts, setPosts] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function loadPosts() {
      try {
        const res = await fetch('/api/posts');
        if (!res.ok) throw new Error('데이터 로드 실패');
        const data = await res.json();
        setPosts(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setIsLoading(false);
      }
    }
    loadPosts();
  }, []);

  // 🚦 4단계 분기 처리 (early return 패턴)

  // 1️⃣ 로딩 중
  if (isLoading) {
    return <div>⏳ 로딩 중...</div>;
  }

  // 2️⃣ 에러 발생
  if (error) {
    return (
      <div>
        <p>❌ {error}</p>
        <button onClick={() => window.location.reload()}>다시 시도</button>
      </div>
    );
  }

  // 3️⃣ 빈 데이터
  if (posts.length === 0) {
    return <div>📭 게시글이 없습니다</div>;
  }

  // 4️⃣ 정상 렌더링
  return (
    <div>
      {posts.map(post => <div key={post.id}>{post.title}</div>)}
    </div>
  );
}

React의 queryparameter

지금까지 문자열 결합으로 url을 만들었다 .

그러나 이런 방식이 위험하다. 

`/api/posts?userId=${userId}&sort=${sortBy}&page=${page}`

 

 

1. 특수문자 이슈

const keyword = '노트북 & 마우스';
`/api/search?q=${keyword}`
// 결과: /api/search?q=노트북 & 마우스
//                              ↑ & 가 새 파라미터 구분자로 해석됨!
// 서버: "q=노트북 ", " 마우스" 로 파싱 😱

2. 한글/특수문자 인코딩

const query = '@#$%';
`/api/?q=${query}`
// 서버에서 제대로 처리 안 될 수 있음

3. 조건부 파라미터 복잡해짐

// ❌ 이런 지저분한 if문 덩어리
let url = '/api/posts';
const params = [];
if (userId) params.push(`userId=${userId}`);
if (sortBy) params.push(`_sort=${sortBy}`);
if (params.length > 0) url += '?' + params.join('&');

 

URLSearchParms를 사용하면 훨씬 편하게 작성할 수 있다.

//조건부로 queryparams 추가하기 쉬움
const params = new URLSearchParams();
if (userId) params.append('userId', userId);
if (sortBy) params.append('_sort', sortBy);
if (limit) params.append('_limit', limit);

=> 쿼리 파라미터는 항상 URLSearchParams 사용하기

(+) useSearchParams()를 사용하면 쿼리 파라미터들을 state로 가져와서 사용할 수 있다. 


디바운싱

짧은 시간 내에 연속적인 이벤트가 발생했을때, 마지막에 발생한 이벤트만 처리하는 

디바운싱 없이 input form을 구현하면 과도하게 자주 fetch를 하게 된다. 


입력 폼 다루기

⭐⭐기술 면접에 무조건 나오는 문제

제어 컴포넌트: input 값을 React State가 제어하는 컴포넌트이다. 

value={state} onChange={(e)=>setState(e.target.value)} 형태가 제어 컴포넌트이다. 

  • State = 원본
  • input = 거울
  • 거울은 원본을 비출 뿐, 혼자 뭘 바꿀 수 없어요
  • 값을 바꾸려면 원본(State)을 바꿔야 거울에 반영됨
function NameForm() {
  const [name, setName] = useState('');
  return (
    <input
      value={name}                              // 👈 State가 보여줄 값
      onChange={(e) => setName(e.target.value)} // 👈 타이핑하면 State 업데이트
    />
  );
}

 

장점

-> 실시간 유효성 검사가 가능하다. 

 

📝 text / email / password / textarea

<input
  value={text}
  onChange={(e) => setText(e.target.value)}
/>

<textarea
  value={comment}
  onChange={(e) => setComment(e.target.value)}
/>

☑️ checkbox — checked 사용

<input
  type="checkbox"
  checked={agreed}                              // 👈 value 아님!
  onChange={(e) => setAgreed(e.target.checked)} // 👈 .checked
/>

🔘 radio — value로 비교

<input
  type="radio"
  value="male"
  checked={gender === 'male'}                   // 👈 비교로 판단
  onChange={(e) => setGender(e.target.value)}
/>
<input
  type="radio"
  value="female"
  checked={gender === 'female'}
  onChange={(e) => setGender(e.target.value)}
/>

📋 select — value로 선택

<select value={country} onChange={(e) => setCountry(e.target.value)}>
  <option value="korea">대한민국</option>
  <option value="usa">미국</option>
  <option value="japan">일본</option>
</select>

 

비제어 컴포넌트: state없이 DOM이 직접 값을 관리하는 input이다. 

- useRef를 사용하는 패턴이 자주 나온다. 

    HTML에서 getElementById 등등으로 DOM을 가져온 느낌이다. 

- 비제어는 value대신 defaultValue를 사용한다. value를 사용하면 onChange 깜빡한거냐고 에러가 뜬다. 

function UncontrolledForm() {
  const inputRef = useRef();
  function handleSubmit(e) {
    e.preventDefault();
    console.log(inputRef.current.value);  // 👈 제출 시점에 DOM에서 꺼내기
  }
  return (
    <form onSubmit={handleSubmit}>
      <input type="text" ref={inputRef} />  {/* value, onChange 없음! */}
      <button>제출</button>
    </form>
  );
}

 

비제어가 강제되는 경우:

1. file 입력할때

2. 엄청나게 무거운 input 작업의 성능을 올리기 위해 (왜냐하면 리렌더링이 일어나지 않음) 

 

판단 근거


onSubmit

// 방법 A: 버튼 onClick
<button onClick={handleSubmit}>제출</button>

// 방법 B: form onSubmit ⭐
<form onSubmit={handleSubmit}>
  <button type="submit">제출</button>
</form>

 

장점

- onClick과 다르게 Enter키로 제출 가능함 (모든 input에서 자동)

- HTML 기본 검증 활용 (required, type="email" 등)

- 접근성 (스크린 리더) 대응

- 시맨틱하게 '이건 폼 제출 동작'임을 명시

 

특징

e.preventDefault()가 필요하다. 없으면 새로고침이 일어난다. 

- 제출전 유효성 검사를 해주자

if (조건) {
  setError('메시지');
  return;  // 👈 제출 중단!
}
async function handleSubmit(e) {
  e.preventDefault();
  setIsSubmitting(true);

  try {
    const res = await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
    if (!res.ok) throw new Error('실패');
  } catch (err) {
    // 에러 처리
  } finally {
    setIsSubmitting(false);
  }
}

<button type="submit" disabled={isSubmitting}>
  {isSubmitting ? '전송 중...' : '전송'}
</button>

⭐(+) input 타입이 password인 경우, 안의 값이 복사가 안된다. input 타입이 text야지만 복사 가능하다


파일 인풋

// 텍스트: 값이 문자열
<input type="text" value={text} onChange={(e) => setText(e.target.value)} />

// 파일: 값이 File 객체!
<input type="file" onChange={(e) => setFile(e.target.files[0])} />
//                                          ^^^^^^^^^^^^^^^^^
//

e.target이 <input type="file">이고

e.target.files는 FileList (유사배열)이다. 

e.target.files[0]은 File 객체 하나로, 안에 name, size, type등의 속성이 있다. 

 

input타입이 file이면 앞서 말했듯이 제어 컴포넌트가 아니라 비제어 컴포넌트를 사용해야한다. 

-> value를 사용하지 않음

function FileUpload() {
  const [file, setFile] = useState(null);

  function handleChange(e) {
    setFile(e.target.files[0]);  // 👈 첫 번째 파일
  }

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

      {file && (
        <div>
          <p>파일명: {file.name}</p>
          <p>크기: {(file.size / 1024).toFixed(2)} KB</p>
          <p>타입: {file.type}</p>
        </div>
      )}
    </form>
  );
}

(+) accpet 속성으로 특정 파일들만 걸러서 받을 수 있다. 

    근데 완벽하지는 않아서 코드 검증도 같이 해줘야한다. 

if (!file.type.startsWith("image/")) {
  setError("이미지 파일만 업로드 가능해요");
  return;
}

 

(+) multiple 속성으로 여러 파일 허용 가능

FileList 역시 유사배열이라서 배열 메서드 사용하려면 일반 메서드로 변환이 필요하다. 

function MultiFileUpload() {
  const [files, setFiles] = useState([]);

  function handleChange(e) {
    // FileList → 배열 변환!
    const selected = Array.from(e.target.files);
    setFiles(selected);
  }

  function removeFile(index) {
    setFiles(files.filter((_, i) => i !== index));
  }

  return (
    <div>
      <input type="file" multiple onChange={handleChange} />

      <ul>
        {files.map((file, index) => (
          <li key={index}>
            {file.name}
            <button onClick={() => removeFile(index)}>❌</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

 

파일 전송

파일은 JSON으로 전송 불가능하다 (텍스트만 가능)

따라서 FormData를 사용해야 가능하다 (텍스트 + 파일 혼합 전송)\

- 이때 Content-Type을 지정해주면 안된다. FormData가 자동으로 올바른 헤더를 만든

async function handleUpload(file) {
  const formData = new FormData();
  formData.append('file', file);              // 파일 추가
  formData.append('description', '프로필');    // 텍스트도 같이 추가 가능

  const res = await fetch('/api/upload', {
    method: 'POST',
    body: formData,
    // ⚠️ Content-Type 헤더 넣지 마세요! (자동 설정됨)
  });

  const data = await res.json();
  console.log('업로드 성공:', data);
}

useRef

- 값을 저장하되, 바뀌어도 리렌더링을 일으키지 않는 Hook

import { useRef } from 'react';

function Example() {
  const ref = useRef(0);  // 초기값 0
  console.log(ref.current);  // 👈 .current로 값 접근
}

 

useRef 사용처

1) DOM 요소에 직접 접근할 경우

  • 포커스 제어: .focus(), .blur()
  • 스크롤 제어: .scrollTo(), .scrollIntoView()
  • 미디어 제어: video.play(), audio.pause()
  • 외부 라이브러리 연동 (차트, 지도 등)

💡 원칙: React로 할 수 있으면 React로, 진짜 DOM을 직접 만져야 할 때만 ref 사용!

function FocusInput() {
  const inputRef = useRef();

  function handleClick() {
    inputRef.current.focus();  // 👈 input에 포커스!
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>포커스</button>
    </>
  );
}

 

2) 리렌더링이 필요하지 않는 값 저장

- 대표적인 예시로 타이머 ID

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const timerRef = useRef(null);  // 👈 타이머 ID 저장용

  function start() {
    timerRef.current = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);
  }

  function stop() {
    clearInterval(timerRef.current);  // 👈 ref로 타이머 취소
  }

  return (
    <div>
      <p>{seconds}초</p>
      <button onClick={start}>시작</button>
      <button onClick={stop}>정지</button>
    </div>
  );
}

데이터 전송하기

1. POST

일반적으로 POST의 응답으로 서버는 생성된 데이터를 돌려준다. 

보통 id가 포함되어 있다. 

따라서 POST 성공시, 반환된 데이터를 기존 목록 앞에 추가하는 방법을 사용한다. 

function PostList() {
  const [posts, setPosts] = useState([]);
  const [newPost, setNewPost] = useState({ title: '', body: '' });

  // 초기 목록 로드
  useEffect(() => {
    fetch('/api/posts')
      .then(res => res.json())
      .then(data => setPosts(data.slice(0, 10)));
  }, []);

  async function handleSubmit(e) {
    e.preventDefault();

    try {
      const res = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ...newPost, userId: 1 }),
      });
      if (!res.ok) throw new Error('생성 실패');

      const createdPost = await res.json();

      // 🎯 핵심: 새 게시글을 목록 맨 앞에 추가
      setPosts(prev => [createdPost, ...prev]);

      // 폼 초기화
      setNewPost({ title: '', body: '' });
    } catch (err) {
      alert(err.message);
    }
  }

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input
          value={newPost.title}
          onChange={(e) => setNewPost({ ...newPost, title: e.target.value })}
          placeholder="제목"
        />
        <textarea
          value={newPost.body}
          onChange={(e) => setNewPost({ ...newPost, body: e.target.value })}
          placeholder="내용"
        />
        <button type="submit">작성</button>
      </form>

      {posts.map(post => (
        <div key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </div>
      ))}
    </div>
  );
}

 

2. UPDATE

게시글 목록 중 하나 클릭 -> 수정 폼 전환 -> 저장 -> 목록에 반영

import { useEffect, useState } from "react";

function PostList() {
  const [posts, setPosts] = useState([]);
  const [editingId, setEditingId] = useState(null); // 👈 어떤 걸 수정 중?
  const [editForm, setEditForm] = useState({ title: "", body: "" });

  // 목록 로드
  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/posts")
      .then((res) => res.json())
      .then((data) => setPosts(data.slice(0, 10)));
  }, []);

  // 수정 시작
  function startEdit(post) {
    setEditingId(post.id);
    setEditForm({ title: post.title, body: post.body });
  }

  // 수정 취소
  function cancelEdit() {
    setEditingId(null);
  }

  // 수정 저장
  async function saveEdit(postId) {
    try {
      const res = await fetch(
        `https://jsonplaceholder.typicode.com/posts/${postId}`,
        {
          method: "PATCH",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(editForm),
        },
      );
      if (!res.ok) throw new Error("수정 실패");
      const updated = await res.json();

      // 🎯 핵심: 목록에서 해당 게시글만 업데이트
      setPosts((prev) =>
        prev.map((post) =>
          post.id === postId ? { ...post, ...updated } : post,
        ),
      );
      setEditingId(null);
    } catch (err) {
      alert(err.message);
    }
  }

  return (
    <div>
      {posts.map((post) => (
        <div key={post.id}>
          {editingId === post.id ? (
            // 수정 모드
            <div style={{ border: "1px solid red" }}>
              <input
                value={editForm.title}
                onChange={(e) =>
                  setEditForm({ ...editForm, title: e.target.value })
                }
              />
              <textarea
                value={editForm.body}
                onChange={(e) =>
                  setEditForm({ ...editForm, body: e.target.value })
                }
              />
              <button onClick={() => saveEdit(post.id)}>저장</button>
              <button onClick={cancelEdit}>취소</button>
            </div>
          ) : (
            // 보기 모드
            <div style={{ border: "1px solid blue" }}>
              <h3>{post.title}</h3>
              <p>{post.body}</p>
              <button onClick={() => startEdit(post)}>수정</button>
            </div>
          )}
        </div>
      ))}
    </div>
  );
}

 

3. DELETE

서버에 삭제 요청하고, 그냥 단순하게 Refetch하고 치운다

이는, DELETE 후에 목록을 다시 GET해서 통째로 갈아치우는 방식이다. 

(filter 안 사용하고)

filter 대신 DELETE를 사용하는 이유는

- 다른 사용자의 변경사항 반영

- 페이지네이션 정합

- 파생 데이터 자동 갱신

- 권한/에러 감

(+) 진짜 삭제할것인지 확인하기 위해서 confirm()을 사용한다. 실무에서는 자체 제작 modal로 확인한다. 


Custom Hook

- 로직을 재사용하기 위한 함수

- 이름이 use로 시작

- 내부에서 다른 Hook 사용 가능

- 여러 컴포넌트에서 재사용 가능

-> 중복 코드 제거, 로직 분리, 테스트하기 좋음

 


자주 쓰이는 React Hook

1. useMemoization

불필요하게 리렌더링하는 것을 막는다.

한번 계산한거 캐싱해서 다시 사용해준다. 

리렌더링이 안되게 캐싱하는 것이 메모이징이다.

지금은 boilerplate 만들때 JS + React Compiler라고 설정하면 알아서 메모이징 해준다

 

이제 거의 쓸 일 없는 내용이다.
2025 React Compiler 1.0이 나오면서 자동 최적화가 되기 때문이다.
그래도 개념은 알고 있어야한다. 레거시 코드, 면접 질문 등 여전히 만날수 있기 때문이다. 

 

1. 부모가 리렌더링되면 자식도 같이 리렌더링된다
2. 불필요한 자식 리렌더링을 막으려면 → 자식을 React.memo 로 감싼다
3. memo 해도 props가 바뀌면 리렌더링 된다
   → 함수 props가 매 렌더링마다 "새 참조"로 내려가면 소용없음
   → useCallback 으로 함수 참조 고정
4. useMemo 는 렌더링 중 무거운 계산을 캐싱 (참조 고정도 가능)

 

 

import { useState, memo, useCallback } from 'react';

// 🎯 자식 컴포넌트
function Child({ onClick }) {
  console.log('🟦 Child 렌더링');
  return <button onClick={onClick}>자식 버튼</button>;
}

// 🎯 부모 컴포넌트
function Parent() {
  const [count, setCount] = useState(0);

  function handleClick() {
    console.log('자식 버튼 클릭됨');
  }

  console.log('🟥 Parent 렌더링');

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>부모 카운트 +1</button>
      <Child onClick={handleClick} />
    </div>
  );
}

export default Parent;

지금은 Parent가 count때문에 리렌더링할때마다 Child도 리렌더링된다.

허나, Child의 정의를 보면, 단순 html이기 때문에

딱히 리렌더링할 필요도 없는 상황이다.

이럴때 Memoization을 하는 것이다. 

const Child = memo(function Child({ onClick }) {
  console.log('🟦 Child 렌더링');
  return <button onClick={onClick}>자식 버튼</button>;
});

 

문제는 이래도 여전히 리렌더링이 일어나는 것이다.

왜냐하면 부모가 count때문에 리렌더링 될때마다 새로운 참조값의 onClick을 Child에 props로 전달하기 때문에

[리렌더링이 일어나는 경우]의 세번째 - props가 변할때를 충족해

계속 child도 리렌더링 되는 것이다. 

 

그래서 onClick함수를 useCallback으로 바꿔준다

useCallback은 useEffect랑 비슷하게 작동하는데, 그냥 변수에 콜백으로 함수 할당할때 쓰는것 뿐이다

const handleClick = useCallback(() => {
  console.log('자식 버튼 클릭됨');
}, []);  // 의존성 비어있으면 한 번만 생성

(컴포넌트를 감쌀수 있는 함수는 useMemo밖에 없다. 

useCallback에다가 컴포넌트 넣을수는 없다)

 

React Compiler가 바꾼 세상

2025 React Compiler 1.0을 적용하면

컴파일러가 빌드 타임에 자동으로

- 어떤 컴포넌트를 메모이제이션 할지 분석

- 어떤 함수/값의 참조를 유지할지 판단

- 필요한 최적화를 코드에 자동 삽입

// 그냥 평범하게 쓴 코드
function Parent() {
  const [count, setCount] = useState(0);
  function handleClick() { ... }  // 👈 그냥 함수

  return <Child onClick={handleClick} />;  // 👈 memo 없음
}

=> memo, useCallback을 쓰지 않아도 같은 최적화 효과!

그럼에도, React Compiler은 아주 무거운 계산까지는 안전하게 최적화 못하는 경우가 있기에

(CPU 집약적 계산, 무거운 변환/필터/정렬)

아직 사용하기는 한다

 

2. Context

전역 데이터를 다루는 방식이다. 

 

전역 데이터의 필요성

Props Drilling

가장 안에 있는 child만 필요한데

조부모도 필요해서 부모에서부터 계속 child로 props를 전달해서 내려주는 꼴이 된다. 

이러면 props가 필요없는 애들도 받게 되어서 필요없는 props가 많아지게 된다. 

- props 거쳐가는 컴포넌트들의 비극 (쓰지도 않는데 받고 넘기기만 함)

- 중간에 하나라도 빠뜨리면 버그

- prop 추가할때마다 모든 경로 수

=> 그래서 최상위 컴포넌트가 전역적으로 데이터를 관리해주는게 좋다

중간 컴포넌트 건너뛰고, 필요한 컴포넌트에 바로 데이터 꽂아주기
Like 방송탐 송출 (필요한 도시가 알아서 수신)

ex) darkmode, 언어설정, 토큰(로그인한 사용자 정보), 알림설정, 여러 컴포넌트가 공유하는 데이터, 깊게 중첩된 구조

 

createContext: 방송국 생성

<DarkModeContext.Provider value={{}}>: 송출 시스템 세팅 (어떤 state/setState를 전달할지 지정)

const {value1} = useContext(DarkModeContext): 방송국 구독

import { createContext, useContext, useState } from "react";

// 1. 방송국(Context) 만들기
const DarkModeContext = createContext();

// 2. 방송 시작 (Provider로 감싸기)
export default function App() {
  const [isDark, setIsDark] = useState(false);
  return (
    <DarkModeContext.Provider value={{ isDark, setIsDark }}>
      <Layout /> {/* props 전달 X */}
    </DarkModeContext.Provider>
  );
}

// 3. 중간 컴포넌트들: 깔끔!
function Layout() {
  return (
    <>
      <Header />
      <MainContent />
    </>
  );
}

function Header({ isDark, setIsDark }) {
  return <ToggleButton isDark={isDark} setIsDark={setIsDark} />;
}

// 4. 필요한 곳에서 바로 수신
function ToggleButton() {
  const { isDark, setIsDark } = useContext(DarkModeContext);
  return <button onClick={() => setIsDark(!isDark)}>토글</button>;
}

function MainContent({ isDark }) {
  return <PostList isDark={isDark} />;
}

function PostList({ isDark }) {
  return <Post isDark={isDark} />; // 드디어...
}

function Post() {
  const { isDark } = useContext(DarkModeContext);
  return <div style={{ background: isDark ? "black" : "white" }}>Post</div>;
}

 

Context가 과한 경우

- 가까운 자식 관계

그냥 props로 처리하면 됨

- 자주 변하는 데이터

Context 값이 바뀌면 구독하는 모든 컴포넌트 리렌더링

- 서버 데이터

API로 가져오는 데이터는 React Query가 더 적합한다. 

 

⭐Custom Provider의 분리

앞선 코드는 모든 Provider을 App.jsx에 몰아넣게 된다. 

// App.jsx
const DarkModeContext = createContext();

function App() {
  const [isDark, setIsDark] = useState(false);
  const toggle = () => setIsDark(prev => !prev);
  return (
    <DarkModeContext.Provider value={{ isDark, toggle }}>
      <Layout />
    </DarkModeContext.Provider>
  );
}

 

- 관심사 분리 안됨

- 기능 추가될때마다 App이 비대해짐

- 재사용 불가

- 컨슈머 쪽도 번거롭다

// 쓰는 곳마다 이 두 줄 반복
import { useContext } from 'react';
import { DarkModeContext } from '../App';

const { isDark, toggle } = useContext(DarkModeContext);

 

그래서 실무에서는 Context 관련 코드를 별도 파일에 모으고, App은 그냥 쓰기만 한다. 

import { createContext, useContext, useState } from 'react';

// 1️⃣ Context 생성 (내부용이라 export 안 해도 OK)
const DarkModeContext = createContext();

// 2️⃣ Provider를 "일반 컴포넌트"로 만들기
export function DarkModeProvider({ children }) {
  const [isDark, setIsDark] = useState(false);
  const toggle = () => setIsDark(prev => !prev);

  return (
    <DarkModeContext.Provider value={{ isDark, toggle }}>
      {children}
    </DarkModeContext.Provider>
  );
}

// 3️⃣ 커스텀 훅으로 소비 로직도 감싸기
export function useDarkMode() {
  return useContext(DarkModeContext);
}

 

상태관리의 짧은 역사

2013 ~ 2015: useState만 쓰기 → Props Drilling 지옥
2015 ~ 2018: Redux 전성기 → 보일러플레이트 지옥
2018 ~ 2020: Context API + Redux Toolkit → 보일러플레이트 감소
2020 ~ 현재: Zustand, Jotai, TanStack Query 등장 → 목적별 분리

요새는 ZustandTanStack을 많이 쓰긴 한다

1) Zustand - 현재 1위

import { create } from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

// 사용 — Provider 없음!
function Counter() {
  const { count, increment } = useStore();
  return <button onClick={increment}>{count}</button>;
}

- 가벼움

- Provider 필요 없음

- 보일러플레이트 거의 0

- TypeScript 잘 지원

 

2) TanStack Query

서버 상태 전용 도구 - 전역 상태의 패러다임 자체를 바꿨다. 

import { useQuery } from '@tanstack/react-query';

function Posts() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('/api/posts').then(r => r.json()),
  });

  if (isLoading) return <Loading />;
  if (error) return <Error />;
  return <List data={data} />;
}

- 로딩/ 에러 / 빈 데이터 상태 자동 관리

- 캐싱, 백그라운드 재조회

- 재시도, 취소

- Race Condition 방어