react-query를 이용한 filter & transform

GI JUNG·2023년 12월 12일
1

react-query

목록 보기
4/4
post-thumbnail

🍀 react-query를 이용한 filtering & transforming

useQuery는 refetch되는 조건을 제외하고는 state변경에 따라 다시 실행되지 않는다. 즉, 의존성 배열이 data만 들어간 배열인 useEffect의 동작과 같이 server에서 fetching한 data의 변동사항에 따라 component가 rendering시에만 실행된다.

하지만, filtering을 할 때는 filter의 값에 따라 데이터를 refetch해야한다. 이를 useEffect를 이용해서 의존성 배열에 filter 값을 넣어줄 수도 있지만, react-query가 제공하는 built-in인 selector를 사용하면 된다. 또한 backend에서 프론트가 원하는 데이터형태로 내려주지 않아 이를 사용하기 전에 원하는 형태로 transform(변환)하는데 사용될 수 있다.

따라서, selector는 fetch한 data에 filtlering & transforming할 때 유용하게 사용할 수 있다.

📚 react-query docs
This option can be used to transform or select a part of the data returned by the query function. It affects the returned data value, but does not affect what gets stored in the query cache.
select option은 query함수의 return 값인 데이터 대한 변형할 때 사용할 수 있으며 cache저장되어 있는 원본 데이터에 대한 변경은 하지 않는다.

예제는 jsonplaceholder에서 제공하는 Todo API를 사용한다. 먼저 selector를 사용하지 않은 기존 코드를 살펴보자.

❌ Without Selector

프론트에서는 todo에 대한 title을 uppercase로 보여주고 싶은데 backend에서는 대,소문자가 섞인 문자열을 보내준다고 가정해보자. 그러면 todo에 대한 data를 변환(transform)해야 한다. 이에 대해서 먼저 살펴보자. 아마 아래와 같은 변형 함수가 필요할 것이다.

const changeTitleToUpper = (data: Todo[]) =>
  data.map((todo) => ({
    ...todo,
    title: todo.title.toUpperCase(),
  }));

이제 filter 값 All, Done, DoneYet에 따라 todo를 filtering해보자.

const BASE_URL = "https://jsonplaceholder.typicode.com/todos?_page=1";

const fetchTodos = async (url: string = BASE_URL) => {
  const headers = { "Content-Type": "application/json" };

  try {
    const response = await fetch(url, {
      headers,
    });

    if (!response.ok) {
      throw new Error("couldn't fetch todos data");
    }

    return response.json();
  } catch (error) {
    throw new Error((error as Error).message);
  }
};

const fetchFilteredTodos = async (filter: Filter): Promise<Todo[]> => {
  switch (filter) {
    case "All":
      return fetchTodos(BASE_URL);
    case "Done":
      return fetchTodos(`${BASE_URL}&completed=${true}`);
    case "DoneYet":
      return fetchTodos(`${BASE_URL}&completed=${false}`);
    default:
      return fetchTodos(BASE_URL);
  }
};

function WithoutSelector() {
  const [filterValue, setFilterValue] = useState<Filter>("All");
  const [data, setData] = useState<Todo[]>([]);

  useEffect(() => {
    (async () => {
      const filteredTodoData = await fetchFilteredTodos(filterValue);
      const transformedData = changeTitleToUpper(filteredTodoData);
      
      setData(transformedData);
    })();
  }, [filterValue]);
}

Done, DoneYet의 상태에 따라 서버에서 데이터를 받아와야 하므로 부수효과를 useEffect에서 처리해주었다.
하지만, 사실상 에러처리 및 Loading에 대한 Ui도 따로 구현해야 하며, 이 외에도 filterValue가 All, Done, DoneYet 상태로 바뀜에 따라서 서버에서 data를 fetching할 때마다 loading UI를 보여주어야 한다는 단점이 있다.

내가 생각하는 단점을 보기 좋게 나열하면

  1. caching을 구현하지 않는 이상 Hard Loading을 유발해 UX를 낮춘다.
  2. 에러 처리에 대한 수고를 더 요구한다.
  3. 효율적인 데이터 관리 X

전체 코드

