Musta React

11/27 (Wed) 상태관리목적이 "아닌" useSearchParams()의 사용 & Pagination 로직 이해 & Context API

taytay 2024. 11. 27. 17:54

옵셔널 체이닝 (Optional Chaining)

?.는 객체에서 **존재하지 않을 수 있는 속성**에 안전하게 접근할 때 사용됩니다.
undefined나 null을 만나면 에러를 발생시키지 않고, 대신 undefined를 반환합니다.

 

useSearchParams

쿼리 스트링(URI에 포함된 ? 뒷부분) 정보를 읽거나 설정하는 데 사용

// list?page=2 요청시
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page') || 1);
// 다음 페이지로 이동
searchParams.set('page', page+1);
setSearchParams(searchParams);

 

pagination 컴포넌트를 따로 뺐는데, TodoList 컴포넌트에서 사용하고 있는 useSearchParams()를 Pagination 컴포넌트에서도 사용하고 있다 -> 문제가 없다. -> 왜?
(🧐 use~로 시작하는 모든 훅은 상태관리 목적으로 쓰이는 게 아니다!!
비슷하게 보이니까 다 똑같이 쓰이는줄 알고 이런 의문 가짐 👀)


TodoList.jsx

function TodoList() {
// ⛱️ useRef를 이용하여 검색창 구현 (실시간 렌더링 필요없으니까 useState ❌)
const searchRef = useRef("");

 // 쿼리 스트링 정보를 읽거나 설정
const [searchParams, setSearchParams] = useSearchParams();

const params = {
    // 🚧 검색어 쳐서 검색한 뒤, 다시 TodoList 눌렀을 때, 검색어가 없어지고 Emptry string으로 나오도록 설정해야 함 rf) 여기서 (|| "") 라고 설정해도 안됨..
    keyword: searchParams.get("keyword"), // 환승 (검색어 꺼내오기)
    page: searchParams.get("page") || 1, // 페이지 디폴트값은 1페이지
    limit: 5, // 설정 안하면 10이 디폴트값
  };
  
const [data, setData] = useState(); // 🌺

// "리액트의 훅 규칙" - 훅은 항상 같은 순서로 호출되어야 정상적으로 작동
// => 리액트는 훅을 배열에 저장하기 때문에 후에 특정 상황에서 누락될 수 있는 가능성 존재... (필기참고)
// 📍 결론: API 호출 시점과 훅 사용 시점이 동일 → 조건문에 쓰면 에러 위험.
// const { data } = useFetch({ url: "/todolist" }); // 🌺

// 📍 해결방법: 대신 useAxiosInstance로 Axios 인스턴스만 반환하도록 만들었고, fetchList라는 함수를 따로 만들어 필요한 시점에서 API를 호출
// => 훅에서 요청하지 않고, 독립적인 함수에서 요청 → 호출 시점을 분리.
const axios = useAxiosInstance(); // 🌺

  // 🖍️ 마운트 직후의 삭제 후에 목록 조회를 담당하는 함수(fetchList) 만듦
  // (+ 인자값에 의해서만 결과가 좌우되도록(함수의 독립성 keep) 매개변수를 설정)
  const fetchList = async (params = {}) => {
    const res = await axios.get(`/todolist`, { params }); 
    // ⛱️ 두번째는 옵션을 전달 - params: ? 찍고 뒤에 보내는 값 (todolist바로 뒤에 하드코드할 수도 있지만, 여러개의 파라미터가 있을 수도 있끼 때문에 객체로 보낼것임 - keyword(검색어)가 넘어감)
    setData(res.data);
  };

  // 🌺 마운트 시에, 데이터 가져와서 보여주긴 해야하니까 빈배열로
  useEffect(() => {
    // ⚠️ fetchList()안에 매개변수를 쓰면 경고창이 뜸! ⚠️
    fetchList(params);
  }, [searchParams]); // ⛱️ 주소창은 검색어에 따라 ?keyword=''이 붙으면서 잘 바뀌는데, 목록창이 안 바뀜!! 
  // => 빈 배열로 해놓으면 마운트(최초 렌더링) 됐을 때만 호출되기 때문, 우리는 검색어가 바뀔 때마다(searchParams) 호출되도록 디펜던시 설정

  // 삭제 작업
  const handleDelete = async (_id) => {
    try {
      // TODO: API서버에 삭제 요청
      await axios.delete(`/todolist/${_id}`);
      alert("할일이 삭제 되었습니다.");

      // 🌺 TODO: 목록을 다시 조회
      fetchList();
    } catch (err) {
      console.error(err);
      alert("할일 삭제에 실패하였습니다.");
    }
  };
  
// 최초에는 비어있다가, useEffect에 의해 data = dummyData로 채워짐.
const itemList = data?.items.map((item) => (
<TodoListItem key={item._id} item={item} handleDelete={handleDelete} />
));

const handleSearch = (e) => {
    e.preventDefault();
    // current 속성을 거쳐서 !!
    const inputKeyword = searchRef.current.value;
    console.log(inputKeyword);

    // 2. 유저가 입력한 값을 키워드값으로 설정하는 작업: URLSearchParams()
    const newSearchParams = new URLSearchParams(`keyword=${inputKeyword}`);
    setSearchParams(newSearchParams); // 💡
  };
  
   return (
    <div id="main">
      <h2>할일 목록</h2>
      <div className="todo">
        <Link to="/list/add">추가</Link>
        <br />
        ✅ input요소가 onSubmit이벤트가 달린 form 요소에 묶여져 있음
        -> submit이벤트 발생시(검색 버튼 눌렀을 시), handleSearch 호출
        -> handleSearch에서 상태값(💡searchParams💡) 변경 -> 컴포넌트 리렌더링 -> 화면 갱신
        <form className="search" onSubmit={handleSearch}>
        
          ✨ useState vsd useRef ✨
          <input
            type="text"
            autoFocus
            💥비제어 컴포넌트로 만들고, useRef훅 사용한 이유: 검색어가 실시간으로 렌더링될 필요❌!!
            defaultValue={params.keyword}
            ref={searchRef}
          />
          <button type="submit">검색</button> // ✅ type="submit"
        </form>
        <ul className="todolist">{itemList}</ul>
      </div>

      {data && (
        <Pagination
          🧩totalPages={data.pagination.totalPages}🧩
          🧩current={data.pagination.page}🧩
        />
      )}

      <Outlet />
    </div>
  );
}

 

