[React] All About React Hook Rules (feat. lexical scope, impure function, spread assignment)

느려도 꾸준한 발걸음·2024년 6월 25일
0

Meta 공식 가이드에서 제공하는 React Hook 사용규칙을 정리해볼게요.

hook 전반에 관한 규칙

먼저, 리액트 훅이라면 useState든 useEffect든 종류에 상관없이 지켜야 할 약속을 알아볼게요.

1. React Hook은 리액트 컴포넌트 함수에만 사용하라

리액트 hook은, 일반 자바스크립트 함수에서는 사용할 수 없습니다.
오직 리액트 컴포넌트에서만 사용할 수 있는데요.

그렇다면, 일반 자바스크립트 함수와 리액트 컴포넌트 함수(함수형 컴포넌트)는 어떻게 구분해야 할까요?

간단합니다! 함수의 이름이 대문자로 시작하면서 JSX를 return한다면,
그건 리액트 컴포넌트겠네요!

이제, 지금까지의 이야기를 코드로 살펴볼게요.

잘못된 코드

아래는 잘못된 코드입니다.

import React, { useState } from 'react';

// 일반 JavaScript 함수
function calculateSum(a, b) {
  // 오류 발견!
  const [sum, setSum] = useState(0);
  
  setSum(a + b);
  return sum;
}

어디가 잘못된 부분인지 보이시나요?

네 맞습니다! 이 함수는 JSX를 리턴하지도 않고, 이름도 소문자로 시작하는 일반 자바스크립트 함수입니다.

이런 일반 자바스크립트 함수에서 react hook인 useState를 사용할 수 없습니다.

2. React Hook은 리액트 컴포넌트 함수의 최상위 렉시컬 스코프에서만 사용하라(setState 제외)

렉시컬 스코프는, 함수의 위치에 따라 결정되는 scope를 의미합니다.
그냥 scope라고 생각하셔도 큰 문제는 없습니다.

만일 누군가 "최상위 렉시컬 스코프를 갖는 변수" 라고 말하면,
중첩된 함수 중 가장 외곽에 선언된 변수를 의미한다고 생각하면 됩니다.

개발을 하다 보면, 함수 안에 함수를 사용하는 경우는 정말 많습니다.
당장 리액트 컴포넌트만 하더라도 하나의 함수이고,
그 안에 여러 이벤트 핸들러 함수를 정의하죠.

그럼, 최외곽 함수인 리액트 컴포넌트와,
리액트 컴포넌트 함수 안에 있는 이벤트 핸들러 함수 중 어디에 hook을 선언하느냐에 따라
scope가 달라질 수 있겠다는 것을 쉽게 이해할 수 있습니다.

React 의 hook은, 컴포넌트 내에서 최 외곽의 스코프에 사용해야 합니다.
아래는 잘못된 용례입니다.

잘못된 코드1

import { useState } from "react";
export default function ControlledComp() {
  

  const handleRangeChange = (e) => {
    const [userSatisfactionNum, setUserSatisfactionNum] = useState(0);
    setUserSatisfactionNum((prev) => e.target.value);
  };

  const handleSubmit = (e) => {
    e.preventDefault();
  };

  return (
    <fieldset>
      <form onSubmit={handleSubmit}>
        <label htmlFor="slider">회원님의 만족도를 입력해주십사. </label> <br />
        <h2>현재 만족도: {userSatisfactionNum}</h2>
        <input
          id="slider"
          type="range"
          min={0}
          max={10}
          value={userSatisfactionNum}
          onChange={handleRangeChange}
        />
        <br />
        <input
          type="submit"
          disabled={userSatisfactionNum >= 8 ? false : true}
        />
      </form>
    </fieldset>
  );
}

이상한 부분을 잘 발견하셨나요?

 const handleRangeChange = (e) => {
    const [userSatisfactionNum, setUserSatisfactionNum] = useState(0);
    setUserSatisfactionNum((prev) => e.target.value);
  };

네, 바로 이 부분입니다.
리액트 hook인 useState를 컴포넌트 함수 내에 사용하고 있네요.

이렇게 되면, handleRangeChange 함수는 컴포넌트 함수 안에 있는 함수이기에,
이 hook의 스코프(렉시컬 스코프)는 이 함수로 제한되는 문제가 생깁니다.

