본문 바로가기

frontend/react

[React] React Hook 사용하기 - useMemo, useCallback

깃헙 TIL 레파지토리에 올릴까 하다가 내용이 길어질 것 같아서

그냥 블로그에 올리기로 결심했다 . . .

TIL을 마크다운 언어로 작성하다보니, 적은 내용이더라도 시간이 엄청 소요된다 ㅜㅜ

사실 블로그 포스팅도 좀 귀찮은데 그래도 안 쓰면 또 까먹을 것 같으니 포스팅해본다!

 


 

사실 그렇게 대단한 내용은 아니라서 블로그에 올릴 거리가 되나 싶긴 하지만

그래도 뭐든 과정에서 배운 점이 있다면 기록해둔다고 나쁠 것 없을 것 같다

 


 

우선 이번에 보여줄 코드는 Next 스터디(라고 하기에는 사실상 React 스터디)로 간단한 TODO 앱을 만들고 있는데,

이 과정에서 렌더링이 너무 무분별하게 발생하는게 꽤 거슬렸다.

따라서 기능을 더 추가하기 전에 React Hook을 사용하여 렌더링 횟수를 조금 줄여보았다.

우선, 이전에 작성한 코드를 살펴보자

 

1️⃣ 수정 전 코드 살펴보기

 

아래 코드는 메인 화면을 담당하는 Main.tsx 파일이다.

import React, { useCallback, useEffect, useState } from "react";
import Todo, { TodoProps } from "../todo/Todo";
import { todosDummy } from "./dummy-data";
import styles from "./Main.module.css";

interface Inputs {
  title: string | undefined;
  subtitle: string | undefined;
}

const Main = () => {
  const [todos, setTodos] = useState<TodoProps[]>([]);
  const [inputs, setInputs] = useState<Inputs>();

  useEffect(() => {
    const todosData = JSON.parse(localStorage.getItem("todos") ?? "[]");
    setTodos(todosData);
  }, [setTodos]);

  useEffect(() => {
    localStorage.setItem("todos", JSON.stringify(todos));
  }, [todos]);

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;

    setInputs({
      ...inputs,
      [name]: value,
    });
  };

  const handleAddSubmit = () => {
    const id =
      todos.reduce((maxId, currentTodo) => Math.max(maxId, currentTodo.id), 0) +
      1;

    const newTodo: TodoProps = {
      id,
      title: inputs?.title ? inputs.title : "",
      subtitle: inputs?.subtitle,
    };

    setTodos((prev: TodoProps[]) => [...prev, newTodo]);

    setInputs({
      title: "",
      subtitle: "",
    });
  };

  const handleRemoveClick = useCallback(
    (id: number) => {
      alert(`삭제할 todo id: ${id}`);
      const newTodos = todos.filter((value) => value.id !== id);
      setTodos([...newTodos]);
    },
    [todos]
  );

  return (
    <div className={styles.mainContainer}>
      <h3>== Todo 리스트 ==</h3>
      <div className={styles.todoContainer}>
        {todos.map((data, index) => (
          <Todo
            todo={data}
            handleRemoveClick={() => handleRemoveClick(data.id)}
          />
        ))}
      </div>

      <h3>== Todo 추가하기 ==</h3>
      <div>
        <label htmlFor="title">title</label>
        <input
          id="title"
          name="title"
          type={"text"}
          value={inputs?.title}
          onChange={handleInputChange}
        ></input>
      </div>
      <div>
        <label htmlFor="subtitle">subtitle</label>
        <input
          id="subtitle"
          name="subtitle"
          type={"text"}
          value={inputs?.subtitle}
          onChange={handleInputChange}
        ></input>
      </div>
      <button type="submit" onClick={handleAddSubmit}>
        추가하기
      </button>
    </div>
  );
};

export default Main;

 

음.. 당시 개인사정으로.. 엄청 급하게 썼던 터라

컴포넌트고 뭐고 Todo를 제외하고는 다 메인에다가 쑤셔넣었다.

 

아무튼

일단 하나씩 살펴보자

 

  • state 값 설명
    • todos - 사용자가 저장한 todo 목록들을 저장하는 state
    • inputs - 사용자가 todo를 새로 추가할 때, input 태그에 입력된 값들을 저장하는 state
const [todos, setTodos] = useState<TodoProps[]>([]);
const [inputs, setInputs] = useState<Inputs>();

 

  • useEffect 설명
    • 첫 번째 effect - 메인 화면이 처음 마운트 될 때, 로컬 스토리지에 저장된 todo 항목들을 가져와서 todos state에 저장
    • 두 번째 effect - todos state가 변경(todo가 추가 혹은 삭제)될 때마다 로컬 스토리지의 todos value를 업데이트