Pagination.jsx

import PropTypes from "prop-types";
import { Link, useSearchParams } from "react-router-dom";

Pagination.propTypes = {
  totalPages: PropTypes.number.isRequired,
  current: PropTypes.number, // 없으면 1로 초기화해주니까 갠춚
};

// <Props>
- totalPages={data.pagination.totalPages}🧩
- current={data.pagination.page}🧩

function Pagination({ totalPages, current = 1 }) {
  // const current = data?.pagination.page; // prop으로 받아올 거라 필요없다.

  let pageList = [];
  const [searchParams] = useSearchParams(); // for문 바깥에 정의

  // pagination 속성은 항상 있기 때문에 굳이 ? 안붙여도 OK
  // 💥💥data는 붙여라!!💥💥
  for (let page = 1; page <= totalPages; page++) {
    searchParams.set("page", page); // page속성을 1.2.3..으로 설정
    let search = searchParams.toString(); // toString: /list?🪝keyword=환승&page=1/2/3🪝 여기서 ?뒤의 문자열을 꺼내옴 (이때, 키워드까지 다같이 뽑아오는 것!)

    pageList.push(
      <li key={page} className={current === page ? "active" : ""}>
        <Link to={`/list?${search}`}>{page}</Link>
      </li>
    );
  }

  return (
    <div className="pagination">
      <ul>{pageList}</ul>
    </div>
  );
}

export default Pagination;

 

useSearchParams()를 두 개의 컴포넌트에서 동시에 사용해도 문제가 되지 않습니다. 이는 useState()와는 다르게, 독립적으로 관리되는 값이 아니라, 브라우저 URL에 의존하는 값이기 때문입니다. 

* useSearchParams는 현재 URL의 쿼리 문자열을 읽고 수정하는 도구에 불과하며, useState와 같은 상태 관리 메커니즘과는 다릅니다.

 

1. useSearchParams란?

