들어가며

Tanstack Query의 `useQuery`는 HTTP GET 요청을 보낼 때 사용하는 함수입니다.

이 함수에는 GET 요청에 사용될 비동기 함수를 지정하는 queryFn과, 받아온 데이터를 캐싱할 queryKey를 지정할 수 있습니다.

 

공식문서 보기

 

이때, queryFn의 인자로 아래 코드와 같이 `QueryFunctionContext`가 제공되는데, 이를 활용하는 방법에 대해 알아보겠습니다.

 

// useQuery에서 제공되는 QueryFunctionContext
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['post', postId],
    queryFn: (QueryFunctionContext) => user_async_function() // QueryFunctionContext의 위치
    enabled: postId !== undefined
  })

QueryFunctionContext의 구성요소

`queryFn`은 `QueryFunctionContext`를 객체 형식의 인자로 제공합니다.

 

공식문서에서는 다음과 같이 설명해줍니다.

[공식문서]
The QueryFunctionContext is the object passed to each query function. It consists of:
1. queryKey: QueryKey - Query Keys  
2. signal?: AbortSignal
- AbortSignal instance provided by TanStack Query
- Can be used for Query Cancellation
3. meta: Record<string, unknown> | undefined
- an optional field you can fill with additional information about your query

 


queryKey

`queryKey`는 해당 `useQuery`에서 사용된 `queryKey`를 반환합니다.

 

이는 Query Key에 변수 값을 사용할 경우, 해당 값을 `queryFn`의 인자로 적용해야 할 때 유용합니다.

 

예를 들어, 글의 ID값을 기반으로 API를 호출한다고 가정해보겠습니다.

 

[대략적인 프로세스]

  1. 글의 id 값을 async fetch. (다른 API를 통해)
  2. 해당 id를 가진 글을 사용자가 클릭하면, 글의 정보를 async fetch.
  3. 이때, queryKey를 ['post', postId] 형태로 관리.

해당 로직은 기본적으로 다음과 같이 useQuery를 활용하여 구현할 수 있습니다.

// #0. Fetch Info using planId (useQuery)
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => getPlan({postId: postId})
    enabled: postId !== undefined
  })

 

이때, 제공되는 `queryKey` 값을 사용하면 컴포넌트에서 관리하는 state대신 useQuery에서 사용되는 queryKey를 사용할 수 있습니다.

  • `queryKey`는 위에서 제공해준 값과 동일하게 주어진다. (예시의 경우 ['post', postId])
const { data, isPending, isError, error } = useQuery({
  queryKey: ['post', postId],
  queryFn: ({ queryKey }) => getPlan({ postId: queryKey[1] }),
  enabled: postId !== undefined,
});

 

이처럼 queryKey를 통해 postId 값을 쉽게 활용할 수 있습니다.

그래서 뭐..? 같은 값에 접근하여 사용하는거 아닌가..?🤔

네 맞습니다.

 

위 예시는 `postId`에 해당하는 하나의 변수만은 `queryKey`에 사용하지만, 복잡한 구조를 가진 코드에서 변수 값을 찾으러 다니기 보다, 가장 가까운 `queryKey`에서 찾는 것이 미래의 내가 코드를 다시 읽을때 편리하지 않을까요?

아래 그림의 2번 루트가 1번 루트보다 가깝다고 할 수 있으니, 디버깅 하기에 원활하지 않을까 생각합니다.

queryKey 부연설명


signal: AbortSignal

(AbortSignal은 Typescript에서 사용하는 type입니다. 기본적으로 제공되는 타입이므로 import하지 않아도 됩니다.)

 

다음은 `signal`입니다. 개인적으로 이해하는데 상당히 애먹었던 개념입니다.

먼저, MDN에서는 Signal에 대해 이렇게 설명합니다.

[MDN, AbortSignal]
AbortSignal 인터페이스는 DOM 요청(Fetch와 같은)과 통신하고 필요한 경우 AbortController 객체를 통해 취소할 수 있게 해주는 신호 객체를 나타냅니다.

 

