
리액트에서 내장된 hook 뿐만 아니라, 사용자가 직접 만들어서 사용하는 Custom Hooks을 만들 수 있다.
커스텀 훅은 결론부터 말하면 재사용이 가능한 함수이다. 더하여, 상태를 설정할 수 있는 로직을 포함한 함수이다. 커스텀 훅을 만들어서 재사용 가능한 함수로써, 상태를 설정하는 로직을 아웃소싱할 수 있다.
정리하면, 커스텀 훅 함수 안에는 리액트 훅과 다른 훅을 사용할 수 있고 이를 다른 컴포넌트 함수에서 호출이 가능하다.
공통된 로직을 추출하여 재사용을 한다는것은 뭔가 익숙한것 같다. 바로 Utils라는 폴더에 따로 함수나 코드 조각을 저장하여 보았거나 사용했을것이다. 차이점이 무엇일까?
util : 보통 utils 폴더 내에서 사용되는 파일들로, 보통 자바스크립트나 타입스크립트 함수의 코드 조각을 저장하는데 사용된다.
utils 폴더내에 있는 함수들은 React 컴포넌트와 상관없이 사용이 가능하다.
Custom hook :보통 hooks 폴더 내에서 사용되는 파일들로, React 기능(상태관리)을 추상화시켜 컴포넌트 간에 재사용할 수 있는 로직을 만드는데 도움을 준다.
hooks 폴더에 있는 함수는 React 컴포넌트 내에서만 사용이 가능하다. 특정 상태 관리 또는 라이프 사이클 작업에 대한 로직을 포함한다.
이제 Cutsom Hook을 만들어보자.
다음과 같이 앞쪽(양수방향)으로 카운팅되는 숫자와,
뒷쪽(음수방향)으로 카운팅되는 숫자가 있다.

4개의 컴포넌트를 사용하고 있다.
App.js
import React from 'react';
import BackwardCounter from './components/BackwardCounter';
import ForwardCounter from './components/ForwardCounter';
function App() {
return (
<React.Fragment>
<ForwardCounter />
<BackwardCounter />
</React.Fragment>
);
}
export default App;
Card.js
import classes from './Card.module.css';
const Card = (props) => {
return <div className={classes.card}>{props.children}</div>;
};
export default Card;
ForwardCounter.js
import { useState, useEffect } from 'react';
import Card from './Card';
const ForwardCounter = () => {
const [counter, setCounter] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCounter((prevCounter) => prevCounter + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <Card>{counter}</Card>;
};
export default ForwardCounter;
import { useState, useEffect } from 'react';
import Card from './Card';
BackwardCounter.js
const BackwardCounter = () => {
const [counter, setCounter] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCounter((prevCounter) => prevCounter - 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <Card>{counter}</Card>;
};
export default BackwardCounter;
여기에서 ForwardCounter.js와 BackwardCounter.js를 유심히 보자.
공통되는 로직이 있고, 일부분의 로직만 다를뿐이다. 해당 예제에서 일부분의 로직이란 양의 방향이나 음의 방향이냐 뿐이지 다른 로직은 모두 같은것을 볼 수 있다. 같은것을 반복해서 쓴다는것은 코딩의 효율성이나 가독성면에서 좋지가 않다.
이를 해결하기위해서 사용하는것이 공통되는 로직을 따로 빼서 재사용 가능한 함수를 만드는것인데,
util함수는 사용하지 못한다. 로직내에서 리액트 내장 훅이 사용되었으므로 고려될 수 있는 수단은 커스텀 훅을 사용하는것이다.
src => hooks 폴더를 만들자.
hoosk 폴더 내에 커스텀훅을 사용하는 파일을 만든다 ex) use-counter.js
파일과 폴더의 구조는 다음과 같이 구성이 된다.
커스텀 훅을 먼저 만들어보자.
use-counter.js
import {useState , useEffect} from 'react';
// 커스텀 훅을 만들때 함수명은 반드시 use로 시작할것.
const useCounter = (forwards = true) => {
const [counter, setCounter] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
// 들어오는 매개변수에 따라 이에 맞는 로직을 설정한다.
if (forwards) {
setCounter((prevCounter) => prevCounter + 1);
} else {
setCounter((prevCounter) => prevCounter -1);
}
}, 1000);
return () => clearInterval(interval);
// forwards의 매개변수에 따른 의존성이 있으므로
// 이를 의존성배열에 추가해야한다.
}, [forwards]);
return counter;
};
export default useCounter;
BackwardCounter.js
import Card from './Card';
import useCounter from '../hooks/use-counter';
const BackwardCounter = () => {
// useCounter 커스텀 훅을 사용하며 인자값은 false를 사용.
const counter = useCounter(false);
return <Card>{counter}</Card>;
};
export default BackwardCounter;
ForwardCounter.js
import Card from './Card';
import useCounter from '../hooks/use-counter';
const ForwardCounter = () => {
// useCounter 커스텀 훅을 사용하며 인자값은 true를 사용.
const counter = useCounter(true);
return <Card>{counter}</Card>;
};
export default ForwardCounter;
두 파일들의 코드들이 매우 보기좋게 바뀌어진것을 볼 수 있다.
동작하는것도 똑같이 동작하는것을 볼 수 있다.

