파크로그
article thumbnail

 

저번 글에서는 createElement 함수를 만들고, JSX 문법을 사용하여 가독성을 높여 DOM Element 를 생성하는 방식을 만들었다.

 

이번 글에서는 상태관리에 대해서 다룰 예정이다.

 

상태관리가 필요한 이유

내가 생각하는 상태란 사용자가 앱을 사용하면서 하는 행동에 의해 변경되는 값이고, 그 행동에 의해 변경된 UI 를 제공하는 값 이라고 생각한다. 

 

예를들어, 간단히 만든 Todo 앱에서 화면에 보여지는 Todo 목록은 상태라고 볼 수 있다.

사용자가 Add 버튼을 누르면, input 에 입력된 값이 화면에 추가되어 보여지기를 기대하기 때문이다.

 

 

어떻게 변경된 UI 를 제공할 수 있을까? DOM Element 추가라면 appendChild 를 하고, 삭제라면 removeChild 를 하면 될까?

지금 Todo 는 어떻게 추가하고 있는지 핸들러를 이전에 어떻게 작성했는지 보자.

 

const App = () => {
  const handleButtonClick = () => {
    const input = document.querySelector('input');
    const todos = document.querySelector('ul');
    const newTodo = document.createElement('li');
    newTodo.innerHTML = input.value;
    todos.appendChild(newTodo);
  };

  return (
    <div id='app'>
      <h1>Hello World</h1>
      <Todos />
      <input />
      <Button onClick={handleButtonClick}>Add</Button>
    </div>
  );
};

DOM API 를 활용하여 button 을 클릭하면 먼저 querySelector 를 사용하고, Selector 로 input element 를 지정해 input 을 찾는다.

todos 는 ul 태그로 찾고 그 자식으로 추가를 시킨다.

 

너무 귀찮다. Todos 배열에 단순히 하나의 Todo 를 append 하는 것으로 해결할 수는 없을까?

 

const App = () => {
  const todos = ['밥먹기', '설거지하기'];

  const handleButtonClick = () => {
    const input = document.querySelector('input');
    const newTodo = input.value;
    todos.push(newTodo);
  };

  return (
    <div id='app'>
      <h1>Hello World</h1>
      <Todos todos={todos} />
      <input />
      <Button onClick={handleButtonClick}>Add</Button>
    </div>
  );
};
const Todos = ({ todos }) => (
  <ul>
    {todos.map((todo) => (
      <li>{todo}</li>
    ))}
  </ul>
);

export default Todos;

 

아쉽게도 원하는대로 동작되지 않는다.

todos 에 추가가 안되는걸까? 앱은 돌아가고 있기에 todos 에 추가가 안되는 것은 아니다. 

이전에 HTML DOM 을 그리기 위해서 createElement 함수를 만들었는데, createElement 가 처음 실행되고, 이벤트를 발생시킨 이후 DOM 을 다시 그리는 작업을 하지 않아 업데이트가 되지 않는 것이다.

 

 

그렇다면 단순히 todos 에 push 를 하기보다, todos 가 변했다 라는 것을 인식하게 하는 함수가 필요함을 느끼게 되고, state 를 변경시킬 수 있는 setter 함수를 만든 이후 setter 함수가 실행되면 DOM 을 업데이트 (리렌더링) 시키는 동작을 추가하도록 한다.

 

render, setRoot

우선 DOM 을 다시 그리게 하기 위한 render 함수를 작성했다.

// core.js

const info = {
  root: null,
  app: null,
};

export const setRoot = (root) => {
  info.root = root;
};

export const render = (app) => {
  if (!info.app) {
    info.app = app;
  }
  info.root.innerHTML = '';
  info.root.appendChild(info.app());
};

 

// index.js

import { setRoot, render } from './core.js';
import App from './app.js';

const $root = document.querySelector('body');

setRoot($root);
render(App);

초기 index 는 appendChild 하던 방식에서, DOM 을 다시 렌더링 시킬 root 를 저장시킨 이후, 실행될 App 또한 저장하여 App 을 실행시킬 render 함수를 작성했다.

 

const App = () => {
  const todos = ['밥먹기', '설거지하기'];

  const handleButtonClick = () => {
    const input = document.querySelector('input');
    const newTodo = input.value;
    todos.push(newTodo);
    render();
  };

  return (
    <div id='app'>
      <h1>Hello World</h1>
      <Todos todos={todos} />
      <input />
      <Button onClick={handleButtonClick}>Add</Button>
    </div>
  );
};

 