useSearchParams는 React Router v6에서 제공하는 훅으로, URL의 쿼리 문자열(search params)을 읽거나 업데이트할 수 있게 해줍니다. 이 훅은 현재 URL의 쿼리 파라미터를 가져오는 역할을 하며, React 상태와 직접적으로 연결되지 않습니다.

반환값:

  • 첫 번째 요소: URLSearchParams 객체 (현재 쿼리 문자열을 조작할 수 있는 객체)
  • 두 번째 요소: 쿼리 문자열을 업데이트하는 함수

 

2. useSearchParams는 상태가 아니다

  • useState()와 달리, useSearchParams는 상태를 저장하지 않고 URL의 현재 쿼리 문자열에만 접근합니다.
  • 쿼리 문자열은 브라우저의 URL과 관련된 정보로, 브라우저가 이를 관리합니다.
  • 따라서 여러 컴포넌트에서 useSearchParams()를 동시에 호출해도 충돌이나 상태 관리 문제는 발생하지 않습니다.
 

3. 여러 컴포넌트에서 useSearchParams 사용 시

공통점:

  • 모든 컴포넌트에서 useSearchParams()는 URL의 현재 쿼리 파라미터를 가져옵니다.
  • 쿼리 파라미터가 변경되면(예: setSearchParams 호출) 동일한 URL에서 useSearchParams를 사용하는 모든 컴포넌트가 새로운 값을 받습니다. => 현재 쿼리 문자열이라는 상태값(실제 상태는 아니다.)이 연결되어 있는 셈!!

차이점:

  • 컴포넌트마다 독립적으로 searchParams를 읽고 사용할 수 있습니다.
  • 컴포넌트에서 setSearchParams를 호출하면 URL이 업데이트되고, 해당 URL의 새로운 값을 기준으로 다시 렌더링됩니다.

 

4. 왜 상태와 다르게 동작할까?

  • useSearchParams는 React 상태처럼 독립된 값을 저장하지 않고, URL에 의존하기 때문입니다.
  • React의 상태(useState)는 컴포넌트의 독립적인 메모리에서 관리되지만, useSearchParams는 URL을 중심으로 동작하므로 공유된 리소스(URL)를 기반으로 동작합니다. = prop시스템 대신에 쓰이는 컨텍스트 느낌?...

5. 실시간 파라미터 사용

useSearchParams는 항상 브라우저의 현재 URL을 기준으로 동작합니다.
따라서 1. 사용자가 브라우저에서 직접 URL을 변경하거나, 🌟🌟2. 다른 컴포넌트에서 setSearchParams를 호출하여 쿼리 문자열을 업데이트하면, 그 변경 사항이 즉시 반영🌟🌟됩니다.

 

참고: 상태 관리가 필요한 경우

만약 URL 파라미터를 React 상태와 함께 사용해야 하는 상황이라면, useSearchParams를 사용하여 파라미터 값을 읽은 후, 이를 useState로 관리하면 됩니다.

const [searchParams] = useSearchParams();
const [currentPage, setCurrentPage] = useState(Number(searchParams.get("page")) || 1);

useEffect(() => {
  setCurrentPage(Number(searchParams.get("page")) || 1);
}, [searchParams]);

return <div>현재 페이지: {currentPage}</div>;

 

 

Context API란?

컴포넌트 트리에서 부모 컴포넌트의 상태나 데이터를 자식 컴포넌트에 전달할 때 보통 props를 사용하지만, 컴포넌트 트리가 깊어질수록 불편해짐. 