useEffect(() => {
  const todosData = JSON.parse(localStorage.getItem("todos") ?? "[]");
  setTodos(todosData);
}, [setTodos]);

useEffect(() => {
  localStorage.setItem("todos", JSON.stringify(todos));
}, [todos]);
👉🏻 사실 위 코드의 두 번째 effect가 원하는대로 동작하지 않는 문제가 있는데, 이는 추후 해결하면 글을 추가로 다시 써보겠다.

 

 

  • event handler 설명 - 1️⃣ input 태그의 onChange 함수
    • input 태그의 value가 변경될 때마다, inputs state 값을 해당 value들로 갱신해주는 함수
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const { name, value } = e.target;

  setInputs({
    ...inputs,
    [name]: value,
  });
};

문제점 👉🏻 메인 화면이 리렌더링 될 때마다, handleInputChange 함수도 항상 재호출된다.

해결 👉🏻 useCallback을 사용하여 handleInputChange 함수가 필요시에만 재호출되도록 수정한다. 

 

여기서 말하는 재호출이 필요한 시점은, handleInputChange 함수 내부에서 사용하는 state 값인 inputs state가 변경되는 경우이다. 

이는, 함수 내부의 inputs 가 항상 현재의 inputs state 값을 가리켜야 정상적으로 동작하기 때문이다. 

 

따라서 우선 아래와 같이 코드를 수정하였다.

const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  const { name, value } = e.target;

  setInputs({
    ...inputs,
    [name]: value,
  });
}, [inputs]);

이렇게 되면, inputs state 값이 변경될 때마다 useCallback의 첫 번째 인자로 넘겨준 콜백함수를 재선언하게 된다. 

 

근데 이렇게 useCallback hook으로 감싸주기만 한다고 해서 렌더링 횟수를 줄일 수 있는게 아니다.

잘 보면 handleInputChange 함수 내부에서 inputs state 값을 바꿔주고 있다.

 

다시 말하자면, handleInputChange 1️⃣함수 안에서 inputs state 값을 변경해주고 있고, 그로 인해 2️⃣inputs state값이 수정되면서 3️⃣handleInputChange 함수가 다시 호출되는 것이다. 즉, useCallback을 써준 의미가 없다.

-> deps로 지정해준 값을 함수 내부에서 수정하고 있으니, useCallback을 사용을 안 한거나 마찬가지가 되어버린 것이다.

 

-> 만약 deps 배열을 빈 배열로 변경해주면, 첫 번째 파라미터의 콜백함수가 재선언 되는 일은 없을 것이다. 

-> 그런데 코드에서 deps 배열에 inputs state 값을 넣은 이유는, 현재의 inputs 값을 참조하기 위해서였다.

-> 그렇다면 deps가 빈 배열인 상태에서, 현재의 inputs state 값을 참조하는 방법이 있을까?

 

∴ 바로 함수형 업데이트를 사용하면 된다.

 

따라서 아래와 같이 코드를 수정할 수 있다.

const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  const { name, value } = e.target;

  setInputs(prev => ({
    ...prev,
    [name]: value,
  }));
}, []);

( 함수형 업데이트에 대한 설명은 생략 )

이렇게 되면 handleInputChange 함수는, 처음 선언된 이후로 재선언되는 일은 발생하지 않는다.

 

 

  • event handler 설명 - 2️⃣ button 태그의 onClick 함수
    • 새로운 todo를 추가하기 위해 버튼을 클릭할 때 실행되는 함수
  const handleAddSubmit = () => {
    const id =
      todos.reduce((maxId, currentTodo) => Math.max(maxId, currentTodo.id), 0) +
      1;

    const newTodo: TodoProps = {
      id,
      title: inputs?.title ? inputs.title : "",
      subtitle: inputs?.subtitle,
    };

    setTodos((prev: TodoProps[]) => [...prev, newTodo]);

    setInputs({
      title: "",
      subtitle: "",
    });
  };

문제점 👉🏻 메인 화면이 리렌더링 될 때마다, handleAddSubmit 함수도 항상 재호출된다.

해결 👉🏻 useCallback을 사용하여 handleAddSubmit 함수가 필요시에만 재호출되도록 수정한다. 

 

얘도 위와 비슷하다.

const handleAddSubmit = useCallback(() => {
    setTodos((prev: TodoProps[]) => [
      ...prev,
      {
        id: prev.reduce((maxId, currentTodo) => Math.max(maxId, currentTodo.id),0)+1,
        title: inputs?.title ?? "",
        contents: inputs?.contents ?? "",
      },
    ]);

    setInputs({
      title: "",
      contents: "",
    });
  }, [inputs]);

일단 id 상수와 newTodo를 없앤 이유는, id 값을 결정할 때 todos state 값을 사용하기 때문이다.

