Getting Closure on React Hooks

Contents

  • Introduction
  • Stateful Functions and Closure
  • Clone React

    • Simple useState
    • useSate with getter
    • React module with getter
    • Hooks array refactor
    • Make useEffect hook
    • import { createElement, render } from "./utils";
    • Custom hooks
  • This is NOT React!
  • References

Introduction

2019년 싱가폴에서 열린 JSConf Asia에 바닐라코딩의 "켄"님이 스피커로 세션을 진행하게 되었습니다. 하지만, 그는 너무나 바쁜 스케줄로 인해 개인적인 커리어 상에도 도움이 될 수 있는 이 세션 진행을 결국 포기하기로 했습니다. 저를 위시한 일부 친구들은 그의 세션을 함께 보고자 비싼 티켓을 구매했지만, 결국 그가 없는 JSConf Asia를 보러가게되는 일명 "켄통수" 사건이 발생했습니다. 하지만 의외로 수영도하고, 맛있는것도 먹고, 여러 사람들도 만나고, 해외 컨퍼런스도 경험해보는 즐거운 시간을 보내고 왔습니다. 싱가폴 컨퍼런스 이야기에 대해서는 나중에 별도로 글을 작성하고자 합니다.

JSConf Singapore 컨퍼런스의 세션 중 가장 인상깊었던 세션은 Netlify의 개발자로 있는 Shawn Wang의 "Getting Closure on React Hooks"였습니다. 리액트에서 Hooks를 도입해 기존에 클래스 컴포넌트를 사용할때 겪었던 여러 문제점을 해결할 수 있었습니다. 하지만, Hooks를 잘 사용하기 위해서는 closure에 대한 이해가 선행되어야 합니다. Shawn Wang은 이 세션에서 매우 간소화된 리액트 클론, Hooks 클론, 커스톰 Hooks를 라이브코딩하며 리액트, closure, Hooks에 대한 이해를 돕습니다.

Stateful Functions and Closure

아래 예시의 함수 add는 매 호출마다 내부적으로 state를 가지고, 그 state를 변형 후 반환합니다.

let foo = 1;
function add() {
  foo = foo + 1;
  return foo;
}
console.log(add()); // 2
console.log(add()); // 3
console.log(add()); // 4

하지만 foo라는 변수가 글로벌 스코프에 있기 때문에 위의 코드 중간에 글로벌 변수 foo에 대한 개입이 있으면 위 코드는 예상과 다르게 작동할 수 있습니다.

let foo = 1;
function add() {
  foo = foo + 1;
  return foo;
}
console.log(add()); // 2
foo = 100;
console.log(add()); // 101
console.log(add()); // 102

위 문제를 해결하기 위해서 우리는 글로벌 변수 foo를 add 함수 안의 스코프로 옮길 수 있습니다.

function add() {
  let foo = 1;
  foo = foo + 1;
  return foo;
}
console.log(add()); // 2
console.log(add()); // 2
console.log(add()); // 2

하지만, 이렇게 진행하면 statefull한 add 함수는 구현할 수 없고, 매번 같은 숫자 2를 log 합니다. 이 함수를 다시 statefull하게 만들기 위해서 아래와 같이 higher order function으로 변경할 수 있습니다.

function getAdd() {
  let foo = 1;
  return function() {
    foo = foo + 1;
    return foo;
  };
}

const add = getAdd();
console.log(add());
console.log(add());
console.log(add());

위와 같이 closure를 이용한 함수를 작성하면, statefull한 함수를 만들 수 있고 또한 함수 밖에서 변수 foo를 조작할 수도 없기 때문에 state를 보호할 수 있습니다. 함수 밖에서는 foo에 접근할 수 없고 add 함수는 호출 시 foo에 접근할 수 있습니다.

그리고 위의 코드를 아래와 같이 모듈 패턴으로 리팩토링 할 수 있습니다. 함수를 괄호로 감싸고 즉시 호출하는 IIFE를 사용했는데, 참고로 IIFE는 웹팩의 bundler output에서도 사용되고 있습니다.