직역하면, “통신을 취소”할 때 발생하는 “신호”로 이해할 수 있습니다.

 

 

이때, `queryFn`에서 제공하는 signal은 useQuery로 인해 발생하는 signal을 자동으로 관리하는 역할을 수행합니다.

즉, Tanstack Query의 라이프사이클(쿼리 재시작, 컴포넌트 언마운트 등)에 따라 자동으로 쿼리를 취소하는 역할을 합니다. 따라서, 개발자는 fetch의 옵션으로 signal을 제공해주기만 하면 됩니다.

 

구현 예제

async function fetch_example({ queryKey, signal }) {

	// 다음과 같이 signal을 옵션으로 넘겨주면 됩니다
	const response = await fetch(`/api/todos?status=${status}&page=${page}`, { signal });
	if (!response.ok) {
		throw new Error('Failed to fetch data');
	}
	return response.json();

	if (err.name === 'AbortError') {
		console.log('Request aborted');
		return;
	}
	throw err;
}

 

Signal에 대해 이해하셨다면, 이를 직접 생성하여 `fetch` 요청을 자유자재로 다룰 수 있습니다.

 

예를 들어

유저는 너무 길어지는 GET 요청에 화난 나머지, “취소” 버튼을 눌러 요청을 중단하고 싶다고 하면, 해당 버튼을 클릭했을때 요청이 중단되어야 합니다. 그렇지 않으면, 화난 유저는 사이트를 떠나 버리거나 쌍욕을 하며 회원탈퇴를 해버릴 수도 있습니다.

 

또는 개발자 측에서 길어지는 요청을 방지하기 위해, 의도적으로 Abort 시키는 방법도 있습니다.

이후, 유저에게 “네트워크 상황을 확인해주세요” 토스트를 띄워, 유저의 네트워크 상황에 의한 시간지연을 웹사이트 성능의 문제점으로 인식시키지 않도록 할 수 있습니다.

 

 

이처럼, AbortSignal은 요청을 취소하는 신호 객체로, 긴 요청을 중단하거나 사용자가 중단 버튼을 클릭했을 때 유용합니다.

 

다음은 일정시간이 지난 이후, 의도적으로 개발자가 요청을 중단시키는 코드입니다.

구현 예제

async function fetchWithTimeout({ queryKey, signal: querySignal }) {
  const [_key, { status, page }] = queryKey;
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 10000); // 10초 후 요청 중단

  const signal = querySignal || controller.signal;

  try {
    const response = await fetch(`/api/todos?status=${status}&page=${page}`, { signal });
    clearTimeout(timeoutId);

    if (!response.ok) {
      throw new Error('Failed to fetch data');
    }
    return response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      console.log('Request aborted');
      return;
    }
    throw err;
  }
}

 

 

useQuery는 기존처럼 동일하게 사용하면 됩니다.

function Todos({ status, page }) {
  const { data, error, isLoading } = useQuery({
    queryKey: ['todos', { status, page }],
    queryFn: fetchWithTimeout,
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

🤔근데, AbortController을 통해 객체를 따로 생성하는 이유는 무엇이에요?

말씀드렸다 시피, queryFn에서 제공하는 signal은 Tanstack Query의 라이프사이클(쿼리 재시작, 컴포넌트 언마운트 등)에 따라 자동으로 쿼리를 취소하는 역할을 하므로, 특정한 상황에 대응하기 위해서는 객체를 직접 생성해야 합니다.

 

이를 활용하여 다양한 `fetch`에 대한 커스텀 훅을 만들어서 npm package로 관리해보는 것도 좋은 방법일 것 같습니다.


meta

meta는 개발자가 원하는 값을 객체 형태로 추가할 수 있는 필드입니다.

 

예를 들어, fetch마다 다른 에러 메시지를 관리하고 싶을 때 활용할 수 있습니다.

 

이때, v5부터 `onError`, `onSuccess`, `onSettled` 과 같은 콜백 함수들을 더이상 useQuery에서 사용할 수 없으므로, QueryClient 객체에서 다음과 같이 설정해주어야 합니다.

 

[콜백함수가 사라진 이유]

 

구현 예제

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error, query) => {
      if (query.meta.errorMessage) {
        toast.error(query.meta.errorMessage);
      }
    },
  }),
});