interface Todo {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

type Filter = "All" | "Done" | "DoneYet";

const BASE_URL = "https://jsonplaceholder.typicode.com/todos?_page=1";
const fetchTodos = async (url: string = BASE_URL) => {
  const headers = { "Content-Type": "application/json" };

  try {
    const response = await fetch(url, {
      headers,
    });

    if (!response.ok) {
      throw new Error("couldn't fetch todos data");
    }

    return response.json();
  } catch (error) {
    throw new Error((error as Error).message);
  }
};

const fetchFilteredTodos = async (filter: Filter): Promise<Todo[]> => {
  switch (filter) {
    case "All":
      return fetchTodos(BASE_URL);
    case "Done":
      return fetchTodos(`${BASE_URL}&completed=${true}`);
    case "DoneYet":
      return fetchTodos(`${BASE_URL}&completed=${false}`);
    default:
      return fetchTodos(BASE_URL);
  }
};

const changeTitleToUpper = (data: Todo[]) =>
  data.map((todo) => ({
    ...todo,
    title: todo.title.toUpperCase(),
  }));

function WithoutSelector() {
  const [filterValue, setFilterValue] = useState<Filter>("All");
  const [data, setData] = useState<Todo[]>([]);
  const [forceUpdate, setForceUpdate] = useState(true);

  useEffect(() => {
    (async () => {
      const filteredTodoData = await fetchFilteredTodos(filterValue);
      const transformedData = changeTitleToUpper(filteredTodoData);

      setData(transformedData);
    })();
  }, [filterValue]);

  const handleFilterChange = (e: ChangeEvent<HTMLSelectElement>) =>
    setFilterValue(e.target.value as Filter);


  return (
    <>
      <div>
        <select value={filterValue} onChange={handleFilterChange}>
          <option value="All">All</option>
          <option value="Done">Done</option>
          <option value="DoneYet">Doneyet</option>
        </select>
        <button>click button to refetch</button>
      </div>
      <div>
        <ul>
          {data.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      </div>
    </>
  );
}

export default WithoutSelector;

✅ With Selector

selector는 원본 데이터를 인자를 받아 원본 데이터에 가공에 대한 결과값을 data에 return하며 가공된 데이터는 caching이 된다. 또한 selector는 react-query의 built-in으로 react-query가 제공하는 쉬운 loading, error 처리 등 개발자가 개발에만 더 집중할 수 있게 해준다.(DX 개선)

select?: (data: TQueryData) => TData;

이제 selector를 이용해서 title을 uppercase로 변환하는 코드를 보자.

const { data } = useQuery({
  queryKey: ["todos", filterValue],
  queryFn: () => fetchFilteredTodos(filterValue),
  select: (data) => 
    data.map((todo) => ({
      ...todo,
      title: todo.title.toUpperCase(),
    })),
  });

기본적인 처리를 queryKey와 귀속시킬 수 있다.

이제 filtering을 진행해야 하는데 위에서 filtering을 server에서 하고 있다. 하지만, selector는 순수함수로서 side-effect를 일으키지 않는 것이 바람직하며 이렇게 함으로써 data를 fetching하는 로직과 filtering하는 로직을 명확회 분리시킬 수 있다. 따라서, filter를 client단에서 실행하는 코드로 바꿔보자.

전체코드

/* eslint-disable @typescript-eslint/no-unused-vars */
import { useQuery, useQueryClient } from "@tanstack/react-query";
import "./App.css";
import { ChangeEvent, useState } from "react";

interface Todo {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

type Filter = "All" | "Done" | "DoneYet";

const BASE_URL = "https://jsonplaceholder.typicode.com/todos?_page=1";
const fetchTodos = async (url: string = BASE_URL): Promise<Todo[]> => {
  const headers = { "Content-Type": "application/json" };

  try {
    const response = await fetch(url, {
      headers,
    });

    if (!response.ok) {
      throw new Error("couldn't fetch todos data");
    }

    return response.json();
  } catch (error) {
    throw new Error((error as Error).message);
  }
};

// 👇 server에서 filtering이 아닌 clent에서 filtering 수행
const filterTodos = (data: Todo[], filter: Filter) => {
  switch (filter) {
    case "All":
      return data;
    case "Done":
      return data.filter((todo) => todo.completed);
    case "DoneYet":
      return data.filter((todo) => !todo.completed);
  }
};

const changeTitleToUpper = (data: Todo[]) =>
  data.map((todo) => ({
    ...todo,
    title: todo.title.toUpperCase(),
  }));

function App() {
  const [filterValue, setFilterValue] = useState<Filter>("All");
  const queryClient = useQueryClient();

  const { data, isLoading } = useQuery({
    queryKey: ["todos", filterValue],
    queryFn: () => fetchTodos(),
    // 👇 ⭐️ 대문자 변형과 filterValue에 따른 filtering
    select: (data) => filterTodos(changeTitleToUpper(data), filterValue),
  });

  const handleFilterChange = (e: ChangeEvent<HTMLSelectElement>) =>
    setFilterValue(e.target.value as Filter);


  if (isLoading) return <h1>Loading...</h1>;

  return (
    <>
      <div>
        <select value={filterValue} onChange={handleFilterChange}>
          <option value="All">All</option>
          <option value="Done">Done</option>
          <option value="DoneYet">Doneyet</option>
        </select>
      </div>
      <div>
        <ul>
          {data?.map((todo) => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      </div>
    </>
  );
}

export default App;

핵심은 ⭐️에 밖에 없다. selector를 사용함으로써 react-query가 제공하는 편리한 부가적인 기능을 편하게 쓸 수 있으며 개발자가 처리할 양도 줄어들게 된다.
먼저 구현을 알아봤는데 selector의 특징을 알아보자.

1️⃣ 원본 데이터를 건드리지 않는다.

selector를 이용한 useQuery의 return되는 data는 selector에 의해 변형 또는 필터링을 거친 값이다. 실직적으로 cache에 저장되는 것은 원본데이터로서 selector는 변형된 데이터를 반환할 뿐이지 원본 데이터는 보존한다. 따라서, 매 rendering마다 서버의 요청은 발생하지 않지만, data가 undefined가 아니라면 매회 selector함수는 실행된다.

이를 확인해보기 위해서 react-query의 devtools를 활용할 수 있지만, cache에 접근에서 값을 확인해보자.

// App.tsx
import { useQuery, useQueryClient } from "@tanstack/react-query";

function App() {
  const queryClient = useQueryClient();
  const { data } = useQuery(.../)
  ...//
  console.log("변형된 데이터:", data);
  console.log("원본 데이터:", queryClient.getQueryData(["todos", "All"]));
  
  ...//
}

타이틀을 보면 cache에 저장된 것은 변형된 데이터가 아닌 서버에서 가져온 원본 데이터가 저장됨을 볼 수 있다.

2️⃣ caching -> Hard Loading 개선

위의 코드를 실행시키면 처음 Loading이후 cache에서 데이터를 접근하여 가져오므로 초기에만 Loading을 보여주어 UX를 개선시킬 수 있다.

All과 Done에 대해서 초기 loading때만 Loading UI를 보여주고 이후에는 Loading없이 빠른 UI를 사용자에게 보여준다.

또한 staleTime만료가 아니라면 서버에 요청을 시도하지 않아 서버부하를 줄일 수 있다. 이 외에도 react-query가 기본적으로 제공하는 것을 통해 효율적으로 데이터를 관리할 수 있다.

3️⃣ 선언적 & 가독성

selector를 이용하여 data를 변환하면 안 쓴 것보다 보다 더 선언적이며 코드의 가독성이 좋다. 예시의 코드는 짧지만, 만약에 더 길다면 더 체감이 갈 듯 하다.

🔥 마치며

selector를 알기 전에는 그저 queryFn 내부에서 filtering을 구현하다가 selector를 이용한 filtering을 보고 정리해보았다. selector를 이용하여 구현하는 것이 더 가독성도 좋고 선언적이라 react-query 매력이 한 층 더 업그레이드 됐다. 데이터 페칭과 변환을 명확히 분리할 수 있는 것과 쿼리에 명확히 종속시키고 custom hook으로 빼면 보다 더 재사용성이 좋게 코드를 구현할 수 있을 것 같다.

📚 참고

tktodo react-query data transform
useQuery select docs

profile
step by step

0개의 댓글