const add = (function getAdd() {
  let foo = 1;
  return function() {
    foo = foo + 1;
    return foo;
  };
})();
console.log(add());
console.log(add());
console.log(add());

Clone React

Simple useState

리액트 Hooks의 useState는 초기 state를 인자로 취하고, 배열을 반환합니다. 배열의 첫 요소는 state value이며, 두 번째 요소는 state는 업데이트 할 수 있는 함수입니다. 그러면 Hooks의 useState를 아래와 같이 구현해 볼 수 있을 것이니다.

useState함수를 이용해 초깃값을 1로 설정한 후, log를 합니다. 그리고, setCount 함수를 이용해 state를 2로 변경하고 다시 log를 합니다. 하지만, 이 코드는 예상대로 작동하지 않습니다.

function useState(initVal) {
  let _val = initVal;
  const state = _val;
  const setState = newVal => {
    _val = newVal;
  };

  return [state, setState];
}

const [count, setCount] = useState(1);
console.log(count); // 1
setCount(2);
console.log(count); // 1

사실, 위의 setCount 함수는 제대로 작동을 합니다. 새로운 값을 인자로 받은 후 _val 변수를 업데이트 합니다. 위 코드의 문제는 useState를 호출 후 desctructuring 할 때 count 변수에 1을 할당하는 것입니다. 그리고 count에 할당된 1을 계속 log합니다.

useSate with getter

이 문제를 해결하기 위해서 가장 간단한 방법은 useState 함수 내부의 state를 getter 함수로 만드는 것입니다.

function useState(initVal) {
  let _val = initVal;
  const state = () => _val;
  const setState = newVal => {
    _val = newVal;
  };

  return [state, setState];
}

const [count, setCount] = useState(1);
console.log(count()); // 1
setCount(2);
console.log(count()); // 2

위와 같이, 매우 간단한 React의 useState 함수를 만들어 보았습니다. 실제 React의 useState를 호출 후 state 값을 얻기 위해서 위와 같은 함수를 호출하지 않고, 항상 최신의 state 값을 반환합니다. 그럼 우리도 이와 같이 코드를 리택토링 하도록 하겠습니다.

React module with getter

우선 우리가 위에서 작성한 useState Hook을 React Module을 만들어 감쌀 수 있습니다.

const React = (function() {
  function useState(initVal) {
    let _val = initVal;
    const state = () => _val;
    const setState = newVal => {
      _val = newVal;
    };

    return [state, setState];
  }
  return { useState };
})();

const [count, setCount] = React.useState(1);
console.log(count()); // 1
setCount(2);
console.log(count()); // 2

그리고 useState Hook을 사용하는 컴포넌트는 아래와 같이 만들 수 있습니다. Component 함수가 반환하는 render 메소드는 DOM에 렌더링을 해야하지만 여기서는 일단 console.log로 대체했습니다.

function Component() {
  const [count, setCount] = React.useState(1);
  return {
    render: () => console.log(count()),
    click: () => setCount(count() + 1)
  };
}

React가 Component를 렌더할 수 있도록 만들어줘야 하기 때문에, React Module에 Component를 인자로 받아 렌더하는 함수를 만들어 줍니다.

const React = (function() {
  function useState(initVal) {
    let _val = initVal;
    const state = () => _val;
    const setState = newVal => {
      _val = newVal;
    };

    return [state, setState];
  }
  function render(Component) {
    const C = Component();
    C.render();
    return C;
  }
  return { useState, render };
})();

React Module의 렌더 함수에서 컴포넌트를 렌더한 후, 다시 컴포넌트를 반환하기 때문에 이 Component 인스턴스를 가지고 우리는 추가 작업을 할 수 있습니다.

function Component() {
  const [count, setCount] = React.useState(1);
  return {
    render: () => console.log(count),
    click: () => setCount(count + 1)
  };
}