export function useTodos() {
  return useQuery({
    queryKey: ['todos', 'list'],
    queryFn: fetchTodos,
    meta: { errorMessage: 'Failed to fetch todos' },
  });
}

 

 


마치며

QueryFunctionContext는 queryKey, signal, meta와 같은 다양한 기능을 제공합니다.

창의적이고 새로운 기술을 만드는 것도 좋지만, 사용하고 있는 기술에 대해 깊이있게 이해하는 것도 중요하다고 생각되는 공부였습니다.

 

참고 문헌

TODO LIST

  • 길어지는 요청에 대한 fetch 함수를 커스텀 함수로 만들어서 관리하기
  • queryFn의 abort에 대한 역할을 직접 network tab에서 관찰해보기

프로젝트를 하다보면, 유저에게서 입력을 받아야 하는 상황이 다수 존재합니다.

대표적으로, 폼을 만들어 정보를 입력받는 상황이 있습니다.

이러한 폼에서는 제어 컴포넌트로 구성할지 비제어 컴포넌트로 구성할지에 대한 고민이 필요합니다.

다음의 2가지 Form 형식을 참고하여 제어 컴포넌트와 비제어 컴포넌트가 무엇인지 알아보겠습니다.


제어 컴포넌트

리액트 공식문서에서 제공하는 제어 컴포넌트의 정의

우리는 React state를 “신뢰 가능한 단일 출처 (single source of truth)“로 만들어 두 요소를 결합할 수 있습니다.
그러면 폼을 렌더링하는 React 컴포넌트는 폼에 발생하는 사용자 입력값을 제어합니다.
이러한 방식으로 React에 의해 값이 제어되는 입력 폼 엘리먼트를 “제어 컴포넌트 (controlled component)“라고 합니다.

 

🔎 즉, State를 생성하여 폼에서 발생하는 값들을 관리하는 방식을 의미한다.

 

다음 예시 코드를 보면 이해가 쉽습니다.

function App() {
  const [input, setInput] = useState("");
  const onChange = (e) => {
    setInput(e.target.value);
  };

  return (
    <div>
      입력값
      <input onChange={onChange} />
    </div>
  );
}

export default App;

 

유저에게 입력을 받는 매우 간단한 코드입니다.

값이 변경될때마다, State로 정의한 input에 값을 반영합니다.

 

그렇다면, 비제어 컴포넌트는 무엇일까요?


비제어 컴포넌트

마찬가지로, 리액트 공식문서에서는 비제어 컴포넌트에 대해 다음과 같이 설명합니다.

비제어 컴포넌트는 DOM 자체에서 폼 데이터가 다루어집니다.

모든 state 업데이트에 대한 이벤트 핸들러를 작성하는 대신 비제어 컴포넌트를 만들려면 ref를 사용하여 DOM에서 폼 값을 가져올 수 있습니다.

 

즉, 기존에 JQuery 혹은 Vanilla Javascript를 통해 DOM 객체에 직접 접근하여 값을 가져오는 방식을 의미합니다.

 

React에서는 useRef를 통해 DOM 객체에 직접 접근가능합니다.

다음 간단한 예시 코드를 살펴봅시다.

function App() {
  const inputRef = useRef(); // ref 사용
  const onClick = () => {
    console.log(inputRef.current.value);
  };

  return (
    <div className="App">
      <input ref={inputRef} />
      <button type="submit" onClick={onClick}>
        전송
      </button>
    </div>
  );
}
export default App;

 

해당 폼은 useRef Hook을 통해 사용자가 입력한 값을 폼 전송시에 접근합니다.

 