deps 배열에 todos state를 없애려고 했더니, 코드가 다소 복잡해졌다. 

 

아무튼, 이제 이 함수도 마찬가지로 inputs state 값이 변경될 때만 재호출된다. 

 

 

  • event handler 설명 - 3️⃣ todo의 삭제 버튼을 클릭할 때 실행되는 함수
    • 새로운 todo를 추가하기 위해 버튼을 클릭할 때 실행되는 함수
  const handleRemoveClick = useCallback((id: number) => {
      alert(`삭제할 todo id: ${id}`);
      const newTodos = todos.filter((value) => value.id !== id);
      setTodos([...newTodos]);
  }, [todos]);

얘도 위와 동일하므로 설명은 생략

  const handleRemoveClick = useCallback((id: number) => {
    alert(`삭제할 todo id: ${id}`);
    setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
  }, []);

 

그리고 Todo List 부분은 따로 분리해서 관리하고자, 아래 코드 부분을 TodoList 라는 컴포넌트로 따로 분리하였다.

  return (
    <div className={styles.mainContainer}>
      <h3>== Todo 리스트 ==</h3>
      <div className={styles.todoContainer}>
        {todos.map((data, index) => (
          <Todo
            todo={data}
            handleRemoveClick={() => handleRemoveClick(data.id)}
          />
        ))}
      </div>
    </div>
  );

 

  • TodoList 컴포넌트
    • React.memo로 감싸주어 props 값이 변경될 때만 컴포넌트가 리렌더링 되도록 설정해주었다.
import React from "react";
import Todo, { TodoProps } from "../todo/Todo";
import styles from "./TodoList.module.css";

interface Props {
  todos: TodoProps[];
  handleRemoveClick: (id: number) => any;
}

function TodoList({ todos, handleRemoveClick }: Props) {
  return (
    <div>
      {todos?.map((data, index) => (
        <Todo
          todo={data}
          handleRemoveClick={() => handleRemoveClick(data.id)}
        />
      ))}
    </div>
  );
}

export default React.memo(TodoList);
사실 이 부분을 이렇게 컴포넌트로 따로 분리한다고 해서 렌더링이 최적화 되는 것은 아니다.
수정한 코드도 그렇고, 기존에 있던 코드에서도 그렇고, Todo 컴포넌트가 todos state에 종속 (-> todos에 map 메소드를 적용하여 Todo 컴포넌트를 렌더링하고 있음)되어있기 때문이다. 즉, todos state 값이 변경될 때마다 해당 부분은 리렌더링 될 수 밖에 없다.
다만, useCallback을 사용하여 handleRemoveClick 함수가 재선언되지 않도록 개선했을 뿐이다. 

 

이와 달리 inputs 부분은 따로 컴포넌트로 분리하게 되면, 전체 화면이 리렌더링되지 않고 input 부분만 렌더링되도록 수정해줄 수 있다.

input 태그가 있는 부분은 todos state 값에 영향을 받지 않고, inputs state 값onChange, onClick 함수에만 영향을 받기 때문이다.

onChange, onClick 함수들은 이미 앞서 useCallback을 사용하여 불필요하게 재선언되는 것을 막아주었기 때문에, 이제 inputs state 값만 따로 inputs 컴포넌트로 분리하면 된다.

 

  • TodoForm 컴포넌트
import React, { Dispatch, SetStateAction, useCallback, useState } from "react";
import { TodoProps } from "../todo/Todo";

export interface Inputs {
  title: string | undefined;
  contents: string | undefined;
}

interface Props {
  setTodos: Dispatch<SetStateAction<TodoProps[]>>;
}

function TodoForm({ setTodos }: Props) {
  const [inputs, setInputs] = useState<Inputs>({
    title: "",
    contents: "",
  });

  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setInputs((prev) => ({ ...prev, [name]: value }));
  }, []);

  const handleAddSubmit = useCallback(() => {
    setTodos((prev: TodoProps[]) => [
      ...prev,
      {
        id: prev.reduce((maxId, currentTodo) => Math.max(maxId, currentTodo.id), 0) + 1,
        title: inputs?.title ?? "",
        contents: inputs?.contents ?? "",
      },
    ]);

    setInputs({
      title: "",
      contents: "",
    });
  }, [inputs]);

  return (
    <>
      <div>
        <label htmlFor="title">title</label>
        <input
          id="title"
          name="title"
          type={"text"}
          value={inputs?.title}
          onChange={handleInputChange}
        ></input>
      </div>
      <div>
        <label htmlFor="contents">contents</label>
        <input
          id="contents"
          name="contents"
          type={"text"}
          value={inputs?.contents}
          onChange={handleInputChange}
        ></input>
      </div>
      <button type="submit" onClick={handleAddSubmit}>
        추가하기
      </button>
    </>
  );
}

