리액트 컴포넌트들을 특정 방식으로 행동하게 만들기

Yujong Lee
Cross-Platform Korea
7 min readSep 1, 2021

리액트는 공식문서에서 cloneElement(), isValidElement(), 그리고React.Children이라는 API들을 소개한다. 리액트로 개발하면서 이것들을 사용할 일은 거의 없지만, 이것들은 종종 유용하고 각종 라이브러리에서 실제로 사용된다. 예를 들어 react-router에서도 이것들을 사용하고 있다.

먼저 간단한 예로 시작해보자.

이 Codesandbox는 리액트 경험이 적은 지인이 dooboo-ui컴포넌트 스타일링을 배우는 것을 도와주기 위해 만들어졌다. 원래 Button 컴포넌트는 disabledoutlined라는 prop을 받고 각 경우에 대해 다른 스타일을 보여준다.

이를 위해서는 button.js에서 상태관리를 해주거나, App.js에서 상태를 관리하고 이를 exampleButton으로 넘겨줘야 할 것이다. 하지만 나는 button.js에서 스타일링 외 요소들을 최대한 제거하고 싶었고, 이 목적을 cloneElement를 통해 달성할 수 있었다.

exampleButtoncloneElement의 첫 번째 인자로, disabled, outlined를 가진 객체를 두 번째 인자로 넘겼다. 이렇게 생성된 리액트 엘리먼트는 exampleButtondisabled, outlined prop이 추가된 것과 같다. 즉, exampleButtondisabled, outlined에 대해 전혀 신경 쓰지 않아도 된다.

위 코드에서 전자는 후자와 (거의) 같다. 차이점은 ref가 유지되느냐에 있다. 관련 내용은 공식문서를 참고하면 좋다. 또, React.createElement의 반환값이 일반 객체이고, 이것을 렌더하는 책임은 렌더러에게 있다는 것을 알고 있으면 React.cloneElement의 이해에 도움이 된다. 관련 내용은 UI 런타임으로서의 리액트를 참고하면 좋다.

그런데 이것이 컴포넌트들을 특정 방식으로 행동하게 만들기와 무슨 상관인가? 이것은 별 의미 없는 기교같은 것 아닌가?

꼭 그렇지는 않다. 다음 예시에서 이 글의 제목이 무슨 의미인지 이해할 수 있을 것이다. 최근 나는 다음과 같은 편집 가능한 아이템 목록을 만들고 있었다.

오른쪽 상단 버튼을 누르면 각 줄이 편집 가능해져야 한다.

이것은 별로 어려운 작업은 아니었지만, 필드 개수가 조금만 많아져도 코드가 복잡해졌고, 비슷한 작업을 반복하고 있다고 느껴졌다. 예를 들어, 각 줄이 editing 상태에 있는지 그렇지 않은지 관리하려면 useState 을 이용하여 [isEditing, setIsEditing]을 얻어야 한다.

그런데 줄이 여러 개면 어떨까? 이런 경우 코드 중복이나 복잡도는 재쳐두더라도 네이밍이 어려워진다. 단지 두 줄뿐인 위 사진의 예시에서도, [isEditingSaleInformations, setIsEditingSaleInformations], [isEditingStockNumber, setIsEditingStockNumber] 등으로 이름을 짓는 작업은 꽤나 피곤하다. 결정적으로는, 뭔가 좋지 않은 냄새(code smell)가 난다. 나는 이런 것을 구조 자체를 바꿔야 하는 신호로 받아들인다.

아래 Codesandbox는 Example1Example2로 이루어져 있다. Example1는 일반적인 구현이고, Example2Editable을 정의하여 Example1을 리팩터링한 것이다.

Editable 의 구현은 간단하다. 각 인스턴스는 자신만의 isEditing 상태를 가지고, 이를 적절히 업데이트하여 children에게 prop으로 제공한다. 따라서 상당한 양의 코드 중복이 제거되며, 네이밍 문제에서도 해방된다.

물론 이러한 구현에 언제나 장점만 있는 것은 아니다. 예를 들어 isEditing 상태가 감추어진다는 것이 그것이다. 이는 Editable 에서는 장점이 되지만, 곧 소개할 Selectable처럼 감추어진 상태 값에 접근해야 할 때는 단점이 될 수 있다. (Selectable에서 나는 이것을 이벤트 헨들러를 넘기는 것으로 해결했다.)