따라서, 다음과 같이 useState의 위치를 컴포넌트 최상위 스코프로 이동시켜야 합니다.

잘못된 코드 2

if user {
  useEffect(() => {/*생략*/},[user])
}

리액트 훅인 useEffect를 if문 안에 사용하고 있으니 오류입니다.

리액트 훅은 if, for, function 등 블록 형태의 그 어떤 곳 내부에도 작성하시면 안 됩니다. 최상위 렉시컬 스코프에 꼭 적어주세요.

참고로, setState함수는 블록문 내에서 써도 아무런 상관이 없으며,
오히려 조건문 없이 쓰는게 이상합니다. 이 녀석은 예외란 점도 잘 기억해두기로 하겠습니다.

바른 코드

import { useState } from "react";
export default function ControlledComp() {
  const [userSatisfactionNum, setUserSatisfactionNum] = useState(0);

  const handleRangeChange = (e) => {

    setUserSatisfactionNum((prev) => e.target.value);
  };

  const handleSubmit = (e) => {
    e.preventDefault();
  };

  return (
    <fieldset>
      <form onSubmit={handleSubmit}>
        <label htmlFor="slider">회원님의 만족도를 입력해주십사. </label> <br />
        <h2>현재 만족도: {userSatisfactionNum}</h2>
        <input
          id="slider"
          type="range"
          min={0}
          max={10}
          value={userSatisfactionNum}
          onChange={handleRangeChange}
        />
        <br />
        <input
          type="submit"
          disabled={userSatisfactionNum >= 8 ? false : true}
        />
      </form>
    </fieldset>
  );
}

수정된 부분이 보이시나요?