export default React.memo(TodoForm);

onChange, onClick 함수도 메인 컴포넌트에서는 사용되지 않기 때문에, 해당 컴포넌트로 옮겨주었다.

그리고 "추가하기" 버튼을 누를 때 todos state 값을 변경하는 setTodos 를 사용하게 되므로, 이는 props로 전달받았다.

+ 참고로 리액트의 useState가 반환하는 setState 함수처음 선언된 이후로 변경되지 않는다. 

 

 


2️⃣ 수정 후 코드 살펴보기

  • Main 컴포넌트
import React, { useCallback, useEffect, useState } from "react";
import Todo, { TodoProps } from "../todo/Todo";
import styles from "./Main.module.css";

const Main = () => {
  const [todos, setTodos] = useState<TodoProps[]>([]);

  useEffect(() => {
    const todosData = JSON.parse(localStorage.getItem("todos") ?? "[]");
    setTodos(todosData);
  }, [setTodos]);

  useEffect(() => {
    localStorage.setItem("todos", JSON.stringify(todos));
  }, [todos]);

  return (
    <div className={styles.mainContainer}>
      <h3>== Todo 리스트 ==</h3>
      <div className={styles.todoContainer}>
        <TodoList todos={todos} setTodos={setTodos} />
      </div>

      <h3>== Todo 추가하기 ==</h3>
      <TodoForm setTodos={setTodos} />
    </div>
  );
};

export default Main;

 

  • TodoList 컴포넌트
    • 얘도 TodoForm과 마찬가지로, onClick 함수를 내부로 옮기고 setState를 props로 전달받았다.
import React, { Dispatch, SetStateAction, useCallback } from "react";
import Todo, { TodoProps } from "../todo/Todo";
import styles from "./TodoList.module.css";

interface Props {
  todos: TodoProps[];
  setTodos: Dispatch<SetStateAction<TodoProps[]>>;
}

function TodoList({ todos, setTodos }: Props) {
  const handleRemoveClick = useCallback((id: number) => {
    setTodos((prevTodos) => prevTodos.filter((todo) => todo.id !== id));
  }, []);
  
  return (
    <div>
      {todos?.map((data, index) => (
      	<>
      	  <Todo todo={data} />
          <button onClick={() => handleRemoveClick(data.id)}>삭제하기</button>
        </>
      ))}
    </div>
  );
}

export default React.memo(TodoList);

 

  • Todo 컴포넌트
import React from "react";
import styles from "./Todo.module.css";

export interface TodoProps {
  id: number;
  title: string;
  contents?: string;
}

interface Props {
  todo: TodoProps;
}

const Todo = ({ todo }: Props) => {
  return (
    <div className={styles.todoContainer}>
      <div>
        <h1>제목: {todo.title}</h1>
        <h4>내용: {todo.contents}</h4>
      </div>
    </div>
  );
};

export default Todo;

 

  • TodoForm 컴포넌트
import React, { Dispatch, SetStateAction, useCallback, useState } from "react";
import { TodoProps } from "../todo/Todo";

export interface Inputs {
  title: string | undefined;
  contents: string | undefined;
}

interface Props {
  setTodos: Dispatch<SetStateAction<TodoProps[]>>;
}

function TodoForm({ setTodos }: Props) {
  const [inputs, setInputs] = useState<Inputs>({
    title: "",
    contents: "",
  });

  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setInputs((prev) => ({ ...prev, [name]: value }));
  }, []);

  const handleAddSubmit = useCallback(() => {
    setTodos((prev: TodoProps[]) => [
      ...prev,
      {
        id: prev.reduce((maxId, currentTodo) => Math.max(maxId, currentTodo.id), 0) + 1,
        title: inputs?.title ?? "",
        contents: inputs?.contents ?? "",
      },
    ]);

    setInputs({
      title: "",
      contents: "",
    });
  }, [inputs]);

  return (
    <>
      <div>
        <label htmlFor="title">title</label>
        <input
          id="title"
          name="title"
          type={"text"}
          value={inputs?.title}
          onChange={handleInputChange}
        ></input>
      </div>
      <div>
        <label htmlFor="contents">contents</label>
        <input
          id="contents"
          name="contents"
          type={"text"}
          value={inputs?.contents}
          onChange={handleInputChange}
        ></input>
      </div>
      <button type="submit" onClick={handleAddSubmit}>
        추가하기
      </button>
    </>
  );
}

export default React.memo(TodoForm);

 

 

 

 

 

'frontend > react' 카테고리의 다른 글

[React] 여러 개의 input 태그 관리와 setState  (0) 2021.11.11