var App = React.render(Component); // logging =>  function state
App.click();
var App = React.render(Component); // logging => function state
App.click();

위에서 Component 함수의 반환된 객체의 render 메소드를 호출하면 count를 로그합니다. 하지만, 지금의 count는 React Module 내 state 함수입니다. 이것을 closure를 이용해 알맞은 count 값을 가지도록 React Module을 수정할 수 있습니다. 기존에 state 변수에 할당된 getter function은 삭제하고 _val의 값이 할당되도록 합니다.

const React = (function() {
  let _val;
  function useState(initVal) {
    _val = _val || initVal;
    const state = _val;
    const setState = newVal => {
      _val = newVal;
    };

    return [state, setState];
  }
  function render(Component) {
    const C = Component();
    C.render();
    return C;
  }
  return { useState, render };
})();

그리고 아래의 코드를 다시 실행하면, stateful한 값을 로그합니다.

function Component() {
  const [count, setCount] = React.useState(1);
  return {
    render: () => console.log(count),
    click: () => setCount(count + 1)
  };
}

var App = React.render(Component); // 1
App.click();
var App = React.render(Component); // 2
App.click();
var App = React.render(Component); // 3
App.click();
var App = React.render(Component); // 4
App.click();
var App = React.render(Component); // 5

여기까지 간단한 React와 Component, useState를 만들어 보았습니다. 간단한 앱이지만 문제 없이 작동하는 것 같습니다. 하지만, 아래와 같이 state를 추가한다면 또다른 이슈가 발생합니다.

function Component() {
  const [count, setCount] = React.useState(1);
  const [text, setText] = React.useState('apple');
  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: word => setText(word)
  };
}

var App = React.render(Component); // {count: 1, text: 1 }
App.click();
var App = React.render(Component); // {count: 2, text: 2 }
App.type('pear');
var App = React.render(Component); // {count: "pear", text: "pear" }

우리의 컴포넌트는 이제 두 개의 Hook을 가지고 있습니다. React Module 내에서 같은 메모리를 사용해 두 개의 state를 저장하는 상황이기 때문에 이전의 state는 지워지고 나중에 설정한 state만 남아서, count와 text의 값이 같게됩니다.

Hooks array refactor

위의 문제를 해결하기 위해 Hooks에 대한 관점의 폭을 넓혀, 하나의 원시값이 아닌 배열로 변경을 할 수 있습니다. 그리고, 배열 내 여러 Hooks 중 현재 진행중인 Hook을 알기 위해서 index 값을 별도록 생성합니다.

const React = (function() {
  let hooks = [];
  let idx = 0;
  function useState(initVal) {
    const state = hooks[idx] || initVal;
    const setState = newVal => {
      hooks[idx] = newVal;
    };

    // Hook의 호출이 끝나기 전에 index 값을 증가시킵니다.
    idx++;

    return [state, setState];
  }
  function render(Component) {
    // render가 새로되면 hooks를 처음부터 가져와야 하므로 index를 0으로 변경해 줍니다.
    idx = 0;
    const C = Component();
    C.render();
    return C;
  }
  return { useState, render };
})();

이렇게 변경을 하고 다시 컴포넌트를 렌더하면, 다른 문제를 발견할 수 있습니다.

function Component() {
  const [count, setCount] = React.useState(1);
  const [text, setText] = React.useState('apple');
  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: word => setText(word)
  };
}

var App = React.render(Component); // {count: 1, text: "apple" }
App.click();
var App = React.render(Component); // {count: 1, text: "apple" }
App.type('pear');
var App = React.render(Component); // {count: 1, text: "apple" }

위 코드에서 React Module의 setState는 render 후 비동기로 작동하며, setState가 실행될 때에는 setState 함수 안의 index 값이 변경된 상태입니다. 따라서, React 모듈의 useState 함수 내에서 setState에서 비동기 상태에서도 사용할 index 값을 fix해야 합니다.