export default function ControlledComp() {
  const [userSatisfactionNum, setUserSatisfactionNum] = useState(0);

  const handleRangeChange = (e) => {
 //중략......

ControlledComp 함수형 컴포넌트의 최상위 렉시컬 스코프(가장 바깥의 scope)로 리액트 훅이 잘 이동된 것을 확인할 수 있습니다.

useEffect 훅과 관련된 규칙

1. impure function은 useEffect훅과 사용하라

순수하지 않은 함수란 무엇일까요?

순수한 함수란, side effect가 없는 함수이고,
순수하지 않은 함수란, side effect를 갖는 함수라고 정의합니다.

그런데, 자바스크립트에서 말하는 side effect란 무엇인지
쉽게 와닿지 않으실 것 같습니다.

그래서 먼저, side effect에 대해 알아보겠습니다.

side effect의 의미와 순수한 함수, 순수하지 않은 함수

정의를 내려보면 다음과 같습니다.

side effect: 함수가 함수 외부의 값을 변경하거나, 동작하는데 코드 외부의 것을 필요로 하는 것(외부 자원에 의존하는 것)

조금 쉽게, 제가 상상할 수 있는 가장 쉬운 상황으로 예를 들어보겠습니다.

const initialVal = 0;

const func1 = () => {
  return initialVal += 1;
}

const func2 = (num1, num2) => {
  return num1 + num2
}

1번 함수는 initialVal이라는 전역변수를 수정하고 있는데요,
이는 func1함수 자기 자신 바깥의 변수를 수정하고 있는 것이네요.

이런 경우 함수 밖의 변수를 수정하는 side effect가 발생했으니 함수 func1은 "순수하지 않은 함수다" 라고 합니다.

2번 함수는 함수 외의 값을 수정하지도 않고, 함수가 동작하는데 함수 외부의 API나 그 어느 도움도 필요하지 않습니다.

이런 함수를 두고 "side effect가 없는 함수다, 순수한 함수다, pure한 함수다" 라고 말합니다.

즉, 어떤 함수가 외부의 값을 수정하지 않고, 외부 값이나 API등의 외부 자원에 의존하지 않고 동작하는 함수는 전부 pure한 함수입니다.

그럼, 개발을 하며 자주 사용할 함수 중 대표적인 비순수함수엔 뭐가 있을까요?

fetch('https://api.example.com/data')
  .then(response => response.json())
  .then(data => console.log(data));

fetch는 인자로 전달된 외부 주소와 통신합니다.
즉, 외부 자원으로부터 데이터를 얻어오고 있죠.

함수가 동작하는데, 함수 바깥의 자원에 의존하고 있습니다.
(외부 자원의 도움 없이 100% 동작하지 못합니다)

이런 경우 fetch함수는 side effect를 가진, impure한 함수라고 말합니다.

이제, 어느정도 느낌이 옵니다.

외부와 통신하는 모든 상황들(DB통신, 백-프론트엔드 통신, API사용 등등...)을 처리하는 함수는 순수하지 않은 함수들이니,
meta의 가이드에 따라 useEffect 훅에 넣어 사용하는 것이 좋겠습니다.

impure한 함수를 왜 useEffect훅과 사용해야 하는가?

대체 이렇게 함으로 얻을 수 있는 이점이 무엇일까요?

우선, 아래의 코드를 살펴볼게요.

import React, { useState } from 'react';

function FetchDataComponent() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  const fetchData = async () => {
    try {
      const response = await fetch('https://api.example.com/data');
      const data = await response.json();
      setData(data);
      setLoading(false);
    } catch (error) {
      console.error('Error fetching data:', error);
      setLoading(false);
    }
  };


  fetchData(); 

  return (
    <div>
      {loading ? (
        <p>Loading...</p>
      ) : (
        <div>
          <h1>Data fetched:</h1>
          <pre>{JSON.stringify(data, null, 2)}</pre>
        </div>
      )}
    </div>
  );
}

export default FetchDataComponent;

리액트 컴포넌트인 fetchDataComponent 안에, impure한 함수 fetchData가 존재합니다.

그런데, fetchData함수를 useEffect훅 없이 사용하고 있습니다.

fetchData(); 

이렇게요.

리액트 컴포넌트에서는, useEffect훅 안에 있지 않은 모든 함수들을 컴포넌트가 렌더링 될 때마다 다시 실행시킵니다.

그런데, 리액트 컴포넌트가 재 렌더링 되는 상황을 생각해보면...
state값이나 prop값이 바뀌기만 하면 컴포넌트는 재 렌더링 되는데,
이때마다 fetch 작업을 다시 해준다는 것은 불필요하며 비용을 낭비하는 행위입니다.

이해를 위해 fetchData함수는 인자로 전달받은 url로 이동해 50만개의 호텔 정보 데이터를 받아온다고 생각해볼게요.

이때, 컴포넌트 내의 어떤 state하나만 변경되어도, 컴포넌트는 내부적으로 다시 렌더링 될 것이고, 컴포넌트 내의 함수들은 전부 재실행되어야 합니다.

50만개의 데이터를 이미 불러왔는데, state하나가 바뀔 때마다 또 다시 50만개의 데이터를 불러와야 한다면, 이는 상당히 비효율적이며 성능을 저하시키는 일입니다.

그래서, useEffect훅의 의존성 배열에 특정 상태를 전달해,
fetching 작업을 다시 하는 것에 타당성을 부여하는 특정 상태변화에만 반응해
컴포넌트 재 렌더링 때마다 불필요하게 fetch하는 것을 막을 수 있습니다.

즉, 앱의 성능 최적화에 도움이 됩니다.

아래는 수정된 코드입니다.

import React, { useState, useEffect } from 'react';

function FetchDataComponent() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  const fetchData = async () => {
    try {
      const response = await fetch('https://api.example.com/data');
      const data = await response.json();
      setData(data);
      setLoading(false);
    } catch (error) {
      console.error('Error fetching data:', error);
      setLoading(false);
    }
  };

  useEffect(() => {fetchData()}, [/*반응할 state*/]);
                 
  

  return (
    <div>
      {loading ? (
        <p>Loading...</p>
      ) : (
        <div>
          <h1>Data fetched:</h1>
          <pre>{JSON.stringify(data, null, 2)}</pre>
        </div>
      )}
    </div>
  );
}

export default FetchDataComponent;

변화한 부분만 보면,

useEffect(() => {fetchData()}, [/*반응할 state*/]);

이렇게 useEffect훅의 두 번째 인자인 의존성 배열에 특정 state변수를 주어
해당 state가 변화하는 순간 외에는 컴포넌트 전체가 다시 렌더링 된다 하더라도, fetchData함수가 다시 실행되는 것을 막아 효율을 증가시킬 수 있습니다.

