[ Custom hook ]
▶ Custom hook
반복되는 로직이나 중복되는 코드를 자신만의 훅 즉 커스텀 훅을 통해서 관리할 수 있음
리액트에서 제공하는 useState, useEffect와 같은 내장 훅을 사용해서 자신만의 훅을 만드는 것
첫 custom hook → useInput 연습하기

만들 커스텀훅은 useInput 이라는 것 input을 관리하는 훅이니까
useInput이라고 이름을 지은 것뿐
커스텀 훅을 만들때 이름은 마음대로 해도 상관이 없으나
단! 파일의 이름 앞에 use 라는 키워드를 붙여줘야 함
src 폴더에 보통 hooks라는 폴더를 생성해서 커스텀 훅들을 보관하는 식으로
많은 개발자들이 디렉토리 구조를 설계함
코드 구현하기
// src/hooks/useInput.js
import React, { useState } from "react";
const useInput = () => {
// 2. value는 useState로 관리하고,
const [value, setValue] = useState("");
// 3. 핸들러 로직도 구현합니다.
const handler = (e) => {
setValue(e.target.value);
};
// 1. 이 훅은 [ ] 을 반환하는데, 첫번째는 value, 두번째는 핸들러를 반환
return [value, handler];
};
export default useInput;
사실 상 커스텀훅이란 컴포넌트에서 구현해왔던 useState와 핸들러를 이렇게 뽑아서 따로 빼놓은 것 함수인 것
// src/App.jsx
import React from "react";
import useInput from "./hooks/useInput";
const App = () => {
// 우리가 만든 훅을 마치 원래 있던 훅인것마냥 사용
const [title, onChangeTitleHandler] = useInput();
const [body, onChangeBodyHandler] = useInput();
return (
<div>
<input
type="text"
name="title"
value={title}
onChange={onChangeTitleHandler}
/>
<input
type="text"
name="title"
value={body}
onChange={onChangeBodyHandler}
/>
</div>
);
};
export default App;
기능은 커스텀훅을 만들기전과 동일하게 작동하되 중복코드가 사라지고 전체적인 코드의 양도 감소
이것이 커스텀 훅의 힘 여러가지 기능을 구현하면서 중복되는 로직이 있으면 “이것을 어떻게 훅으로 빼볼 수 있을까” 라고 생각해보는 습관을 가지면 좋음
▶ 정리
커스텀훅이란 리액트 훅을 이용해서 공통된 로직이나 기능을 별도로 분리한 훅을 말함
파일의 이름 앞에 반드시 use 라는 키워드가 들어가야 함
[ React Query ]
▶ React Query
개념
리액트 쿼리(React Query)는 React 애플리케이션에서 데이터를 가져오고 관리하기 위한 라이브러리
기존 미들웨어의 한계
다른 서버와의 API 통신과 비동기 데이터 관리를 위해 Redux-thunk, Redux-Saga 등 미들웨어를 채택해서 사용할 수 있음하지만 다음과 같은 문제가 있음
- 보일러 플레이트 : 코드량이 너무 많아요.
- 규격화 문제 : Redux가 비동기 데이터 관리를 위한 전문 라이브러리가 아님(규격화 문제)
리액트 쿼리의 강점: 너무 쉽고 책임에서 자유로움
- 보일러 플레이트 만들다가 오류날 일이 없음
- 내가 만든 부분 아니기 때문에 잘못이 일어난들 내 잘못이 아님
- 사용방법이 기존 thunk 대비 너무 쉽고 직관적
▶ 주요 키워드
Query
어떤 데이터에 대한 요청을 의미함

axios의 경우 get 요청과 비슷함
Mutation
어떤 데이터를 변경하는 것

여기서 어떤 데이터는 데이터 그룹 그 자체를 의미함
바꾼다는 것은 추가, 수정, 삭제를 의미 CRUD 중 CUD에 해당함(Create, Update, Delete)
axios의 경우 post, put, patch, delete 요청과 비슷함
Query Invalidation
Query를 invalidation. 즉 무효화 시킨다는 의미