const React = (function() {
  let hooks = [];
  let idx = 0;
  function useState(initVal) {
    const state = hooks[idx] || initVal;
    // index 값을 fix합니다.
    const _idx = idx;
    const setState = newVal => {
      hooks[_idx] = newVal;
    };

    // Hook의 호출이 끝나기 전에 index 값을 증가시킵니다.
    idx++;

    return [state, setState];
  }
  function render(Component) {
    // render가 새로되면 hooks를 처음부터 가져와야 하므로 index를 0으로 변경해 줍니다.
    idx = 0;
    const C = Component();
    C.render();
    return C;
  }
  return { useState, render };
})();

이렇게 하므로써, setState 함수 안에서 setState가 호출된 순간의 closure 영역의 _idx 값을 사용할 수 있습니다. 이제 우리의 Component 안에서 여러 개의 독립적인 state를 사용할 수 있습니다. 이것은 Hooks가 가져온 새로운 패러다임일 수 있습니다. 기존에는 state 한 개체에 필요한 모든 state를 담아두었지만, 지금은 독립적인 state를 이용해 좀 더 세세하게 작업을 할 수 있습니다.

function Component() {
  const [count, setCount] = React.useState(1);
  const [text, setText] = React.useState('apple');
  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: word => setText(word)
  };
}

var App = React.render(Component); // {count: 1, text: "apple" }
App.click();
var App = React.render(Component); // {count: 2, text: "apple" }
App.type('pear');
var App = React.render(Component); // {count: 2, text: "pear" }

React 공식 문서에서 Hooks는 조건에 따라 실행할 수 없고, 항상 같은 순서로 진행을 해야 한다고 소개합니다. 이러한 Hooks의 규칙은 위와 같이 Hooks의 순서에 맞게 index 값을 가지고 내부적으로 setState 등의 작업이 진행되기 때문입니다.

Make useEffect hook

여기까지 useState hook을 간단하게 구현해 보았습니다. 그러면 또다른 hook인 useEffect를 만들어 보도록 하겠습니다. useEffect는 첫 번째 인자로 callback을 받고, 두 번째 optional 인자로 dependency 배열을 받습니다.

function Component() {
  const [count, setCount] = React.useState(1);
  const [text, setText] = React.useState('apple');
  React.useEffect(
    () => {
      console.log('watcha!!');
    },
    [count]
  );
  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: word => setText(word)
  };
}

useEffect는 두 번째 인자로 받는 배열의 요소가 변경되면 callback을 호출합니다. 만약 두 번째 인자가 없으면, 매 렌더마다 callback을 실행합니다. 빈 배열을 받으면, callback을 한 번만 실행합니다. 그러면 React Module에 useEffect API를 추가해 보도록 하겠습니다.

const React = (function() {
  let hooks = [];
  let idx = 0;

  // .. 생략 ..
  function useEffect(cb, depArray) {
    const oldDeps = hooks[idx];
    let hasChanged = true;

    if (oldDeps) {
      hasChanged = depArray.some((dep, i) => !Object.is(dep, oldDeps[i]));
    }

    if (hasChanged) {
      cb();
    }

    hooks[idx] = depArray;
  }

  return { useState, render, useEffect };
})();

위와 같이 useEffect를 구현해 보았습니다. 위에서 구현한 Component에서 useEffect를 사용하고, dependency에 count나 text 또는 빈 배열을 추가하면 dependecy에 따라서 callback의 호출 횟수가 달라지는것을 확인할 수 있습니다.

import { createElement, render } from "./utils";

앞서 React의 useState, useEffect를 간단히 구현해 보았습니다. 하지만 렌더시 DOM에 화면을 그리지는 않고 console의 log로만 확인을 했죠. 다음으로 실제 DOM에 위에서 작성한 코드를 바탕으로 화면을 그리는 작업을 해 보겠습니다. 그러기 위해서 별도로 작성된 createElement 함수와 render 함수를 import 합니다. createElement 함수는 JSX를 바벨이 compile하는 것과 같은 역활을 합니다. render 함수는 higher order function으로 hooks를 메모이즈하고 DOM에 렌더를 해 줍니다.