이번에는 HTTP 통신에 관련하여 현실적인 예시로 알아보자.

Add Task 버튼을 누르면 타이핑한 내용이 FireBase의 데이터베이스쪽으로 전달이 되어 목록이 저장이되고, 다시 FireBase측에서 데이터베이스를 기반하여 목록을 전달하여 표시 하려한다.
해당 프로젝트의 필요한 일부분만 떼서 알아보자.
데이터베이스에 수신요청 코드
App.js
try {
const response = await fetch(
<!-- tasks 이쪽은 내가 원하는 이름을 쓰면된다-->
'https://react-http-3c308-default-rtdb.firebaseio.com/tasks.json'
);
if (!response.ok) {
throw new Error('Request failed!');
}
const data = await response.json();
console.log(data);
const loadedTasks = [];
for (const taskKey in data) {
loadedTasks.push({ id: taskKey, text: data[taskKey].text });
}
setTasks(loadedTasks);
} catch (err) {
setError(err.message || 'Something went wrong!');
}
setIsLoading(false);
};
타이핑한 데이터를 데이터베이스에 POST요청하는 코드
NewTask.js
const enterTaskHandler = async (taskText) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
'https://react-http-3c308-default-rtdb.firebaseio.com/tasks.json',
{
method: 'POST',
body: JSON.stringify({ text: taskText }),
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error('Request failed!');
}
const data = await response.json();
const generatedId = data.name; // firebase-specific => "name" contains generated id
const createdTask = { id: generatedId, text: taskText };
props.onAddTask(createdTask);
} catch (err) {
setError(err.message || 'Something went wrong!');
}
setIsLoading(false);
};
두 코드는 하는역할은 다르다. 하지만 공통된부분이 있고 이를 묶을 수 있다.
단, 빌트인 훅을 사용한 시점부터 일반 함수는 사용하지 못한다는것을 알아야한다.
커스텀 리액트 Hook으로 공통된 로직을 추출해보자.
App.js에서는 파이어베이스로부터 데이터를 Get하는 로직을 구성하였고,
NewTask.js에서는 사용자가 입력한 데이터를 POST하는 로직을 구성하였다.
이 Get 로직과 Post로직의 공통된부분을 추출하여 Custom Hook을 만들어 구성하려고한다.
공통되는 로직을 추출하여 Custom Hook인 use-http.js로 저장하여 빌드해보자.
use-http.js
// 이 훅은 어떤 종류의 요청이든 받아서
// 모든 종류의 URL로 보낼 수 있어야 하며,
// 어떤 데이터로도 변환도 할수 있어야 한다.
// 이런 유연한 훅을 만드는것이 목적!
import {useState} from 'react';
// requestConfig 매개변수는 url주소나, url 응답방식(get,post)을 객체로받는다.
// applyData 매개변수는 함수로 데이터 처리를 어떻게 할것인지에 대한 함수이다.
// 데이터 처리를 어떻게할 것인가에 대한 함수와 내용에 관련해서는 커스텀훅을 사용하는 컴포넌트에서 서술한다.
const useHttp = (requestConfig, applyData) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// fetchRequset => sendRequest로 일반적인 이름으로 Rename 했다.
// 어느 한 종류의 기능만을 국한하는것이 아니다. 어떤 종류의 요청이든 받아서 모든 종류의 URL로 보낼 수 있어야한다.
const sendRequset = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
requestConfig.url, {
method : requestConfig.method ? requestConfig.method : 'GET',
headers : requestConfig.headers ? requestConfig.headers : {},
body : requestConfig.body ? JSON.stringify(requestConfig.body) : null,
}
);
if (!response.ok) {
throw new Error("Request failed!");
}
const data = await response.json();
// applyData 함수로 데이터 전달하기
// 세부적인 변환 과정은 해당 훅이 사용되는 컴포넌트에게 맡기기
applyData(data);
} catch (err) {
setError(err.message || "Something went wrong!");
}
setIsLoading(false);
};
// 커스텀 훅은 무엇이든 반환할 수 있다.
// 여기선 객체를 반환한다
return {
// 좌측은 속성명, 우측은 변수명
// 해당 커스텀 훅이 관리하는 isLoading 상태를 값으로
isLoading : isLoading,
// 해당 커스텀 훅이 관리하는 error 상태를 값으로
eeror : error,
// 해당 커스텀 훅의 함수인 sendRequest를 값으로
sendRequset : sendRequset
}
};
export default useHttp;
App.js에서 파이어베이스로부터 데이터를 Get하는 로직을 구성 했었다.
이부분을 Custom Hook을 사용해보자.
App.js
import React, { useEffect, useState } from "react";
import Tasks from "./components/Tasks/Tasks";
import NewTask from "./components/NewTask/NewTask";
import useHttp from "./components/hooks/use-http";
function App() {
const [tasks, setTasks] = useState([]);
// 데이터처리를 어떻게 할것인가에 대한 함수
const transformTasks = taskObj => {
const loadedTasks = [];
for (const taskKey in taskObj) {
loadedTasks.push({ id: taskKey, text: taskObj[taskKey].text });
}
setTasks(loadedTasks);
};
// 커스텀 훅 사용하기! 인자로 url과 get만 받아오면된다. headers, body는 필요없음.
// httpData 변수로 useHttp 커스텀훅에서 반환한것을 저장한다.
// 가져오자마자 구조분해하기. 분해문법에서 콜론을통해 별칭을 설정가능하다.
const {isLoading, error, sendRequset: fetchTasks} = useHttp(
{ url: "https://customhook-1e4d0-default-rtdb.firebaseio.com/tasks.json" },
<!-- style="color:indianred" -->
// 데이터 처리를 어떻게할것인지에대한 함수
transformTasks
);
useEffect(() => {
fetchTasks();
}, []);
const taskAddHandler = (task) => {
setTasks((prevTasks) => prevTasks.concat(task));
};
return (
<React.Fragment>
<NewTask onAddTask={taskAddHandler} />
<Tasks
items={tasks}
loading={isLoading}
error={error}
onFetch={fetchTasks}
/>
</React.Fragment>
);
}
export default App;
16.4.1, 16.4.2의 코드처럼은 아직 2% 모자라다. 무한 루프의 현상을 해결하지 못했기 떄문이다.
useEffect(() => {
fetchTasks();
}, []);
이 코드를 보자.외부의존성인 fetchTasks함수를 두고 빈배열을 두는것은 말이되지 않는다. 그렇다변 외부의존성에 fetchTasks를 추가해야 한다.
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
추가를 해도 문제가있다. 함수는 객체라는것이다. 객체는 객체안의 로직이 같을지라도 객체 메모리주소 자체는 재평가시마다 매번 달라진다는것이 문제이다.
재평가만 안되면 되는거 아니냐? 라고 할 수도 없다. fetchTasks()를 실행시에 내부 로직에 상태 변경의 로직이있고, 이 상태 변경은 곧 컴포넌트 함수의 재평가로 직결되기 때문이다. 이렇게되면 다음과같은 무한 루프의 악순환고리를 벗어나지 못한다.
fetchTasks() 최초 실행 => fetchTasks의 로직 실행 => fetchTasks의 로직내부에 상태변경 로직이 있음 => 함수 컴포넌트 재실행 => fetchTasks() 재 실행 => fetchTasks의 로직 실행 => .......
이러한 현상을 해결하기 위해서는 useCallback, useMemo 를 사용하거나 창의적 코딩으로 해결이 가능하다.
주석처리된곳을 위주로 보자
App.js
import React, { useEffect, useState } from "react";
import Tasks from "./components/Tasks/Tasks";
import NewTask from "./components/NewTask/NewTask";
import useHttp from "./components/hooks/use-http";
import { useCallback } from "react";
function App() {
const [tasks, setTasks] = useState([]);
// useHttp에 보내는 데이터처리 함수인 transformTasks도 useCallback을 사용해야 한다.
// useHttp에서는 applyData라는 변수명으로 사용하고있는데, useHttp에서는
// applData(transformTasks)가 외부 의존성이기 때문이다. 이 외부 의존성도
// useCallback을 사용하여 바뀌지 않음을 명시해야한다.
const transformTasks = useCallback ( (taskObj) => {
const loadedTasks = [];
for (const taskKey in taskObj) {
loadedTasks.push({ id: taskKey, text: taskObj[taskKey].text });
}
setTasks(loadedTasks);
// 외부의존성이 없기때문에 따로 명시해줄 필요가 없다.
},[]);
// url을 감싸져있는 객체도 재평가마다 매번 재성성될것이므로 useMemo를 사용하여 재생성 방지를 해보자.
// (useHttp에서 기술)
const {isLoading, error, sendRequset: fetchTasks} = useHttp(
{ url: "https://customhook-1e4d0-default-rtdb.firebaseio.com/tasks.json" },
transformTasks
);
// fetchTasks는 useCallback을 사용하여 함수의 불필요한 재생성을 방지해보자.
// (useHttp에서 기술)
useEffect(() => {
fetchTasks();
}, [ fetchTasks]);
const taskAddHandler = (task) => {
setTasks((prevTasks) => prevTasks.concat(task));
};
return (
<React.Fragment>
<NewTask onAddTask={taskAddHandler} />
<Tasks
items={tasks}
loading={isLoading}
error={error}
onFetch={fetchTasks}
/>
</React.Fragment>
);
}
export default App;
주석처리된곳을 위주로 보자.
praticeUseHttp.js
import {useState, useCallback, useMemo} from 'react';
const useHttp = (requestConfig, applyData) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// useMemo를 사용하여 url의 불필요한 재생성 방지하기.
const { url } = requestConfig;
const memoizedUrl = useMemo(() => url
, [url]);
// sendRequset(fetchTasks)에 useCallback을 사용하여 함수의 불필요한 재생성 방지하기.
const sendRequset = useCallback (async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
memoizedUrl , {
method : memoizedUrl.method ? memoizedUrl.method : 'GET',
headers : memoizedUrl.headers ? memoizedUrl.headers : {},
body : memoizedUrl.body ? JSON.stringify(memoizedUrl.body) : null,
}
);
if (!response.ok) {
throw new Error("Request failed!");
}
const data = await response.json();
console.log(data);
const loadedTasks = [];
for (const taskKey in data) {
loadedTasks.push({ id: taskKey, text: data[taskKey].text });
}
applyData(data);
} catch (err) {
setError(err.message || "Something went wrong!");
}
setIsLoading(false);
// 외부의존성인 applyData와 requestConfig를 추가해야한다.
// applyData는 useCallback을 사용하여 불필요한 함수 재생성을 방지해야하고,
// requestConfig는 useMemo를 사용하여 불필요한 객체 재생성을 방지해야한다.
},[applyData, memoizedUrl]);
return {
isLoading : isLoading,
error : error,
sendRequset : sendRequset
}
};
export default useHttp;
useCallback을 최소화하고 직접 매개변수로 전달하여 해결하는 방법도 있다.
App.js
import React, { useEffect, useState } from "react";
import Tasks from "./components/Tasks/Tasks";
import NewTask from "./components/NewTask/NewTask";
import useHttp from "./components/hooks/use-http";
function App() {
const [tasks, setTasks] = useState([]);
const {isLoading, error, sendRequset: fetchTasks} = useHttp();
useEffect(() => {
// transformTasks를 useEffect안에 옮기고 fetchTasks의 매개변수로 직접 전달하는 방식
const transformTasks = (taskObj) => {
const loadedTasks = [];
for (const taskKey in taskObj) {
loadedTasks.push({ id: taskKey, text: taskObj[taskKey].text });
}
setTasks(loadedTasks);
};
// 매개변수로 직접 url과 데이터처리 함수를 전달한다.
fetchTasks({ url: "https://customhook-1e4d0-default-rtdb.firebaseio.com/tasks.json" },transformTasks);
}, [fetchTasks]);
const taskAddHandler = (task) => {
setTasks((prevTasks) => prevTasks.concat(task));
};
return (
<React.Fragment>
<NewTask onAddTask={taskAddHandler} />
<Tasks
items={tasks}
loading={isLoading}
error={error}
onFetch={fetchTasks}
/>
</React.Fragment>
);
}
export default App;
use-http.js
import {useState,useCallback} from 'react';
const useHttp = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const sendRequset = useCallback (async (requestConfig, applyData) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
requestConfig.url, {
method : requestConfig.method ? requestConfig.method : 'GET',
headers : requestConfig.headers ? requestConfig.headers : {},
body : requestConfig.body ? JSON.stringify(requestConfig.body) : null,
}
);
if (!response.ok) {
throw new Error("Request failed!");
}
const data = await response.json();
applyData(data);
} catch (err) {
setError(err.message || "Something went wrong!");
}
setIsLoading(false);
// useCallback의 외부의존성은 존재하지않으므로 빈배열로 구성해준다.
// 모두 매개변수로 받기 때문이다.
},[]);
return {
// 좌측은 속성명, 우측은 변수명
// 해당 커스텀 훅이 관리하는 isLoading 상태를 값으로
isLoading : isLoading,
// 해당 커스텀 훅이 관리하는 error 상태를 값으로
eeror : error,
// 해당 커스텀 훅의 함수인 sendRequest를 값으로
sendRequset : sendRequset
}
};
export default useHttp;
무엇을 선택하여 사용할지는 코드 상황에 따라 유동적으로 사용해야 할 것같다.