=> 여러 컴포넌트가 상태를 공유해야 한다면, 공통의 부모 컴포넌트에서 상태를 관리해야 함.

  • 트리가 깊어지면 상위 컴포넌트의 데이터를 하위 컴포넌트로 전달하기 위해 많은 중간 컴포넌트를 거쳐야 하므로 코드가 복잡해짐
  • prop 이름이 변경되거나 새로운 props가 추가되면 모든 중간 컴포넌트를 수정해야 하는 번거로움이 있음.
  •  데이터 변경 시 중간 컴포넌트들이 불필요하게 리렌더링되는 문제가 발생.
  •  동일한 데이터를 여러 자식 컴포넌트에 전달할 경우 번거롭고 불편해짐.
  • Context API는 컴포넌트 트리에서 데이터를 효율적으로 전달하기 위해 리엑트에서 기본으로 제공하는 API
  • Context API를 사용하면 매번 자식 컴포넌트에 prop을 전달하지 않고, 필요한 데이터를 직접 전달할 수 있어 불필요한 리렌더링과 코드 복잡성을 줄일 수 있음.
  • Context API를 이용해서 하위 컴포넌트에 상태와 이를 수정하는 함수를 전달하면 간단한 상태 관리 도구로 사용할 수 있지만 Context API 자체적으로 상태를 관리하거나 복잡한 상태 변경 로직을 처리할 수 있는 기능은 없으므로 복잡한 상태관리가 필요하다면 글로벌 상태관리 라이브러리를 사용하는게 편함

 

사용 방법

Context 객체 생성

  • React.createContext() 함수로 생성

 

Provider 컴포넌트 작성

  • 상태와 상태 변경 함수를 관리/제공할 컴포넌트 = Provider(데이터 공급자) 
  • Context 객체가 제공하는 Provider "컴포넌트"를 사용해서 자식 컴포넌트를 렌더링하고, 이때 Provider의 value 속성으로 전달할 Context(상태/상태변경 함수)를 지정
import { createContext, useState } from "react";

// Context 객체 생성
const CounterContext = createContext();

// Provider 컴포넌트 작성
export function CounterProvider({ children }){
  // 상태
  const [count, setCount] = useState(10);
  
  // 상태 변경 함수
  const countUp = function(step){
    setCount(count + step);
  };

  // 하위 컴포넌트에 전달할 Context
  const values = {
    state: { count },
    actions: { countUp }
  };

  return (
    <CounterContext.Provider value={ values }>
      { children }
    </CounterContext.Provider>
  );
}

export default CounterContext;

 

자식 컴포넌트에 Context 제공 = Provider 컴포넌트로 제공 받을 컴포넌트를 감싸자

import { CounterProvider } from '@context/CounterContext';

<CounterProvider>
  <Left1 />
  <Right1 />
</CounterProvider>

// = 아래와 같은 코드 (컨텍스트 객체의 Provider 컴포넌트 사용한 것)
<CounterContext.Provider>
  <Left1 />
  <Right1 />
</CounterContext.Provider>

 

 

자식 컴포넌트에서 Context 사용 = useContext(컨텍스트)를 이용해 사용할 데이터 꺼내자

React.useContext 훅을 이용해 Context를 꺼내서 사용

import CounterContext from '@context/CounterContext';
import { useContext } from 'react';

const { state: { count } } = useContext(CounterContext);

 

Context API 사용 사례

1. 테마 지정

다크모드, 라이트 모드 등의 테마를 사용자가 수정할 수 있게 제공할 경우, 컴포넌트 트리 상단에서 Context를 제공하고 선택한 테마를 하위 컴포넌트에서 사용

 

2. 로그인 상태 관리

사용자의 로그인 상태(로그인/비로그인) 여부를 Context로 제공하고, 하위 컴포넌트에서 사용

 

3. 전역 상태 관리

여러 컴포넌트가 공통으로 관리해야 하는 상태를 Context로 제공하고, 하위 컴포넌트에서 상태를 수정하면 필요한 모든 컴포넌트에서 수정된 상태를 사용해서 리렌더링

 

Context API의 단점

  • useContext()를 사용하는 컴포넌트는 재활용이 어려울 수 있음
  • => Context를 제공하는 Provider에 의존하게 되므로 Provider를 벗어난 곳에서는 사용할 수 없음
  • 하나의 Context에는 하나의 상태만 저장 가능
  • => 여러 상태를 관리하기 위해 객체를 상태로 지정할 수 있지만, 객체의 속성 하나만 변경되어도 구독하는 모든 컴포넌트가 리렌더링되는 현상 발생)
  • => Context를 여러 개 만들어서 각각의 Context에 상태를 분리할 수는 있지만, 상태가 복잡하고 규모가 큰 경우에 Context가 중첩되면 관리가 어려워짐
  •  전역 상태 관리를 위한 간단한 도구 (=> 복잡한 대규모 상태 관리에 적합하지 않음)