이렇게, 리액트에서의 제어 컴포넌트와 비제어 컴포넌트의 개념에 대해 알아보았습니다.

 


 

그렇다면 어떤 컴포넌트 형식을 언제 활용해야 할까요?

이를 알아보기 위해서, 먼저 각 방식의 장단점에 대해 분석해봅시다.

제어 컴포넌트의 장단점

장점

관리하는 값을 최신 상태 유지시킨다.

→ 유효성 검사 가능 (ex. 이메일 형식)

→ 실시간 피드백 제공 (ex. 비밀번호 복잡도)

 

단점

값이 바뀔때마다 리렌더링된다.

제어 컴포넌트에서의 단점은 생각보다 치명적입니다.

이전에 보았던 코드에서, 리렌더링 될때마다 콘솔로그를 찍어봅시다.

let render = 0;
function App() {
  const [input, setInput] = useState("");
  const onChange = (e) => {
    setInput(e.target.value);
  };

  const printRender = () => {
    console.log(render);
  };

  useEffect(() => {
    console.log(input);
    render++;
  }, [input]);

  return (
    <div>
      입력값
      <input onChange={onChange} />
      <button type="button" onClick={printRender}>
	        렌더링 횟수 출력
      </button>
    </div>
  );
}

export default App;

 

결과

 

이메일 하나 입력하는데 무려 20번의 Rerendering이 발생했습니다.

반면, 예상할 수 있듯이 비제어 컴포넌트는  Rerendering 횟수를 최소화할 수 있습니다.

마찬가지로, 기존 코드를 변형하여 봅시다.

import { useEffect, useRef } from "react";

let render = 0;
function App() {
  const inputRef = useRef(); // ref 사용

  const printRender = () => {
    console.log(render);
  };

  useEffect(() => {
    render++;
  }, [inputRef.current?.value]);
  return (
    <div className="App">
      입력값
      <input ref={inputRef} />
      <button type="button" onClick={printRender}>
        렌더링 횟수 출력
      </button>
    </div>
  );
}
export default App;

 

결과

비제어 컴포넌트의 렌더링

비제어 컴포넌트는 렌더링 횟수에서 제어 컴포넌트보다 우위에 있다고 이야기할 수 있습니다.

 


그렇다면 언제, 무엇을 사용해야 할까요?

장단점 분석의 뉘양스로는 제어 컴포넌트는 사용하지 않아야 할 것 같습니다.

하지만, 아닙니다.

무엇을 써야 할지는 상황에 따라 바뀝니다.

 

값을 추적하여 사용자에게 유효성 검사와 같은 기능을 제공해야 한다면 제어 컴포넌트를,

버튼을 클릭했을때 전체 유효성을 검사하는 방식을 선택해야 한다면 비제어 컴포넌트를 사용하면 됩니다.

 

물론, 하나의 폼에서 제어 컴포넌트와 비제어 컴포넌트를 동시에 사용하는 것도 괜찮습니다.

다만 그럴경우, 비제어 컴포넌트와 제어 컴포넌트는 컴포넌트화하여 렌더링되는 부분을 최소화하는 것이 좋겠습니다.

 


 

이상으로, 제어 컴포넌트와 비제어 컴포넌트가 무엇인지 알아보았습니다.

다음 글에서는 비제어 컴포넌트를 효율적으로 관리하는 React-Hook form 라이브러리 사용법에 대해 알아보겠습니다.

Zustand는 React 기반 프로젝트에서 전역상태를 관리하는 매우 유용한 라이브러리입니다. 가장 대중적으로 사용되던 Redux의 불편함을 뒤로하고, 편리하고 깔끔한 zustand가 npm trends에서 비등비등한 사용량을 보여주고 있습니다.
이에, Zustand가 어떠한 장점을 가지고 있고 사용하는 방법을 알아보는 시간을 가져보겠습니다.

 

(React CustomHook 및 상태 관리의 기본 개념에 대한 숙지를 전제한 상태로 작성되었습니다)

Redux를 뛰어넘은 zustand

