커스텀 훅이란?
상태를 설정할 수 있는 로직을 포함한 재사용 가능한 함수
useState나 useEffect 같은 다른 리액트 훅을 사용할 수 있고, 이를 통해 컴포넌트 간 특정 로직을 공유(재사용)할 수 있다.
🌟 커스텀 훅의 이름은 use로 시작해야 한다.
간단한 커스텀 훅 만들기
const BackwardCounter = () => {
const [counter, setCounter] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCounter((prevCounter) => prevCounter - 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <Card>{counter}</Card>;
};
const ForwardCounter = () => {
const [counter, setCounter] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCounter((prevCounter) => prevCounter + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <Card>{counter}</Card>;
};
ForwardCounter와 BackwardCounter는 덧셈과 뺄셈 외에는 정확히 동일한 로직을 가진 컴포넌트이다.
보통 코드가 중복될 때는 공통되는 코드를 갖는 함수를 만들어 리팩토링한다.
여기선 중복되는 코드가 useState나 useEffect같이 리액트 훅을 사용하기 때문에 이런 리액트 훅을 사용할 수 있는 커스텀 훅을 만들 것이다.
- 어떤 컴포넌트 안에서 커스텀 훅을 호출한다면 커스텀 훅에서 사용하고 있는 상태가 그 커스텀 훅을 사용하는 컴포넌트에 묶이게 된다.
즉, 다수의 컴포넌트에서 커스텀 훅을 사용한다고 해서 상태를 공유하는 것이 아니라, 모든 컴포넌트 인스턴스가 각자의 상태를 가진다는 것을 의미한다. - 따라서 커스텀 훅에서 상태 업데이트가 되면 커스텀 훅을 사용하는 컴포넌트들이 리렌더링된다.
- 커스텀 훅은 함수이므로 매개변수를 받을 수도 있고, 필요한 건 무엇이든지 반환할 수 있다.
// src/hooks/use-counter.js
import { useEffect, useState } from 'react';
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]);
return counter;
};
export default useCounter;
useCounter라는 커스텀 훅을 만들어서 ForwardCounter와 BackwardCounter에 적용할 수 있다.
매개변수로 forwards라는 flag 값을 받아 카운터를 증가할 건지, 감소할 건지 정할 수 있다.
또는 아래와 같이 갱신 함수 전체를 받아서 실행해줄 수도 있다.
setCounter(counterUpdateFn());
이렇게 커스텀 훅을 만들면 중복되는 코드를 줄일 수 있다.
const BackwardCounter = () => {
const counter = useCounter(false);
return <Card>{counter}</Card>;
};
const ForwardCounter = () => {
const counter = useCounter();
return <Card>{counter}</Card>;
};
http 요청 커스텀 훅 만들기
첫 렌더링 시, 버튼 클릭 시 Firebase에서 데이터를 가져오는 App 컴포넌트와 사용자가 입력한 할 일 데이터를 전송하는 NewTask 컴포넌트를 살펴보자.
import React, { useEffect, useState } from 'react';
import Tasks from './components/Tasks/Tasks';
import NewTask from './components/NewTask/NewTask';
function App() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [tasks, setTasks] = useState([]);
const fetchTasks = async (taskText) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
'firebase url'
);
if (!response.ok) {
throw new Error('Request failed!');
}
const data = await response.json();
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);
};
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;
import { useState } from 'react';
import Section from '../UI/Section';
import TaskForm from './TaskForm';
const NewTask = (props) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const enterTaskHandler = async (taskText) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
'firebase url',
{
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);
};
return (
<Section>
<TaskForm onEnterTask={enterTaskHandler} loading={isLoading} />
{error && <p>{error}</p>}
</Section>
);
};
export default NewTask;
두 컴포넌트에서는 모두
1. http요청을 보내고 있고
2. 로딩(isLoading)과 에러(error) 상태를 관리하고 있고, 이 두 가지 상태를 모두 http 요청을 보내기 전과 보내는 후에 동일한 방법으로 설정하고 있다.
이렇게 중복되는 코드를 가져와 커스텀 훅으로 만들 수 있다.
재사용성을 위해 어떤 종류의 요청이든(GET, POST..) 받아서 모든 종류의 URL로 보낼 수 있어야 한다.
따라서 fecth API에 필요한 URL, method, body, headers 같은 것들을 포함하는 requestConfig 매개변수를 설정한다.
import { useState, useCallback } from 'react';
const useHttp = (requestConfig, applyData) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const sendRequest = () => {
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);
};
return {
isLoading,
error,
sendRequest,
};
};
export default useHttp;
App과 NewTask 컴포넌트에서 중복되는 로직으로 훅을 구성하고, 요청을 보내고 데이터를 처리하는 부분은 두 컴포넌트 방식이 다르기 때문에 훅에 포함되서는 안된다.
따라서 훅을 사용하는 컴포넌트로부터 얻은 함수 (applyData)를 실행해서 그 함수에 데이터를 넘기는 방식을 사용한다.
훅을 사용하는 컴포넌트들은 결국 로딩 상태, 에러 상태, sendRequest 함수에도 접근할 수 있어야 하기 때문에 이 두 개의 상태와 함수를 반환하면 된다.
사용하기
만든 커스텀 훅을 import 하고 호출한다. useHttp 훅에서 반환하는 값은 로딩 상태, 에러 상태, http 요청 함수이기 때문에 이 값들을 구조분해할당을 해서 저장할 수 있다.
const { isLoading, error, sendRequest: fetchTasks } = useHttp({ url: 'firebase url' }, transformTasks);
const transformTasks = (tasksObj) => {
const loadedTasks = [];
for (const taskKey in tasksObj) {
loadedTasks.push({ id: taskKey, text: tasksObj[taskKey].text });
}
setTasks(loadedTasks);
};
const { isLoading, error, sendRequest: fetchTasks } = useHttp({
url: 'firebase url',
},transformTasks);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
이 코드에서는 문제점이 하나 있다. fetchTasks를 호출하는 useEffect에서는 fetchTask 함수 내부에서 무슨 일이 일어나는지 모르기 때문에 의존성으로 fetchTask 함수를 추가해야 한다. 하지만 지금 그렇게 의존성에 추가하면 무한 루프가 만들어진다.
어떻게 해야할까?
바로 커스텀 훅의 sendRequest 함수를 재생성하지않도록 useCallback으로 감싸주면 된다.
const sendRequest = useCallback(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(data);
} catch (err) {
setError(err.message || 'Something went wrong!');
}
setIsLoading(false);
}, [requestConfig, applyData]);
하지만 여기도 문제점 발생.!! useCallback의 의존성 배열에 함수 안에서 사용되는 것을 넣어야하는데 여기선 requestConfig와 applyData 함수이다.
하지만 이 둘도 모두 객체이기 때문에 App 컴포넌트에서 이 객체와 함수를 전달할 때 재생성이 되지 않도록 useMemo와 useCallback을 사용해야 할 것이다..
이것은 너무 귀찮고 까다롭다!! 그래서 requestConfig와 applyData 함수를 useHttp 훅에 전달하는 것이 아닌 이 훅의 sendRequest 함수에 전달하는 방법을 사용하자.
const sendRequest = 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);
}, []);
이렇게 하면 requestConfig와 applyData를 의존성 배열에 넣지 않아도 된다. 이것들은 이제 외부 의존성이 아닌, useCallback에 의해 감싸진 함수의 매개변수이기 때문이다.
따라서 App에서 커스텀 훅을 사용하는 코드는 이렇게 변경된다.
const { isLoading, error, sendRequest: fetchTasks } = useHttp();
useEffect(() => {
const transformTasks = (tasksObj) => {
const loadedTasks = [];
for (const taskKey in tasksObj) {
loadedTasks.push({ id: taskKey, text: tasksObj[taskKey].text });
}
setTasks(loadedTasks);
};
fetchTasks(
{
url: 'firebase url',
},
transformTasks
);
}, [fetchTasks]);
POST 요청을 보내는 NewTask 컴포넌트에서도 다음과 같이 커스텀 훅을 사용할 수 있다.
const { isLoading, error, sendRequest: sendTaskRequest } = useHttp();
const createTask = (taskText, taskData) => {
const generatedId = taskData.name; // firebase-specific => "name" contains generated id
const createdTask = { id: generatedId, text: taskText };
props.onAddTask(createdTask);
};
const enterTaskHandler = (taskText) => {
sendTaskRequest(
{
url: 'firebase url',
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: { text: taskText },
},
createTask.bind(null, taskText)
);
};
여기서 요청을 보내고 데이터를 처리하는 함수인 createTask를 sendTaskRequest 함수의 두 번째 인수로 넣어주고 있는데, 이 createTask 함수에서 필요한 것은 요청을 보내고 받은 응답 데이터와 App의 tasks 상태에 넣을 데이터인 taskText가 필요하다.
하지만 createTask를 호출하는 커스텀 훅은 하나의 인수만을 전달하고 있다. (applyData(data))
이것을 해결하기 위해 createTask에 bind 메서드를 호출한다. bind를 사용하면 함수를 사전에 구성할 수 있게 해준다.
createTask.bind(null, taskText)
이렇게 두 번째 인수로 tastText를 넣어주면, 이것은 호출 예정인 함수(createTask)가 받는 첫 번째 인자가 된다.
'React' 카테고리의 다른 글
Redux(리덕스)란? (0) | 2023.09.21 |
---|---|
[React] Hook의 규칙 (0) | 2023.09.17 |
[React] useMemo (0) | 2023.09.16 |
[React] React.memo와 useCallback (0) | 2023.09.16 |
Virtual DOM, 리액트가 작동하는 방식 (0) | 2023.09.16 |