무효화 시킨다는 것은 기존에 가져온 Query는 서버 데이터이기 때문에 언제든지 변경이 있을 수 있음
그렇기 때문에 ‘최신 상태가 아닐 수’ 있음 그런 경우 기존의 쿼리를 무효화 시킨 후 최신화 시켜야 함
이런 과정을 React Query에서는 알아서 해줌 그 기능이 바로 Query Invalidation
▶ 실습
조회기능 구현: 기존 Todolist 프로젝트 변경해보기
명령어로 react-query 설치
yarn add react-query
//App.jsx
import React from "react";
import Router from "./shared/Router";
import { QueryClient, QueryClientProvider } from "react-query";
const queryClient = new QueryClient();
const App = () => {
return (
<QueryClientProvider client={queryClient}>
<Router />;
</QueryClientProvider>
);
};
export default App;
QueryClientProvider : 데이터를 읽어오는 기능(QueryClient)을 애플리케이션 전체에 주입하도록 하는 API
먼저 src > api 폴더를 만들어주고 그 아래에 todos 관련 api를 관리할 파일을 만듬
//src > api > todos.js
import axios from "axios";
// 모든 todos를 가져오는 api
const getTodos = async () => {
const response = await axios.get("http://localhost:3000/todos");
return response;
};
export { getTodos };
//Todolist.jsx
import React from "react";
import { StyledDiv, StyledTodoListHeader, StyledTodoListBox } from "./styles";
import Todo from "../Todo";
import { __getTodosThunk } from "../../modules/todosSlice";
import { getTodos } from "../../../api/todos";
import { useQuery } from "react-query";
function TodoList({ isActive }) {
const { isLoading, isError, data } = useQuery("todos", getTodos);
if (isLoading) {
return <p>로딩중입니다....!</p>;
}
if (isError) {
return <p>오류가 발생하였습니다...!</p>;
}
return (
<StyledDiv>
<StyledTodoListHeader>
{isActive ? "해야 할 일 ⛱" : "완료한 일 ✅"}
</StyledTodoListHeader>
<StyledTodoListBox>
{data
.filter((item) => item.isDone === !isActive)
.map((item) => {
return <Todo key={item.id} todo={item} isActive={isActive} />;
})}
</StyledTodoListBox>
</StyledDiv>
);
}
export default TodoList;
const { isLoading, isError, data } = useQuery("todos", getTodos);
이 부분이 React Query가 가지고 있는 큰 장점 Thunk를 이용하면 isLoading, isError등을 개발자가 state에서 직접 만들어줬어야 했지만 React Query는 서버 데이터를 위한 표준을 이미 제시하고 있기 때문에 개발자들 마다의 특성에 따라 바뀔 염려가 없어 return 문에 도착하기 전에 isLoading 또는 isError에 따라 별도의 처리를 해주기 때문에 대기상태 처리 / 오류 처리에 대한 부분도 아주 쉽게 해결할 수 있음
추가 기능 구현
todo를 추가하는 부분 구현
// src > api > todos.js
import axios from "axios";
// 공통으로 뺐지만 .env를 쓰는 것이 더 바람직함
const SERVER_URI = "http://localhost:4000";
const getTodos = async () => {
const response = await axios.get(`${SERVER_URI}/todos`);
return response.data;
};
const addTodo = async (newTodo) => {
await axios.post(`${SERVER_URI}/todos`, newTodo);
};
export { getTodos, addTodo };
// Input.jsx
...
import { addTodo } from "../../../api/todos";
import { QueryClient, useMutation } from "react-query";
...
function Input() {
...
const queryClient = new QueryClient();
const mutation = useMutation(addTodo, {
onSuccess: () => {
// Invalidate and refresh
// 이렇게 하면 todos라는 이름으로 만들었던 query를
// invalidate 할 수 있음
queryClient.invalidateQueries("todos");
},
});
invalidate의 과정
Input.jsx에서 값 입력으로 인해 서버 데이터가 변경됨
- onSuccess가 일어나면 기존의 Query인 “todos”는 무효화
- 새로운 데이터를 가져와서 “todos”를 최신화시킴
- TodoList.jsx를 갱신함
따라서 계속해서 리액트 앱은 최신 상태의 서버 데이터를 유지할 수 있게 되는 것
▶ 조금 더 나아가기
useQuery
import { useQuery } from 'react-query';
import { fetchTodoList } from '../api/fetchTodoList';
function App() {
const info = useQuery('todos', fetchTodoList);
}
- useQuery 인자
- 첫 번째 인자 ‘todos’ 이걸 쿼리의 키(Query Keys)라고 부름
- refetching 하는 데에 쓰이고 캐싱(caching) 처리를 하는 데에도 쓰임
- 애플리케이션 전체 맥락에서 이 쿼리를 공유하는 방법으로 쓰임
- 어느 컴포넌트 곳곳에 뿌려져 있어도 같은 key면 같은 쿼리 및 데이터를 보장함
- 두 번째 인자 ‘fetchTodoList’ 이걸 쿼리 함수(Query Functions)라고 부름
- 쿼리 함수는 promise 객체를 return
- promise 객체는 반드시 data를 resolve하거나 에러를 내야 함
- resolve는 정상적으로 통신이 되었음을 의미
- 원했던 상황이 아닌 경우 즉 오류가 발생한 경우에는 그에 맞는 적절한 오류 처리 관련 로직을 삽입해서 처리를 해줘야만 함 axios, fetch, graphql 중 어떤 방법을 이용하던지 적절한 오류 처리를 통해 사용자가 혼란에 빠지지 않도록 해줘야만 함
- useQuery의 결과물에 대해
- useQuery를 통해 얻은 결과물은 객체(object)
- 그 안에는 ‘조회’를 요청한 결과에 대한 거의 모든 정보가 들어있고 그 과정에 대한 정보도 다음과 같이 들어있음
- 시작 하면 isLoading이 true가 됨
- 조회 결과 오류가 나면 isError가 true가 됨 그리고 isLoading은 false가 됨
- error 객체를 통해 좀 더 상세한 오류 내용을 확인할 수 있음
- 조회 결과 정상이 되면 isSuccess가 true가 됨 그리고 isLoading은 false가 됨
- data 객체를 통해 좀 더 상세한 조회 결과를 확인할 수 있음
- Query Keys에 대해
QK는 위 예제처럼 한 단어일 수 있고 배열의 형태일 수도 있고 심지어는 nested 객체일 수도 있음
Key라는 말이 의미하듯 모든 Query keys는 Unique해야 함
정보를 유일하게 식별하기 위해 하나의 단어보다 더 많은 '표현'이 필요하면 문자, 숫자, object 등으로 여러가지 조합한 배열 형태의 key도 사용이 가능함
// 💥주의! key는 표현이 그런거지 api 로직과는 관련이 없음!
// ID가 5인 todo 아이템 1개
useQuery(['todo', 5], ...)
// queryKey === ['todo', 5]
// ID가 5인 todo 아이템 1개인데, preview 속성은 true야
useQuery(['todo', 5, { preview: true }], ...)
// queryKey === ['todo', 5, { preview: true }]
// todolist 전체인데, type은 done이야
useQuery(['todos', { type: 'done' }], ...)
// queryKey === ['todos', { type: 'done' }]
mutations
query와는 다르게 mutation은 CUD에서 사용됨
// [출처] : 공식문서
function App() {
const mutation = useMutation(newTodo => {
return axios.post('/todos', newTodo)
})
return (
<div>
{mutation.isLoading ? (
'Adding todo...'
) : (
<>
{mutation.isError ? (
<div>An error occurred: {mutation.error.message}</div>
) : null}
{mutation.isSuccess ? <div>Todo added!</div> : null}
<button
onClick={() => {
mutation.mutate({ id: new Date(), title: 'Do Laundry' })
}}
>
Create Todo
</button>
</>
)}
</div>
)
}
- useMutation hook임 함수고 API
- mutation.mutate(인자)
- 인자는 한 개의 변수 또는 객체여야 함
- mutation.mutate(인자1, 인자2) → 오류
- 결과는 객체(object 형태로) 갖고 있음
- 결과물 객체는 항상 어느 상태 중 하나에 속함
- isldle(번역하면 게으른, 나태한)
- isLoading
- isError
- error 객체를 항상 품고 있음을 명심
- isSuccess(query에만 있는게 아님)
- data 객체를 항상 품고 있음을 명심
- mutation.mutate(인자)
[ throttling & debouncing ]
▶throttling & debouncing
쓰로틀링과 디바운싱은 짧은 시간 간격으로 연속해서 이벤트가 발생했을 때 과도한 이벤트 핸들러 호출을 방지하는 기법
Throttling

짧은 시간 간격으로 연속해서 발생한 이벤트들을 일정시간 단위(delay)로 그룹화하여 처음 또는 마지막 이벤트 핸들러만 호출되도록 하는 것
주로 사용되는 예: 무한스크롤
Debouncing

짧은 시간 간격으로 연속해서 이벤트가 발생하면 이벤트 핸들러를 호출하지 않다가 마지막 이벤트로부터 일정 시간(delay)이 경과한 후에 한 번만 호출하도록 하는 것
주로 사용되는 예: 입력값 실시간 검색, 화면 resize 이벤트
메모리 누수(Memory Leak)
필요하지 않은 메모리를 계속 점유하고 있는 현상
setTimeout 이 메모리 누수(Memory Leak)를 유발하는가
상황에 따라 메모리 누수를 일으킬 수도 있고 아닐 수도 있음 하나의 페이지에서 페이지 이동 없이 setTimeout을 동작시키고 타이머 함수가 종료될 때까지 기다린다면 메모리 누수는 없음
리액트로 만든 SPA 웹사이트는 페이지 이동 시 컴포넌트가 언마운트 됨 그런데 페이지 이동 전에 setTimeout으로 인해 타이머가 동작중인 상태에서 clearTimeout을 안해주고 페이지 이동 시 컴포넌트는 언마운트 되었음에도 불구하고 타이머는 여전히 메모리를 차지하고 동작하고 있음 이 경우 메모리 누수(Memory Leak)에 해당한다고 말할 수 있음
▶ 실습
react-router-dom 설치
yarn add react-router-dom
코드
// App.jsx
import { BrowserRouter, Route, Routes } from "react-router-dom";
import Home from "pages/Home";
import Company from "pages/Company";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/company" element={<Company />} />
</Routes>
</BrowserRouter>
);
}
export default App;
// src > pages > Home.jsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
export default function Home() {
// const [state, setState] = useState(false);
const navigate = useNavigate();
let timerId = null;
// Leading Edge Throttling
const throttle = (delay) => {
if (timerId) {
// timerId가 있으면 바로 함수 종료
return;
}
// setState(!state);
console.log(`API요청 실행! ${delay}ms 동안 추가요청 안받음`);
timerId = setTimeout(() => {
console.log(`${delay}ms 지남 추가요청 받음`);
// alert("Home / 쓰로틀링 쪽 API호출!");
timerId = null;
}, delay);
};
// Trailing Edge Debouncing
const debounce = (delay) => {
if (timerId) {
// 할당되어 있는 timerId에 해당하는 타이머 제거
clearTimeout(timerId);
}
timerId = setTimeout(() => {
// timerId에 새로운 타이머 할당
console.log(`마지막 요청으로부터 ${delay}ms지났으므로 API요청 실행!`);
timerId = null;
}, delay);
};
useEffect(() => {
return () => {
// 페이지 이동 시 실행
if (timerId) {
// 메모리 누수 방지
clearTimeout(timerId);
}
};
}, [timerId]);
return (
<div style={{ paddingLeft: 20, paddingRight: 20 }}>
<h1>Button 이벤트 예제</h1>
<button onClick={() => throttle(2000)}>쓰로틀링 버튼</button>
<button onClick={() => debounce(2000)}>디바운싱 버튼</button>
<div>
<button onClick={() => navigate("/company")}>페이지 이동</button>
</div>
</div>
);
}
// src > pages > Company.jsx
import React from 'react;
export default function Company() {
return (
<div>
Test Page
</div>
);
}
▶ lodash 적용 및 useCallback을 써야하는 이유 알아보기
lodash 적용해보기
import "./App.css";
import { useState, useCallback } from "react";
import _ from "lodash";
function App() {
const [searchText, setSearchText] = useState("");
const [inputText, setInputText] = useState("");
const handleSearchText = useCallback(
_.debounce((text) => setSearchText(text), 2000),
[]
);
const handleChange = (e) => {
setInputText(e.target.value);
handleSearchText(e.target.value);
};
return (
<div
style={{
paddingLeft: 20,
paddingRight: 20,
}}
>
<h1>디바운싱 예제</h1>
<br />
<input
placeholder="입력값을 넣고 디바운싱 테스트를 해보세요."
style={{ width: "300px" }}
onChange={handleChange}
type="text"
/>
<p>Search Text: {searchText}</p>
<p>Input Text: {inputText}</p>
</div>
);
}
export default App;
만일 useCallback을 제거하면 정상적으로 동작하지 않음
lodash에서 제공하고 있는 debounce API를 직접 만들어보면
// App.jsx
import "./App.css";
import { useState, useCallback } from "react";
import _ from "lodash";
function App() {
const [searchText, setSearchText] = useState("");
const [inputText, setInputText] = useState("");
// custom debounce
const debounce = (callback, delay) => {
let timerId = null;
return (...args) => {
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {
callback(...args);
}, delay);
};
};
const handleSearchText = useCallback(
debounce((text) => setSearchText(text), 2000),
[]
);
const handleChange = (e) => {
setInputText(e.target.value);
handleSearchText(e.target.value);
};
return (
<div
style={{
paddingLeft: 20,
paddingRight: 20,
}}
>
<h1>디바운싱 예제</h1>
<br />
<input
placeholder="입력값을 넣고 디바운싱 테스트를 해보세요."
style={{ width: "300px" }}
onChange={handleChange}
type="text"
/>
<p>Search Text: {searchText}</p>
<p>Input Text: {inputText}</p>
</div>
);
}
export default App;
// custom debounce
const debounce = (callback, delay) => {
let timerId = null;
return (...args) => {
if (timerId) clearTimeout(timerId);
timerId = setTimeout(() => {
callback(...args);
}, delay);
};
};
위에 예시를 보면 debounce 함수는 값이 아닌 함수를 리턴해주고 있음
그냥 함수가 아닌 내부 함수에서 외부 함수의 변수에 접근하는 클로저 함수를 리턴하고 있음
따라서 useCallback hook을 통해 마운트 시에 debounce를 기억해주게 되면 이 클로저 함수는 외부 함수의 변수에 계속해서 참조를 갖고있기 때문에 타이머 아이디를 기억할 수 있게 되는것
댓글