React module에서 기존에 작성한 render 함수를 삭제하고, import한 render 함수로 대체합니다. 그리고 createElement, render 함수를 리턴하는 객체에 포함합니다. 그리고 Component에서는 기존의 객체 대신 JSX를 반환합니다.

const React = (function() {
  let hooks = [];
  let idx = 0;
  // ... 생략 ... //
  return { createElement, useState, render: render(hooks), useEffect };
})();

function Component() {
  const [count, setCount] = React.useState(1);
  const [text, setText] = React.useState('apple');
  React.useEffect(() => {
    console.log('watcha!!');
  });
  return (
    <main>
      <h1>Hello World</h1>
      <button onClick={() => setCount(count + 1)}>Click Me: {count}</button>
    </main>
  );
}

그리고 위에서 반환한 render 함수를 이용해 DOM에 최종적으로 렌더를 합니다. 그러면 드디어 DOM에 그려진 Hello World란 문구와 Click Me 버튼을 볼 수 있습니다~!! 짜잔 :)

React.render(<Component />, document.getElementById('root'));

하지만, Click Me 버튼을 눌러도 화면의 숫자 1이 증가하지 않습니다. 왜일까요?

React는 내부적으로 loop을 돌며, 진행해야 하는 작업이 있는지 체크를 합니다. 앞서 React를 간단히 구현했을 때, 우리는 아래와 같이 state가 변경된 후 직접 다시 렌더를 했습니다.

var App = React.render(Component); // {count: 1, text: "apple" }
App.click();
var App = React.render(Component); // {count: 2, text: "apple" }
App.type('pear');
var App = React.render(Component); // {count: 2, text: "pear" }

화면이 state가 변경 시 최신의 state를 보여주게 하기 위해서는 이 작업을 React Module 안에서 해 주어야 합니다. 그러면, React Module 내 아래와 같이 workloop 함수를 추가할 수 있습니다.

const React = (function() {
  let hooks = [];
  let idx = 0;

  // ... 생략 ... //

  function workLoop() {
    idx = 0;
    render(hooks)();
    setTimeout(workLoop, 300);
  }
  setTimeout(workLoop, 300);

  // ... 생략 ... //

  return { createElement, useState, render: render(hooks), useEffect };
})();

이제 버튼을 클릭하면, state가 반영된 숫자가 화면에 출력됩니다.

Custom hooks

매우 간소화된 React, useState, useEffect를 흉내내 보았습니다. 아직까지 built-in hooks만을 구현해 보았는데 이번에는 custom hook을 만들어 보도록 하겠습니다.

function useDogs(count) {
  const [(list, setList)] = React.useState([]);

  React.useEffect(() => {
    fetch(`https://dogceo.netlify.com/.netlify/functions/pics?count=${count}`)
    .then(x => x.json())
    .then(x => setList(x));
  }, [count]);

  return list;
}

위 useDog Hook은 list state를 가지고 있습니다. count 갯수에 따라 강아지 사진을 fetch하고 url을 반환합니다. 작성한 Custom Hook을 Component 안에서 사용해 봅시다.

function Component() {
  const [count, setCount] = React.useState(1);
  const list = useDogs(count);
  return (
    <main>
      <h1>Hello World</h1>
      <button onClick={() => setCount(count + 1)}>Click Me: {count}</button>
      {list.map(item => (
        <img src={item} />
      ))}
    </main>
  );
}

아래는 지금까지 작성한 전체 코드 입니다.

This is NOT React!

여기까지 적은 양의 코드로 React, Hooks를 간단히 구현해 보았습니다. 하지만, 이것은 리액트가 아닙니다. 그러면, 우리는 왜 리액트를 사용하는 것일까요?

References