Editable 은 재미있는 구현이고, 나는 앞으로 비슷한 일을 할 때 이를 고려할 것이다. 그런데 여기서 끝내기는 아쉽다. 나는 종종 거리를 두고 내가 하는 일이 어떤 의미가 있는지 생각해보기를 즐긴다. 구체적인 구현과 추상을 오가는 것이다.

Editable이 하는 일의 핵심은 무엇일까? 이것이 무엇을 해주기에 내가 편안함을 느낄까? 나는 나름의 대답을 재사용성에 대해 생각하면서 얻었다.

리액트 renderProp은 렌더링 로직을, hook은 상태 관련 로직을, Presentational 컴포넌트는 뷰를 재사용 하는 것이다. 그리고, Editable에서 사용한 패턴은 prop 업데이트 로직을 재사용 하는 것이다.

이것을 이해하면, 더 복잡한 것도 시도해볼 수 있다. 예를 들어 아래 Codesandbox에서 정의한 Selectableprop 업데이트 로직을 singleChoicemultipleChoice라는 추상으로 제공하고 있다.

조금 복잡하지만, mode에 따라 다른 checked와 handlePress를 받는 부분에만 집중하면 된다.

코드가 Editable 보다 복잡해졌지만, 핵심은 같다. Selectable 안에서 children에게 어떤 props를 넘겨줄지 결정하고, 그것을 React.ChildrenReact.cloneElement를 이용해 제공한다.

SelectableEditable의 차이는 관리하는 상태를 노출하느냐에 있다. Editable을 사용하는 쪽은 isEditing 값이 필요하지 않을 수도 있지만, Selectable을 사용하는 쪽은 아마 무슨 아이템이 선택되었는지 알고 싶을 것이다. 이는 Selectable에 이벤트 헨들러를 넘기는 것으로 얻어진다.

Selectable은 자신에게 onPress가 제공되었다면 children으로 받은 요소들이 pressed될 때마다 선택된 요소(들)로 onPress를 호출한다.

Selectable를 사용하는 쪽도 선택된 요소(들)이 무엇인지 알 수 있다.

앞서 살펴본 예시들은 모두 prop 업데이트 로직을 재사용하는 형태였다. 나는 이것들이 가장 유용한 Use case라고 생각한다. 하지만 조금 다른 형태로 응용할 수도 있다. 다음 예시는 Submit 했을 때 다음 필드로 넘어가도록 하는 Submittable 인데, 여기서 Submittableprop 업데이트 로직보다는 prop 제공 로직을 재사용하기 위해 사용되었다고 할 수 있다.

Example1는 일반적인 구현이고, Example2Submittable을 정의하여 Example1을 리팩터링한 것이다. form1에서 엔터를 누르면 form2로, form2에서 엔터를 누르면 form3로 넘어간다.

Submittable으로 EditText들을 감싸는 것만으로, 각 필드는 다음 필드와 연결되었다. 각 필드에 올바른 inputRefonSubmitEditing을 제공하는 로직은 Submittable에 의해 재사용된다. 따라서 불필요한 반복과 실수에서 벗어날 수 있다.

물론 이러한 접근에는 분명한 한계점들이 있다. 이것은 children에 대한 암묵적인 가정을 포함하고 있으며, Redux같은 상태관리 도구를 도입하면 더 쉽게 해결할 수 있는 문제를 어려운 기교로 해결한다는 느낌도 있다. 그렇지만 훌륭한 해결책으로 발전할 가능성이 있고, 결정적으로 재미있다!

실제로 나는 최근 상태관리 도구를 사용하지 않는 회사 프로젝트에서 많은 항목이 있는 편집 가능한 필드들을 Editable으로 간결하게 해결했다. Selectabledooboo-uiPull Request를 보냈다가 해당 프로젝트에서는 크게 유용하지 않은 것 같아 close했었는데, 이 글을 쓰다 보니 다시 시도해봐야겠다는 생각이 든다.

  • 이 글의 예제에 사용된 dooboo-uiios, Android, Web에서 사용 가능한 초기 단계의 React Native UI Framework이다. 관심 있는 이들의 기여를 기다린다.
  • 이 글에서 소개하는 패턴은 RubyEnumerable mixinduck type의 영향을 받았다. checkedonPressprops에 존재한다면, 그것은 Selectable인 것이다.

--

--