이제 버튼 클릭시 render 까지 시키므로, 원하는 대로 동작할까?

input 이 초기화 되고, 개발자도구를 통해 elements 를 확인해보니 Element 들이 다시 그려지는 것이 맞는 것 같은데, 변경된 todos 들을 확인할 수는 없었다.

 

원인부터 이야기하자면 todos 를 사용하는 방식이 함수 내에 변수로 저장되어 사용되는데, App 을 처음부터 다시 실행한다면, todos 변수는 다시 초기화되기 때문에 이전의 값을 상실하게 되고, 새로 초기화된 값을 사용하게 되어 그렇다.

 

그렇다면 사용하고자 하는 컴포넌트(함수) 내부에서 단순히 변수로 선언하는 것은 변경된 상태를 저장하기 어려울 것이므로, 

해당 상태를 위에서 선언했던 core의 info 가 가지고 있도록 해보자.

 

useState

useState 를 사용했던 인터페이스부터 떠올려보고, 스켈레톤 코드부터 작성해보자.

useState 는 초기 값을 설정할 수 있고, state value 와 setter 함수를 배열로 제공해준다.

const info = {
  ...
  states: {},
};

export const useState = (initValue) => {
  const value = initValue
  const setValue = (newValue) => {
  	// states[??] = newValue
  }
  return [value, setValue]
}

각 함수에서 사용되는 useState 가 어떻게 적절한 값을 사용할 수 있게 도와주는 것일까?

useState 가 실행되면서 core 내부적으로 현재 state 의 key 를 가지고 있고, useState 함수가 실행되면서 해당 key 를 변경(+1) 해주는 식으로 사용하면, useState 가 쓰여진 순서에 맞춰 필요한 state 를 제공해줄 것이다.

 

+) 여담이지만, 그래서 React 에서는 hook 을 조건부로 실행하지 못하도록 규칙을 정했다. 항상 동일한 순서로 Hook 이 호출되어야 위와 같은 상황에서 동일한 값을 반환해줄 것이기 때문이다.

 

const info = {
  root: null,
  app: null,
  states: {},
  currentStateKey: 0,
};

export const useState = (initValue) => {
  const currentStateKey = info.currentStateKey;
  const isNewState = info.states[currentStateKey] === undefined;

  if (isNewState) {
    info.states[currentStateKey] = initValue;
  }

  const value = info.states[currentStateKey];

  const setValue = (newValue) => {
    info.states[currentStateKey] = newValue;
    render() // 새로운 info states 를 바탕으로 DOM 을 다시 그리게 한다.
  };

  info.currentStateKey += 1;

  return [value, setValue];
};

export const render = (app) => {
  if (!info.app) {
    info.app = app;
  }
  info.root.innerHTML = '';
  info.currentStateKey = 0; // state key 를 0 으로 변경시켜주어, useState 가 적절한 값을 가지도록 한다.
  info.root.appendChild(info.app());
};
const App = () => {
  const [todos, setTodos] = useState(['밥먹기', '설거지하기']);

  const handleButtonClick = () => {
    const input = document.querySelector('input');
    const newTodo = input.value;
    setTodos([...todos, newTodo]); // render 는 setter 함수에게 맡긴다
  };

  return (
    <div id='app'>
      <h1>Hello World</h1>
      <Todos todos={todos} />
      <input />
      <Button onClick={handleButtonClick}>Add</Button>
    </div>
  );
};

state 를 core 에 저장시켜놓고 사용하는 방식으로 상태변경에 의한 리렌더링을 구현해보았다.

 

그러나 현재 방식은 매우 비효율적이다.

여러번의 setter 함수 사용시 함수 호출마다 리렌더링을 발생시키고, 어느 지점의 상태가 변경되었는지와 관계없이 DOM 의 root 부터 리렌더링을 하기때문이다.

 

React 에서는 연속된 setter 함수 사용시 여러번 리렌더링 되는 것을 막고, 효율적인 리렌더링을 시키기위해 batching 을 사용하기도 한다.

또한 어느 지점의 상태가 변경되었는지 효율적으로 확인하기위해 가상돔을 활용하여 diff 알고리즘을 통해 변경된 Element 만을 리렌더링 시킨다.

 

다음 글에서는 어떻게 가상돔을 활용하여 상태에 의해 변경이 필요한 Element 만 리렌더링 시킬지를 더 알아보자.

 

참고

리액트 useState 는 어떤 모습일까? - 김정환님 블로그 

Vanilla Javascript로 React UseState Hook 만들기 - 황준일님 블로그

profile

파크로그

@파크park

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!