useState 훅과 관련된 규칙

1. array, object 타입의 state는 spread연산자를 이용해 객체나 배열을 새로 만들어 수정하라

number나 string타입의 데이터가 아니라, array나 object를 useState훅의 초기값으로 사용한다 해볼게요.

이 땐, state의 값을 수정할 때, state를 직접 변경하지 않고, spread연산자를 사용해 기존의 값은 그대로 받아오고, 수정 사항을 반영한 새로운 배열이나 객체를 직접 생성해 setState함수에 넣어주어야 합니다.

하나씩 살펴봅시다.

예제 1 - state에 값을 추가하기

먼저 아래의 코드를 볼까요?

import React, { useState } from 'react';

function ArrayStateExample() {
  const [items, setItems] = useState(['apple', 'banana', 'cherry']);

  const addItem = () => {
    setItems([...items, 'orange']);
  };

  return (
    <div>
      <h1>Items</h1>
      <ul>
        {items.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
      <button onClick={addItem}>Add Item</button>
    </div>
  );
}

export default ArrayStateExample;

저희가 주목해볼 부분은 아래와 같습니다.

const [items, setItems] = useState(['apple', 'banana', 'cherry']);

  const addItem = () => {
    //items배열에 망고 추가하고 싶음
  };

세 개의 과일을 담고 있는 array state인 items에 망고를 추가하고 싶다고 해보겠습니다.

state인 items의 값을 변경하려 할 때, items는 배열이니,

items.push('mango');

이렇게 수정할 수도 있겠다는 생각이 들죠.

하지만, 이렇게 하면 리액트의 패러다임 중 하나인 "불변성 원칙" 을 우리도 모르는 새 어기게 됩니다.

이 문제를 어떻게 해결해야 할까요?

생각보다 간단하게, JS의 spread(전개할당)연산자를 사용하면 됩니다.

const [items, setItems] = useState(['apple', 'banana', 'cherry']);

  const addItem = () => {
    setItems([...items, 'mango']);
  };

이렇게, 기존의 items배열이 아닌, 새로운 배열을 생성하되, ...연산자로 기존 items배열의 값은 모두 가져올 수 있습니다.

이렇게 하면, 리액트의 상태불변성 원칙을 고수하며 배열에 요소를 추가할 수 있습니다.

예제 2 - 값을 수정하기

아래와 같이 spread연산자를 이용해
불변성 원칙을 고수하며 값을 변경할 수 있습니다.

버튼을 누르면, person 객체의 age값을 하나 늘린 값으로 수정하려 합니다.

import React, { useState } from 'react';

function ObjectStateExample() {
  const [person, setPerson] = useState({ name: 'John', age: 30 });

  const incrementAge = () => {
    // 새로운 객체를 생성하여 상태를 업데이트
    setPerson({
      ...person,
      age: person.age + 1
    });
  };

  return (
    <div>
      <h1>Person Information</h1>
      <p>Name: {person.name}</p>
      <p>Age: {person.age}</p>
      <button onClick={incrementAge}>Increment Age</button>
    </div>
  );
}

export default ObjectStateExample;

주목해볼 부분은 아래와 같습니다.

const incrementAge = () => {
    // 새로운 객체를 생성하여 상태를 업데이트
    setPerson({
      ...person,
      age: person.age + 1
    });
  };

person.age += 1 이런 식으로 state변수에 직접 접근하지 않고,

...person 으로 값을 받아와 새로운 객체에 넣고, 이미 존재하는 key name age에 접근하여 값을 수정하고 있는 것을 확인할 수 있습니다.

마무리

이렇게 오늘은 굉장히 많은 react hook 관련 규칙을 알아보았습니다.

순수함수, 렉시컬스코프, 스프레드 연산 등 자바스크립트의 문법들이 다소 등장했는데,
이 내용들에 익숙하지 않으시다면 꼭 다시 점검해보시면 좋겠습니다.

이상으로 포스팅을 마칩니다.
감사합니다

예제코드 제공: chat GPT
참고한 영상: https://www.youtube.com/watch?v=cd3P3yXyx30

profile
웹 풀스택 개발자를 준비하고 있습니다. MERN스택을 수상하리만큼 사랑합니다.

0개의 댓글