Zustand의 장점

  1. Provider를 설정해줄 필요 없다.
  2. 보일러플레이트가 거의 zero에 가깝다.
  3. Redux와 같은 Flux Pattern을 사용하므로 친숙하다.
  4. Redux Devtools를 사용할 수 있다.

Zustand도 다른 상태관리 라이브러리와 마찬가지로, 상태를 구독하고 있는 Listener들을 저장합니다.

이후, 상태가 변경되었을 때 등록된 리스너들에게 상태가 변경되었다고 알려주며, 최신화된 상태값을 전달합니다.

(이를, 발행/구독 모델이라고 합니다)


사용방법

1. 설치하기

npm i zustand

2. Store 생성하기 (Store: 전역 변수)

  • Typescript에서는 `create<StoreType>` 을 명시해 주어야 합니다. (외 동일)
import { create } from 'zustand'

type State = {
  firstName: string
  lastName: string
}

type Action = {
  updateFirstName: (firstName: State['firstName']) => void
  updateLastName: (lastName: State['lastName']) => void
}

const usePersonStore = create<State & Action>((set) => ({
  firstName: '',
  lastName: '',
  updateFirstName: (firstName) => set(() => ({ firstName: firstName })),
  updateLastName: (lastName) => set(() => ({ lastName: lastName })),
}))

export default userPersonStore;

`

3. Component에서 사용하기

function App() {
  // "select" the needed state and actions, in this case, the firstName value
  // and the action updateFirstName
  const firstName = usePersonStore((state) => state.firstName)
  const updateFirstName = usePersonStore((state) => state.updateFirstName)
	// 전체 사용하는 방법
	const {firstName, lastName, updateFirstName, updateLastName} = usePersonStore();
  return (
    <main>
      <label>
        First name
        <input
          // Update the "firstName" state
          onChange={(e) => updateFirstName(e.currentTarget.value)}
          value={firstName}
        />
      </label>

      <p>
        Hello, <strong>{firstName}!</strong>
      </p>
    </main>
  )
}

 

Tip: 전체 스토어를 구독하지 않고, 필요한 부분만 구독하여 불필요한 렌더링을 최적화해야 한다.

 

끝입니다. 정말 간단하죠?
여러분의 프로젝트에서 Zustand를 적극 사용해보세요.

 


Deep Dive

어떤 라이브러리를 도입하기 전에는 코드 한번쯤은 파하쳐봐야 합니다. 겉모습만 보고 판단하면 안됩니다.

프로젝트에 치명적인 오버헤드를 발생시킬 수 있으니까요.

여기서부터는 zustand의 동작 원리에 대해 파헤쳐보는 내용입니다.

1. set함수 (setState)

Zustand 소스코드의 set함수는 다음과 같이 작성되어 있습니다.

 

현재 상태를 기반으로 새로운 상태를 리턴하는 함수 혹은, 아예 변경하려는 상태 값을 전달받습니다.

상태 변화는 값이 바뀌었을 때만 발생하며, `Object.assign`을 통해 변경됩니다.

// set function
let state; // 스토어 상태
const setState = (partial, replace) => {
    const nextState = typeof partial === 'function' ? partial(state) : partial;
    if (nextState !== state) {
      const previousState = state;
			// 값이 바뀐 경우에만 변화
      state = replace ? nextState : Object.assign({}, state, nextState);

      listeners.forEach(listener => listener(state, previousState));
    }
};

 

다음과 같이 실행할 수 있습니다.

store.setState(state => ({ counter: state.counter + 1 }));
// 혹은
store.setState({ counter: 10 });

 

🔎 Object.Assign을 사용하기 때문에 React의 setState에서 바뀌지 않는 기존 값을 보존하는 보일러 플레이트를 줄일 수 있다.

// React에서의 방식을 적용한다면
set((state) => ({ ...state, count: state.count + 1 }))

// Zustand에서는 바뀌지 않는 값은 기존 값이 된다.
updateFirstName: (firstName) => set(() => ({ firstName: firstName }))

 

🔥 주의 🔥
Object.Assign은 얕은 복사로 Nested Object를 바꿀때는 추가 Action이 필요합니다.

→ React의 setState처럼 `…`를 활용해야 하세요.

import { create } from 'zustand'

	const useCountStore = create((set) => ({
  nested: { count: 0 },
  inc: () =>
    set((state) => ({
      nested: { ...state.nested, count: state.nested.count + 1 },
    })),
}))

 

2. 상태의 구독

상태를 구독하는 함수를 등록할 때는 `subscribe` 함수를 사용합니다.

이 함수를 사용하여 모든 상태의 변화를 구독할 수도 있고, 상태의 일부만 구독할 수도 있습니다.

const subscribe = (listener, selector, equalityFn) => {
  if (selector || equalityFn) {
    return subscribeWithSelector(listener, selector, equalityFn);
  }
  listeners.add(listener);
  // 구독을 해제하는 함수도 리턴해준다.
  return () => listeners.delete(listener);
};

 

subscribe를 활용하여 다음과 같이 디버깅도 가능합니다.

const store = create(set => ({
  text: '',
  count: 0,
  setCount: newCount => set({ count: newCount }),
  increment: () => set(state => ({ count: state.count + 1 })),
  setText: text => set({ text })
}));

store.subscribe(state => console.log('Something's changed: ', state)); // 어떤 상태가 변경되더라도 로그가 출력됨
store.subscribe(
  state => console.log('Count is changed: ', count),
  state => state.count
); // count 값이 바뀔 때만 로그가 출력됨
store.subscribe(
  state => console.log('Text has been changed: ', text),
  state => state.text
); // text 값이 바뀔 때만 로그가 출력됨

store.setText('Changed'); // text 값만 변경
// 결과
// Something's changed: [Object]
// Text has been changed: Changed

 

이상으로, Zustand 사용방법이었습니다.

사용하기 쉬운 만큼 깃허브 코드도 Deep Dive 해볼만 한것 같습니다. 추후에 기여를 해보고 싶네요.

 


번외

Zustand는 Redux Devtools를 사용하여 개발이 가능합니다.

사용방법

  1. 먼저 Redux DevTools를 Chrome 웹 스토어에서 설치해줍니다.
  2. store를 devetools로 감싸서 사용합니다.
// store.js
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

const store = (set) => ({
  bears: 0,
  increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 })
})

const useStore = create(devtools(store))

export default useStore
  1. 애플리케이션을 브라우저로 띄운 다음 개발자 도구 창에서 Redux DevTools를 확인해보세요. store의 상태를 확인하실 수 있다.

DevTools 사용하지 않은 Production / 사용하는 Development 처리

// store.js
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

const store = (set) => ({
  bears: 0,
  increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 })
})

// 
const useStore = create(
  **process.env.NODE_ENV !== 'production' ? devtools(store) : store**
)

export default useStore

참고 문헌

https://github.com/pmndrs/zustand

 

GitHub - pmndrs/zustand: 🐻 Bear necessities for state management in React

🐻 Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.

github.com

 

React 상태 관리 라이브러리 Zustand의 코드를 파헤쳐보자

 

React 상태 관리 라이브러리 Zustand의 코드를 파헤쳐보자

TOAST UI Calendar의 새로운 상태 관리 방법을 도입하기 위해 참고한 라이브러리 Zustand의 코드를 분석해본다.

ui.toast.com

 

개쉽다! Zustand 사용법 - React 상태관리 라이브러리

 

개쉽다! Zustand 사용법 - React 상태관리 라이브러리

현재 리액트 상태관리 라이브러리는 참 많이 있습니다. 대표적인 Redux와 MobX, Recoil, Jotai, ... Redux가 상태관리 라이브러리의 시초격(Flux 패턴)이라 할 수 있는데요. 그렇기 때문에 많은 개발자들로

blacklobster.tistory.com

 

+ Recent posts